From f10348e1dbd9963f8442c50b47476d9b5b528609 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 01:10:09 -0700 Subject: [PATCH 01/69] chore: ignore nested snap artifacts --- .gitignore | 3 +- .../calendar/domain/event_move_policy.dart | 51 ++ .../presentation/calendar_color_dialog.dart | 174 +++++ .../domain/event_recurrence_codec.dart | 431 ++++++++++++ .../recurrence/domain/recurrence_rule.dart | 602 ++++++++++++++++ .../presentation/recurrence_editor.dart | 654 ++++++++++++++++++ ...calendar_management_capabilities_test.dart | 67 ++ .../domain/event_move_policy_test.dart | 96 +++ .../calendar_color_dialog_test.dart | 49 ++ .../domain/event_recurrence_codec_test.dart | 228 ++++++ 10 files changed, 2354 insertions(+), 1 deletion(-) create mode 100644 lib/src/features/calendar/domain/event_move_policy.dart create mode 100644 lib/src/features/calendar/presentation/calendar_color_dialog.dart create mode 100644 lib/src/features/recurrence/domain/event_recurrence_codec.dart create mode 100644 lib/src/features/recurrence/domain/recurrence_rule.dart create mode 100644 lib/src/features/recurrence/presentation/recurrence_editor.dart create mode 100644 test/features/calendar/domain/calendar_management_capabilities_test.dart create mode 100644 test/features/calendar/domain/event_move_policy_test.dart create mode 100644 test/features/calendar/presentation/calendar_color_dialog_test.dart create mode 100644 test/features/recurrence/domain/event_recurrence_codec_test.dart diff --git a/.gitignore b/.gitignore index eb0e8ec8..3228ca41 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ app.*.map.json .snap-local/ # Local Snap packages contain compiled build-time configuration. -/*.snap +*.snap +**/*.snap diff --git a/lib/src/features/calendar/domain/event_move_policy.dart b/lib/src/features/calendar/domain/event_move_policy.dart new file mode 100644 index 00000000..24ef38ea --- /dev/null +++ b/lib/src/features/calendar/domain/event_move_policy.dart @@ -0,0 +1,51 @@ +import '../../../providers/busy_provider.dart'; +import '../presentation/event_editor_draft.dart'; + +enum CalendarEventMoveStrategy { none, googleNative, davNative, copyThenDelete } + +CalendarEventMoveStrategy calendarEventMoveStrategy({ + required String sourceAccountId, + required String sourceId, + required String sourceProviderCalendarId, + required BusyProvider sourceProvider, + required String? sourceDavCollectionId, + required String destinationAccountId, + required String destinationId, + required String destinationProviderCalendarId, + required BusyProvider destinationProvider, + required String? destinationDavCollectionId, + required String? eventType, + required bool recurring, + required RecurringEventMutationScope? recurringScope, +}) { + final changed = + sourceAccountId != destinationAccountId || + sourceId != destinationId || + sourceProviderCalendarId != destinationProviderCalendarId || + sourceProvider != destinationProvider; + if (!changed) return CalendarEventMoveStrategy.none; + + final movesWholeResource = + !recurring || recurringScope == RecurringEventMutationScope.entireSeries; + if (!movesWholeResource || sourceAccountId != destinationAccountId) { + return CalendarEventMoveStrategy.copyThenDelete; + } + + if (sourceProvider == BusyProvider.google && + destinationProvider == BusyProvider.google && + (eventType == null || eventType.isEmpty || eventType == 'default')) { + return CalendarEventMoveStrategy.googleNative; + } + + final davProvider = + sourceProvider == BusyProvider.nextcloud || + sourceProvider == BusyProvider.appleICloud; + if (davProvider && + destinationProvider == sourceProvider && + sourceDavCollectionId != null && + destinationDavCollectionId != null) { + return CalendarEventMoveStrategy.davNative; + } + + return CalendarEventMoveStrategy.copyThenDelete; +} diff --git a/lib/src/features/calendar/presentation/calendar_color_dialog.dart b/lib/src/features/calendar/presentation/calendar_color_dialog.dart new file mode 100644 index 00000000..58ab9360 --- /dev/null +++ b/lib/src/features/calendar/presentation/calendar_color_dialog.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; + +import '../../../app/busymax_dialogs.dart'; +import '../../../app/busymax_design.dart'; +import '../../../calendar_providers/calendar_colors.dart'; +import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_service.dart'; +import '../../../providers/busy_provider.dart'; + +Future showCalendarColorDialog( + BuildContext context, { + required BusyProvider provider, + required String? currentBackgroundColor, + required String? currentColorId, + required LinuxHeaderBarService headerBarService, +}) { + return showBusyMaxModalDialog( + context, + headerBarService: headerBarService, + barrierDismissible: false, + builder: (dialogContext) => _CalendarColorDialog( + provider: provider, + currentBackgroundColor: currentBackgroundColor, + currentColorId: currentColorId, + ), + ); +} + +class _CalendarColorDialog extends StatefulWidget { + const _CalendarColorDialog({ + required this.provider, + required this.currentBackgroundColor, + required this.currentColorId, + }); + + final BusyProvider provider; + final String? currentBackgroundColor; + final String? currentColorId; + + @override + State<_CalendarColorDialog> createState() => _CalendarColorDialogState(); +} + +class _CalendarColorDialogState extends State<_CalendarColorDialog> { + late final List _choices; + CalendarColorChoice? _selected; + + @override + void initState() { + super.initState(); + _choices = calendarColorChoices(widget.provider); + _selected = switch (widget.provider) { + BusyProvider.google => _findChoice( + (choice) => + _sameHex(choice.backgroundColor, widget.currentBackgroundColor), + ), + BusyProvider.microsoft => _findChoice( + (choice) => choice.providerValue == widget.currentColorId, + ), + BusyProvider.appleICloud || BusyProvider.nextcloud => null, + }; + } + + CalendarColorChoice? _findChoice( + bool Function(CalendarColorChoice choice) matches, + ) { + for (final choice in _choices) { + if (matches(choice)) return choice; + } + return null; + } + + @override + Widget build(BuildContext context) { + return BusyMaxDialogShell( + title: context.l10n.calendarColor, + maxWidth: 360, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.l10n.cancel), + ), + BusyMaxPushButton.suggested( + onPressed: _selected == null + ? null + : () => Navigator.of(context).pop(_selected), + child: Text(context.l10n.save), + ), + ], + children: [ + Wrap( + alignment: WrapAlignment.center, + spacing: BusyMaxSpacing.sm, + runSpacing: BusyMaxSpacing.sm, + children: [ + for (var index = 0; index < _choices.length; index += 1) + _CalendarColorSwatch( + choice: _choices[index], + label: context.l10n.calendarColorOption(index + 1), + selected: _selected == _choices[index], + onSelected: () => setState(() => _selected = _choices[index]), + ), + ], + ), + ], + ); + } +} + +class _CalendarColorSwatch extends StatelessWidget { + const _CalendarColorSwatch({ + required this.choice, + required this.label, + required this.selected, + required this.onSelected, + }); + + final CalendarColorChoice choice; + final String label; + final bool selected; + final VoidCallback onSelected; + + @override + Widget build(BuildContext context) { + final color = _parseColor(choice.backgroundColor); + final checkColor = + ThemeData.estimateBrightnessForColor(color) == Brightness.dark + ? Colors.white + : Colors.black; + return Semantics( + label: label, + selected: selected, + button: true, + child: Tooltip( + message: label, + child: InkResponse( + onTap: onSelected, + radius: 27, + customBorder: const CircleBorder(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 44, + height: 44, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outlineVariant, + width: selected ? 3 : 1, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: selected + ? Icon(Icons.check, color: checkColor, size: 20) + : null, + ), + ), + ), + ), + ); + } +} + +Color _parseColor(String value) { + final normalized = value.replaceFirst('#', ''); + return Color(int.parse('ff$normalized', radix: 16)); +} + +bool _sameHex(String first, String? second) { + return second != null && first.toLowerCase() == second.toLowerCase(); +} diff --git a/lib/src/features/recurrence/domain/event_recurrence_codec.dart b/lib/src/features/recurrence/domain/event_recurrence_codec.dart new file mode 100644 index 00000000..33eb1250 --- /dev/null +++ b/lib/src/features/recurrence/domain/event_recurrence_codec.dart @@ -0,0 +1,431 @@ +import '../../../providers/busy_provider.dart'; +import 'recurrence_rule.dart'; + +final class RecurrenceRuleLimits { + const RecurrenceRuleLimits({ + required this.ordinalPositions, + required this.allowMultipleMonthDays, + required this.allowMultipleMonths, + }); + + static const rfc5545 = RecurrenceRuleLimits( + ordinalPositions: {1, 2, 3, 4, 5, -2, -1}, + allowMultipleMonthDays: true, + allowMultipleMonths: true, + ); + + static const microsoftGraph = RecurrenceRuleLimits( + ordinalPositions: {1, 2, 3, 4, -1}, + allowMultipleMonthDays: false, + allowMultipleMonths: false, + ); + + final Set ordinalPositions; + final bool allowMultipleMonthDays; + final bool allowMultipleMonths; + + bool supports(RecurrenceRule rule) { + if (!rule.isSupported || rule.interval < 1 || rule.interval > 366) { + return false; + } + if (!rule.repeats) return true; + if (rule.count case final count? when count < 1 || count > 3500) { + return false; + } + if (rule.count != null && rule.untilRaw != null) return false; + if (!rfcWeekdays.contains(rule.weekStart) || + rule.byDay.toSet().length != rule.byDay.length || + rule.byMonth.toSet().length != rule.byMonth.length || + rule.byMonthDay.toSet().length != rule.byMonthDay.length || + rule.byMonth.any((value) => value < 1 || value > 12) || + rule.byMonthDay.any((value) => value < 1 || value > 31)) { + return false; + } + switch (rule.frequency) { + case RecurrenceFrequency.none: + return true; + case RecurrenceFrequency.daily: + return rule.byDay.isEmpty && + rule.byMonth.isEmpty && + rule.byMonthDay.isEmpty && + rule.bySetPosition == null; + case RecurrenceFrequency.weekly: + return rule.byDay.isNotEmpty && + rule.byDay.every(rfcWeekdays.contains) && + rule.byMonth.isEmpty && + rule.byMonthDay.isEmpty && + rule.bySetPosition == null; + case RecurrenceFrequency.monthly: + return rule.byMonth.isEmpty && _supportsMonthlyShape(rule); + case RecurrenceFrequency.yearly: + return rule.byMonth.isNotEmpty && + (allowMultipleMonths || rule.byMonth.length == 1) && + _supportsMonthlyShape(rule); + } + } + + bool _supportsMonthlyShape(RecurrenceRule rule) { + if (rule.byMonthDay.isNotEmpty) { + return rule.byDay.isEmpty && + rule.bySetPosition == null && + (allowMultipleMonthDays || rule.byMonthDay.length == 1); + } + final position = rule.bySetPosition; + return position != null && + ordinalPositions.contains(position) && + rule.byDay.isNotEmpty && + _isEditableOrdinalDaySet(rule.byDay); + } +} + +abstract final class EventRecurrenceCodec { + static RecurrenceRuleLimits limitsFor(BusyProvider provider) { + return provider == BusyProvider.microsoft + ? RecurrenceRuleLimits.microsoftGraph + : RecurrenceRuleLimits.rfc5545; + } + + static RecurrenceRule decode( + BusyProvider provider, + Object? value, { + DateTime? baseDate, + }) { + return switch (provider) { + BusyProvider.microsoft => _decodeMicrosoft(value, baseDate: baseDate), + BusyProvider.google || + BusyProvider.appleICloud || + BusyProvider.nextcloud => _decodeIcalendar(value, baseDate: baseDate), + }; + } + + static bool canEncode(BusyProvider provider, RecurrenceRule rule) { + return limitsFor(provider).supports(rule); + } + + static Object? encode( + BusyProvider provider, + RecurrenceRule rule, { + required DateTime baseDate, + required bool allDay, + String? timeZone, + Object? original, + }) { + if (!canEncode(provider, rule)) { + throw StateError( + '${provider.displayName} cannot represent this recurrence rule.', + ); + } + if (!rule.repeats) return null; + return switch (provider) { + BusyProvider.google => [ + 'RRULE:${rule.toRrule()}', + ..._googleAncillaryLines(original), + ], + BusyProvider.appleICloud || BusyProvider.nextcloud => { + 'rules': [rule.toRrule()], + 'dates': const [], + 'excludedDates': const [], + }, + BusyProvider.microsoft => _encodeMicrosoft( + rule, + baseDate: baseDate, + timeZone: timeZone, + original: original, + ), + }; + } +} + +RecurrenceRule _decodeIcalendar(Object? value, {DateTime? baseDate}) { + if (value == null) return const RecurrenceRule.none(); + final rules = []; + final dates = []; + final excludedDates = []; + if (value is List) { + for (final item in value) { + final line = item.toString().trim(); + final separator = line.indexOf(':'); + if (separator <= 0 || separator == line.length - 1) { + return RecurrenceRule.unsupported(rawRules: [line]); + } + final property = line.substring(0, separator).toUpperCase(); + final propertyName = property.split(';').first; + final rawValue = line.substring(separator + 1); + switch (propertyName) { + case 'RRULE': + rules.add(rawValue); + case 'RDATE': + dates.add(line); + case 'EXDATE': + excludedDates.add(line); + default: + return RecurrenceRule.unsupported(rawRules: [line]); + } + } + } else if (value is Map) { + try { + rules.addAll(_stringList(value['rules'])); + dates.addAll(_stringList(value['dates'])); + excludedDates.addAll(_stringList(value['excludedDates'])); + } on FormatException { + return const RecurrenceRule.unsupported(); + } + } else { + return const RecurrenceRule.unsupported(); + } + if (dates.isNotEmpty || excludedDates.isNotEmpty) { + return RecurrenceRule.unsupported( + rawRules: rules, + recurrenceDates: dates, + exceptionDates: excludedDates, + ); + } + return RecurrenceRule.fromIcalendar(rules: rules, baseDate: baseDate); +} + +RecurrenceRule _decodeMicrosoft(Object? value, {DateTime? baseDate}) { + if (value == null) return const RecurrenceRule.none(); + if (value is! Map) return const RecurrenceRule.unsupported(); + final patternValue = value['pattern']; + final rangeValue = value['range']; + if (patternValue is! Map || rangeValue is! Map) { + return const RecurrenceRule.unsupported(); + } + final pattern = patternValue.cast(); + final range = rangeValue.cast(); + final type = pattern['type']?.toString(); + final interval = _positiveInt(pattern['interval']); + if (interval == null) return const RecurrenceRule.unsupported(); + final graphDays = _stringListOrEmpty(pattern['daysOfWeek']); + final byDay = [ + for (final day in graphDays) + if (_graphDayToRfc(day) case final value?) value, + ]; + if (byDay.length != graphDays.length) { + return const RecurrenceRule.unsupported(); + } + final frequency = switch (type) { + 'daily' => RecurrenceFrequency.daily, + 'weekly' => RecurrenceFrequency.weekly, + 'absoluteMonthly' || 'relativeMonthly' => RecurrenceFrequency.monthly, + 'absoluteYearly' || 'relativeYearly' => RecurrenceFrequency.yearly, + _ => null, + }; + if (frequency == null) return const RecurrenceRule.unsupported(); + final relative = type == 'relativeMonthly' || type == 'relativeYearly'; + final absolute = type == 'absoluteMonthly' || type == 'absoluteYearly'; + final dayOfMonth = _positiveInt(pattern['dayOfMonth']); + final month = _positiveInt(pattern['month']); + final position = relative + ? _graphIndex(pattern['index']?.toString() ?? 'first') + : null; + final rangeType = range['type']?.toString(); + final startDate = _basicDateOrNull(range['startDate']?.toString()); + final count = rangeType == 'numbered' + ? _positiveInt(range['numberOfOccurrences']) + : null; + final endDate = rangeType == 'endDate' + ? _basicDateOrNull(range['endDate']?.toString()) + : null; + if (!const {'noEnd', 'numbered', 'endDate'}.contains(rangeType) || + startDate == null || + (rangeType == 'numbered' && count == null) || + (rangeType == 'endDate' && + (endDate == null || endDate.compareTo(startDate) < 0))) { + return const RecurrenceRule.unsupported(); + } + final weekStart = type == 'weekly' + ? _graphDayToRfc(pattern['firstDayOfWeek']?.toString() ?? 'sunday') + : 'MO'; + if (weekStart == null) return const RecurrenceRule.unsupported(); + final rule = RecurrenceRule( + frequency: frequency, + interval: interval, + byDay: type == 'weekly' || relative ? List.unmodifiable(byDay) : const [], + byMonth: type == 'absoluteYearly' || type == 'relativeYearly' + ? [if (month != null) month] + : const [], + byMonthDay: absolute ? [if (dayOfMonth != null) dayOfMonth] : const [], + bySetPosition: position, + weekStart: weekStart, + count: count, + untilRaw: endDate, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); + if (!RecurrenceRuleLimits.microsoftGraph.supports(rule)) { + return const RecurrenceRule.unsupported(); + } + return rule; +} + +Map _encodeMicrosoft( + RecurrenceRule rule, { + required DateTime baseDate, + required String? timeZone, + required Object? original, +}) { + final relative = rule.bySetPosition != null; + final type = switch (rule.frequency) { + RecurrenceFrequency.daily => 'daily', + RecurrenceFrequency.weekly => 'weekly', + RecurrenceFrequency.monthly => + relative ? 'relativeMonthly' : 'absoluteMonthly', + RecurrenceFrequency.yearly => + relative ? 'relativeYearly' : 'absoluteYearly', + RecurrenceFrequency.none => throw StateError( + 'A non-repeating rule cannot be encoded.', + ), + }; + final pattern = { + 'type': type, + 'interval': rule.interval, + if (rule.frequency == RecurrenceFrequency.weekly) ...{ + 'daysOfWeek': rule.byDay.map(_rfcDayToGraph).toList(), + 'firstDayOfWeek': _rfcDayToGraph(rule.weekStart), + }, + if (rule.frequency == RecurrenceFrequency.monthly && !relative) + 'dayOfMonth': rule.byMonthDay.single, + if (rule.frequency == RecurrenceFrequency.yearly) ...{ + 'month': rule.byMonth.single, + if (!relative) 'dayOfMonth': rule.byMonthDay.single, + }, + if (relative) ...{ + 'daysOfWeek': rule.byDay.map(_rfcDayToGraph).toList(), + 'index': _rfcPositionToGraph(rule.bySetPosition!), + }, + }; + final endDate = rule.untilDateFor(timeZone: timeZone); + final recurrenceTimeZone = _microsoftRecurrenceTimeZone(original); + final range = { + 'type': rule.count != null + ? 'numbered' + : endDate != null + ? 'endDate' + : 'noEnd', + 'startDate': _isoDate(baseDate), + if (rule.count != null) 'numberOfOccurrences': rule.count, + if (rule.count == null && endDate != null) 'endDate': endDate, + if (recurrenceTimeZone != null) 'recurrenceTimeZone': recurrenceTimeZone, + }; + return {'pattern': pattern, 'range': range}; +} + +String? _microsoftRecurrenceTimeZone(Object? original) { + if (original is! Map) return null; + final range = original['range']; + if (range is! Map) return null; + final value = range['recurrenceTimeZone']?.toString().trim(); + return value == null || value.isEmpty ? null : value; +} + +List _googleAncillaryLines(Object? original) { + if (original is! List) return const []; + return [ + for (final item in original) + if (!item.toString().trimLeft().toUpperCase().startsWith('RRULE:')) + item.toString(), + ]; +} + +List _stringList(Object? value) { + if (value == null) return const []; + if (value is! List) throw const FormatException(); + return [for (final item in value) item.toString()]; +} + +List _stringListOrEmpty(Object? value) { + try { + return _stringList(value); + } on FormatException { + return const []; + } +} + +int? _positiveInt(Object? value) { + final parsed = value is int ? value : int.tryParse(value?.toString() ?? ''); + return parsed != null && parsed > 0 ? parsed : null; +} + +String? _basicDateOrNull(String? value) { + if (value == null || !RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) { + return null; + } + final parsed = DateTime.tryParse(value); + if (parsed == null || _isoDate(parsed) != value) return null; + return '${parsed.year.toString().padLeft(4, '0')}' + '${parsed.month.toString().padLeft(2, '0')}' + '${parsed.day.toString().padLeft(2, '0')}'; +} + +String _isoDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + +String? _graphDayToRfc(String value) => switch (value.toLowerCase()) { + 'monday' => 'MO', + 'tuesday' => 'TU', + 'wednesday' => 'WE', + 'thursday' => 'TH', + 'friday' => 'FR', + 'saturday' => 'SA', + 'sunday' => 'SU', + _ => null, +}; + +String _rfcDayToGraph(String value) => switch (value) { + 'MO' => 'monday', + 'TU' => 'tuesday', + 'WE' => 'wednesday', + 'TH' => 'thursday', + 'FR' => 'friday', + 'SA' => 'saturday', + 'SU' => 'sunday', + _ => throw StateError('Invalid RFC weekday: $value'), +}; + +int? _graphIndex(String? value) => switch (value) { + 'first' => 1, + 'second' => 2, + 'third' => 3, + 'fourth' => 4, + 'last' => -1, + _ => null, +}; + +String _rfcPositionToGraph(int value) => switch (value) { + 1 => 'first', + 2 => 'second', + 3 => 'third', + 4 => 'fourth', + -1 => 'last', + _ => throw StateError('Invalid Microsoft recurrence position: $value'), +}; + +bool _isEditableOrdinalDaySet(List values) { + return ordinalDaySets.any((candidate) => _sameSet(values, candidate)); +} + +bool _sameSet(List left, List right) { + return left.length == right.length && + left.toSet().length == left.length && + left.toSet().containsAll(right); +} + +const rfcWeekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; + +const ordinalDaySets = >[ + ['MO'], + ['TU'], + ['WE'], + ['TH'], + ['FR'], + ['SA'], + ['SU'], + ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], + ['MO', 'TU', 'WE', 'TH', 'FR'], + ['SA', 'SU'], +]; diff --git a/lib/src/features/recurrence/domain/recurrence_rule.dart b/lib/src/features/recurrence/domain/recurrence_rule.dart new file mode 100644 index 00000000..cd66edc1 --- /dev/null +++ b/lib/src/features/recurrence/domain/recurrence_rule.dart @@ -0,0 +1,602 @@ +import 'dart:convert'; + +import '../../../core/time/provider_date_time.dart'; + +enum RecurrenceFrequency { none, daily, weekly, monthly, yearly } + +/// The recurrence subset BusyMax can edit without losing provider data. +/// +/// Rules outside that subset remain available through [rawRules] and are not +/// rewritten. The editable subset covers the RFC 5545 fields shared by the +/// supported calendar and task providers. +final class RecurrenceRule { + const RecurrenceRule({ + required this.frequency, + required this.interval, + required this.byDay, + required this.byMonth, + required this.byMonthDay, + required this.bySetPosition, + this.weekStart = 'MO', + required this.count, + required this.untilRaw, + required this.recurrenceDates, + required this.exceptionDates, + required this.rawRules, + required this.isSupported, + }); + + const RecurrenceRule.none() + : frequency = RecurrenceFrequency.none, + interval = 1, + byDay = const [], + byMonth = const [], + byMonthDay = const [], + bySetPosition = null, + weekStart = 'MO', + count = null, + untilRaw = null, + recurrenceDates = const [], + exceptionDates = const [], + rawRules = const [], + isSupported = true; + + factory RecurrenceRule.fromJson(String? source, {DateTime? baseDate}) { + if (source == null || source.isEmpty) { + return const RecurrenceRule.none(); + } + try { + final decoded = jsonDecode(source); + if (decoded is! Map) return _unsupported(const [], const [], const []); + final map = decoded.cast(); + final rules = _strings(map['rules']); + final dates = _strings(map['dates']); + final excludedDates = _strings(map['excludedDates']); + if (rules.isEmpty) { + return RecurrenceRule( + frequency: RecurrenceFrequency.none, + interval: 1, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + weekStart: 'MO', + count: null, + untilRaw: null, + recurrenceDates: dates, + exceptionDates: excludedDates, + rawRules: rules, + isSupported: true, + ); + } + if (rules.length != 1) return _unsupported(rules, dates, excludedDates); + final parts = {}; + for (final segment in rules.single.split(';')) { + final separator = segment.indexOf('='); + if (separator <= 0 || separator == segment.length - 1) { + return _unsupported(rules, dates, excludedDates); + } + final key = segment.substring(0, separator).toUpperCase(); + if (parts.containsKey(key) || !_supportedParts.contains(key)) { + return _unsupported(rules, dates, excludedDates); + } + parts[key] = segment.substring(separator + 1).toUpperCase(); + } + final frequency = switch (parts['FREQ']) { + 'DAILY' => RecurrenceFrequency.daily, + 'WEEKLY' => RecurrenceFrequency.weekly, + 'MONTHLY' => RecurrenceFrequency.monthly, + 'YEARLY' => RecurrenceFrequency.yearly, + _ => null, + }; + final interval = int.tryParse(parts['INTERVAL'] ?? '1'); + final count = parts['COUNT'] == null + ? null + : int.tryParse(parts['COUNT']!); + final byDay = _csv(parts['BYDAY']); + final byMonth = _integers(parts['BYMONTH']); + final byMonthDay = _integers(parts['BYMONTHDAY']); + final bySetPositions = _integers(parts['BYSETPOS']); + final weekStart = parts['WKST'] ?? 'MO'; + final until = parts['UNTIL']; + final valid = + frequency != null && + interval != null && + interval >= 1 && + interval <= 366 && + (count == null || (count >= 1 && count <= 3500)) && + !(count != null && until != null) && + (until == null || _validUntil(until)) && + byMonth.every((value) => value >= 1 && value <= 12) && + byMonthDay.every( + (value) => value != 0 && value >= -31 && value <= 31, + ) && + bySetPositions.length <= 1 && + bySetPositions.every( + (value) => value != 0 && value >= -366 && value <= 366, + ) && + _plainWeekdays.contains(weekStart); + if (!valid) return _unsupported(rules, dates, excludedDates); + final supportedFrequency = frequency; + final supportedInterval = interval; + + final normalized = _normalizeEditorRule( + frequency: supportedFrequency, + byDay: byDay, + byMonth: byMonth, + byMonthDay: byMonthDay, + bySetPositions: bySetPositions, + baseDate: baseDate, + ); + if (normalized == null) { + return _unsupported(rules, dates, excludedDates); + } + return RecurrenceRule( + frequency: supportedFrequency, + interval: supportedInterval, + byDay: List.unmodifiable(normalized.byDay), + byMonth: List.unmodifiable(normalized.byMonth), + byMonthDay: List.unmodifiable(normalized.byMonthDay), + bySetPosition: normalized.bySetPosition, + weekStart: weekStart, + count: count, + untilRaw: until, + recurrenceDates: List.unmodifiable(dates), + exceptionDates: List.unmodifiable(excludedDates), + rawRules: List.unmodifiable(rules), + isSupported: true, + ); + } on Object { + return _unsupported(const [], const [], const []); + } + } + + factory RecurrenceRule.fromIcalendar({ + required List rules, + List recurrenceDates = const [], + List exceptionDates = const [], + DateTime? baseDate, + }) { + return RecurrenceRule.fromJson( + jsonEncode({ + 'rules': rules, + 'dates': recurrenceDates, + 'excludedDates': exceptionDates, + }), + baseDate: baseDate, + ); + } + + const RecurrenceRule.unsupported({ + this.rawRules = const [], + this.recurrenceDates = const [], + this.exceptionDates = const [], + }) : frequency = RecurrenceFrequency.none, + interval = 1, + byDay = const [], + byMonth = const [], + byMonthDay = const [], + bySetPosition = null, + weekStart = 'MO', + count = null, + untilRaw = null, + isSupported = false; + + final RecurrenceFrequency frequency; + final int interval; + final List byDay; + final List byMonth; + final List byMonthDay; + final int? bySetPosition; + final String weekStart; + final int? count; + final String? untilRaw; + final List recurrenceDates; + final List exceptionDates; + final List rawRules; + final bool isSupported; + + bool get repeats => frequency != RecurrenceFrequency.none; + + String? get untilDate => untilDateFor(); + + String? untilDateFor({String? timeZone}) { + final value = untilRaw; + if (value == null || value.length < 8) return null; + final parsed = _parseUntil(value); + if (parsed == null) return null; + final display = value.endsWith('Z') + ? providerUtcInstantAsWallTime(parsed, timeZone) + : parsed; + return '${display.year.toString().padLeft(4, '0')}-' + '${display.month.toString().padLeft(2, '0')}-' + '${display.day.toString().padLeft(2, '0')}'; + } + + RecurrenceRule copyWith({ + RecurrenceFrequency? frequency, + int? interval, + List? byDay, + List? byMonth, + List? byMonthDay, + Object? bySetPosition = _unchanged, + String? weekStart, + Object? count = _unchanged, + Object? untilRaw = _unchanged, + List? recurrenceDates, + List? exceptionDates, + }) => RecurrenceRule( + frequency: frequency ?? this.frequency, + interval: interval ?? this.interval, + byDay: List.unmodifiable(byDay ?? this.byDay), + byMonth: List.unmodifiable(byMonth ?? this.byMonth), + byMonthDay: List.unmodifiable(byMonthDay ?? this.byMonthDay), + bySetPosition: bySetPosition == _unchanged + ? this.bySetPosition + : bySetPosition as int?, + weekStart: weekStart ?? this.weekStart, + count: count == _unchanged ? this.count : count as int?, + untilRaw: untilRaw == _unchanged ? this.untilRaw : untilRaw as String?, + recurrenceDates: List.unmodifiable(recurrenceDates ?? this.recurrenceDates), + exceptionDates: List.unmodifiable(exceptionDates ?? this.exceptionDates), + rawRules: const [], + isSupported: true, + ); + + RecurrenceRule withUntilDate( + String? date, { + required bool allDay, + bool floating = false, + DateTime? baseDate, + String? timeZone, + }) { + if (date == null || date.isEmpty) return copyWith(untilRaw: null); + final parsed = DateTime.tryParse(date); + if (parsed == null) return this; + final wall = DateTime( + parsed.year, + parsed.month, + parsed.day, + baseDate?.hour ?? 0, + baseDate?.minute ?? 0, + baseDate?.second ?? 0, + ); + final basic = _basicDate(wall); + if (allDay) return copyWith(untilRaw: basic); + if (floating) { + return copyWith( + untilRaw: + '${basic}T' + '${wall.hour.toString().padLeft(2, '0')}' + '${wall.minute.toString().padLeft(2, '0')}' + '${wall.second.toString().padLeft(2, '0')}', + ); + } + final utc = + providerDateTimeAsUtcInstant(wall.toIso8601String(), timeZone) ?? + wall.toUtc(); + return copyWith( + untilRaw: + '${_basicDate(utc)}T' + '${utc.hour.toString().padLeft(2, '0')}' + '${utc.minute.toString().padLeft(2, '0')}' + '${utc.second.toString().padLeft(2, '0')}Z', + ); + } + + String toJsonString() => jsonEncode({ + 'rules': repeats ? [toRrule()] : const [], + 'dates': recurrenceDates, + 'excludedDates': exceptionDates, + }); + + String toRrule() { + if (!isSupported || !repeats) { + throw StateError('Only a supported repeating rule can be serialized.'); + } + final result = [ + 'FREQ=${frequency.name.toUpperCase()}', + 'INTERVAL=$interval', + if (byDay.isNotEmpty) 'BYDAY=${byDay.join(',')}', + if (byMonth.isNotEmpty) 'BYMONTH=${byMonth.join(',')}', + if (byMonthDay.isNotEmpty) 'BYMONTHDAY=${byMonthDay.join(',')}', + if (bySetPosition != null) 'BYSETPOS=$bySetPosition', + if (frequency == RecurrenceFrequency.weekly) 'WKST=$weekStart', + if (count != null) 'COUNT=$count', + if (untilRaw != null) 'UNTIL=$untilRaw', + ]; + return result.join(';'); + } + + @override + bool operator ==(Object other) { + return other is RecurrenceRule && + other.frequency == frequency && + other.interval == interval && + _listEquals(other.byDay, byDay) && + _listEquals(other.byMonth, byMonth) && + _listEquals(other.byMonthDay, byMonthDay) && + other.bySetPosition == bySetPosition && + other.weekStart == weekStart && + other.count == count && + other.untilRaw == untilRaw && + _listEquals(other.recurrenceDates, recurrenceDates) && + _listEquals(other.exceptionDates, exceptionDates) && + _listEquals(other.rawRules, rawRules) && + other.isSupported == isSupported; + } + + @override + int get hashCode => Object.hash( + frequency, + interval, + Object.hashAll(byDay), + Object.hashAll(byMonth), + Object.hashAll(byMonthDay), + bySetPosition, + weekStart, + count, + untilRaw, + Object.hashAll(recurrenceDates), + Object.hashAll(exceptionDates), + Object.hashAll(rawRules), + isSupported, + ); +} + +const _supportedParts = { + 'FREQ', + 'INTERVAL', + 'BYDAY', + 'BYMONTH', + 'BYMONTHDAY', + 'BYSETPOS', + 'WKST', + 'COUNT', + 'UNTIL', +}; + +const _unchanged = Object(); + +RecurrenceRule _unsupported( + List rules, + List dates, + List excludedDates, +) => RecurrenceRule( + frequency: RecurrenceFrequency.none, + interval: 1, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + weekStart: 'MO', + count: null, + untilRaw: null, + recurrenceDates: List.unmodifiable(dates), + exceptionDates: List.unmodifiable(excludedDates), + rawRules: List.unmodifiable(rules), + isSupported: false, +); + +List _strings(Object? source) { + if (source == null) return const []; + if (source is! List) throw const FormatException(); + return [for (final value in source) value.toString()]; +} + +List _csv(String? source) => source == null || source.isEmpty + ? const [] + : source.split(',').where((value) => value.isNotEmpty).toList(); + +List _integers(String? source) { + final values = _csv(source); + final result = []; + for (final value in values) { + final parsed = int.tryParse(value); + if (parsed == null) throw const FormatException(); + result.add(parsed); + } + return result; +} + +typedef _NormalizedEditorRule = ({ + List byDay, + List byMonth, + List byMonthDay, + int? bySetPosition, +}); + +_NormalizedEditorRule? _normalizeEditorRule({ + required RecurrenceFrequency frequency, + required List byDay, + required List byMonth, + required List byMonthDay, + required List bySetPositions, + required DateTime? baseDate, +}) { + switch (frequency) { + case RecurrenceFrequency.none: + return null; + case RecurrenceFrequency.daily: + if (byDay.isNotEmpty || + byMonth.isNotEmpty || + byMonthDay.isNotEmpty || + bySetPositions.isNotEmpty) { + return null; + } + return ( + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ); + case RecurrenceFrequency.weekly: + if (byMonth.isNotEmpty || + byMonthDay.isNotEmpty || + bySetPositions.isNotEmpty || + byDay.any((value) => !_plainWeekdays.contains(value))) { + return null; + } + return ( + byDay: byDay.isEmpty && baseDate != null + ? [_weekdayFor(baseDate.weekday)] + : byDay, + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ); + case RecurrenceFrequency.monthly: + if (byMonth.isNotEmpty) return null; + return _normalizeMonthlyOrYearly( + byDay: byDay, + byMonth: const [], + byMonthDay: byMonthDay, + bySetPositions: bySetPositions, + defaultMonthDay: baseDate?.day, + ); + case RecurrenceFrequency.yearly: + final months = byMonth.isEmpty && baseDate != null + ? [baseDate.month] + : byMonth; + if (months.isEmpty) return null; + return _normalizeMonthlyOrYearly( + byDay: byDay, + byMonth: months, + byMonthDay: byMonthDay, + bySetPositions: bySetPositions, + defaultMonthDay: baseDate?.day, + ); + } +} + +_NormalizedEditorRule? _normalizeMonthlyOrYearly({ + required List byDay, + required List byMonth, + required List byMonthDay, + required List bySetPositions, + required int? defaultMonthDay, +}) { + if (byMonthDay.isNotEmpty) { + if (byDay.isNotEmpty || + bySetPositions.isNotEmpty || + byMonthDay.any((value) => value < 1 || value > 31)) { + return null; + } + return ( + byDay: const [], + byMonth: byMonth, + byMonthDay: byMonthDay, + bySetPosition: null, + ); + } + + if (byDay.length == 1 && bySetPositions.isEmpty) { + final ordinal = RegExp( + r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$', + ).firstMatch(byDay.single); + if (ordinal != null) { + final position = int.parse(ordinal.group(1)!); + if (_editorSetPositions.contains(position)) { + return ( + byDay: [ordinal.group(2)!], + byMonth: byMonth, + byMonthDay: const [], + bySetPosition: position, + ); + } + } + } + + if (byDay.isNotEmpty && + bySetPositions.length == 1 && + _allowedEditorByDay(byDay) && + _editorSetPositions.contains(bySetPositions.single)) { + return ( + byDay: byDay, + byMonth: byMonth, + byMonthDay: const [], + bySetPosition: bySetPositions.single, + ); + } + + if (byDay.isEmpty && bySetPositions.isEmpty && defaultMonthDay != null) { + return ( + byDay: const [], + byMonth: byMonth, + byMonthDay: [defaultMonthDay], + bySetPosition: null, + ); + } + return null; +} + +bool _allowedEditorByDay(List values) { + final sorted = [...values]..sort(); + return _editorDaySets.any((allowed) { + final candidate = [...allowed]..sort(); + if (candidate.length != sorted.length) return false; + for (var index = 0; index < sorted.length; index += 1) { + if (candidate[index] != sorted[index]) return false; + } + return true; + }); +} + +bool _validUntil(String value) => _parseUntil(value) != null; + +DateTime? _parseUntil(String value) { + final match = RegExp( + r'^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$', + ).firstMatch(value); + if (match == null) return null; + final year = int.parse(match.group(1)!); + final month = int.parse(match.group(2)!); + final day = int.parse(match.group(3)!); + final hasTime = match.group(4) != null; + final hour = hasTime ? int.parse(match.group(4)!) : 0; + final minute = hasTime ? int.parse(match.group(5)!) : 0; + final second = hasTime ? int.parse(match.group(6)!) : 0; + final parsed = match.group(7) == 'Z' + ? DateTime.utc(year, month, day, hour, minute, second) + : DateTime(year, month, day, hour, minute, second); + if (parsed.year != year || + parsed.month != month || + parsed.day != day || + parsed.hour != hour || + parsed.minute != minute || + parsed.second != second) { + return null; + } + return parsed; +} + +String _basicDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}' + '${value.month.toString().padLeft(2, '0')}' + '${value.day.toString().padLeft(2, '0')}'; + +String _weekdayFor(int weekday) => _plainWeekdays[(weekday - 1).clamp(0, 6)]; + +const _plainWeekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; +const _editorSetPositions = {1, 2, 3, 4, 5, -2, -1}; +const _editorDaySets = >[ + ['MO'], + ['TU'], + ['WE'], + ['TH'], + ['FR'], + ['SA'], + ['SU'], + ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], + ['MO', 'TU', 'WE', 'TH', 'FR'], + ['SA', 'SU'], +]; + +bool _listEquals(List left, List right) { + if (identical(left, right)) return true; + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) return false; + } + return true; +} diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart new file mode 100644 index 00000000..925798bd --- /dev/null +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -0,0 +1,654 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_service.dart'; +import '../../tasks/presentation/desktop_date_time_fields.dart'; +import '../domain/event_recurrence_codec.dart'; +import '../domain/recurrence_rule.dart'; + +Future showRecurrenceEditorDialog( + BuildContext context, { + required RecurrenceRule initial, + required bool allDay, + required DateTime baseDate, + required DateTime minimumDate, + required bool useNativeDatePicker, + bool floating = false, + String? timeZone, + RecurrenceRuleLimits limits = RecurrenceRuleLimits.rfc5545, + Color? barrierColor, + LinuxHeaderBarService? headerBarService, +}) { + return showBusyMaxModalEditorDialog( + context, + barrierColor: barrierColor, + headerBarService: headerBarService, + maxWidth: 620, + maxHeight: 820, + builder: (context) => RecurrenceEditorDialog( + initial: initial, + allDay: allDay, + baseDate: baseDate, + minimumDate: minimumDate, + floating: floating, + timeZone: timeZone, + limits: limits, + useNativeDatePicker: useNativeDatePicker, + ), + ); +} + +String recurrenceRuleSummary( + BuildContext context, + RecurrenceRule recurrence, { + String? timeZone, +}) { + if (!recurrence.repeats) return context.l10n.repeatNone; + final l10n = context.l10n; + 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, + }; + if (recurrence.frequency == RecurrenceFrequency.weekly && + recurrence.byDay.isNotEmpty) { + summary = + '$summary ${l10n.repeatOnDaysSummary(recurrence.byDay.map((day) => _localizedWeekday(context, day, abbreviated: false)).join(', '))}'; + } else if ((recurrence.frequency == RecurrenceFrequency.monthly || + recurrence.frequency == RecurrenceFrequency.yearly) && + recurrence.byMonthDay.isNotEmpty) { + summary = + '$summary ${l10n.repeatOnMonthDaysSummary(recurrence.byMonthDay.join(', '))}'; + } else if (recurrence.bySetPosition case final position?) { + summary = + '$summary ${l10n.repeatOnOrdinalSummary(_localizedOrdinal(context, position), _localizedOrdinalDay(context, recurrence.byDay))}'; + } + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty) { + summary = + '$summary ${l10n.repeatInMonthsSummary(recurrence.byMonth.map((month) => _localizedMonth(context, month)).join(', '))}'; + } + 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)}'; +} + +class RecurrenceEditorDialog extends StatefulWidget { + const RecurrenceEditorDialog({ + super.key, + required this.initial, + required this.allDay, + required this.baseDate, + required this.minimumDate, + required this.floating, + required this.useNativeDatePicker, + required this.limits, + this.timeZone, + }); + + final RecurrenceRule initial; + final bool allDay; + final DateTime baseDate; + final DateTime minimumDate; + final bool floating; + final bool useNativeDatePicker; + final RecurrenceRuleLimits limits; + final String? timeZone; + + @override + State createState() => _RecurrenceEditorDialogState(); +} + +enum _RecurrenceEnd { never, until, count } + +class _RecurrenceEditorDialogState extends State { + late RecurrenceRule _value; + late _RecurrenceEnd _end; + late final TextEditingController _intervalController; + late final TextEditingController _countController; + + @override + void initState() { + super.initState(); + _value = widget.initial.isSupported + ? widget.initial + : const RecurrenceRule.none(); + _end = _value.count != null + ? _RecurrenceEnd.count + : _value.untilRaw != null + ? _RecurrenceEnd.until + : _RecurrenceEnd.never; + _intervalController = TextEditingController(text: '${_value.interval}'); + _countController = TextEditingController(text: '${_value.count ?? 10}'); + } + + @override + void dispose() { + _intervalController.dispose(); + _countController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final repeats = _value.repeats; + return BusyMaxModalEditorScaffold( + title: l10n.repeat, + cancelLabel: l10n.cancel, + saveLabel: l10n.save, + onCancel: () => Navigator.pop(context), + onSave: _isValid ? () => Navigator.pop(context, _value) : null, + contentMaxWidth: 560, + children: [ + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxComboRow( + title: l10n.repeat, + values: RecurrenceFrequency.values, + selected: _value.frequency, + labelFor: _frequencyLabel, + onSelected: _setFrequency, + ), + if (repeats) + YaruListTile.square( + title: TextField( + controller: _intervalController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.repeatEvery, + ), + onChanged: (value) { + final parsed = int.tryParse(value); + setState(() { + if (parsed != null && parsed >= 1 && parsed <= 366) { + _value = _value.copyWith(interval: parsed); + } + }); + }, + ), + ), + ], + ), + if (_value.frequency == RecurrenceFrequency.weekly) + BusyMaxGroupedList( + title: l10n.repeatOn, + filled: true, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: YaruChoiceChipBar( + style: YaruChoiceChipBarStyle.wrap, + labels: [ + for (final day in rfcWeekdays) + Text(_localizedWeekday(context, day, abbreviated: true)), + ], + isSelected: [ + for (final day in rfcWeekdays) _value.byDay.contains(day), + ], + selectedFirst: false, + clearOnSelect: false, + onSelected: (index) { + final day = rfcWeekdays[index]; + _toggleWeekday(day, !_value.byDay.contains(day)); + }, + ), + ), + ], + ), + if (_value.frequency == RecurrenceFrequency.monthly || + _value.frequency == RecurrenceFrequency.yearly) + BusyMaxGroupedList( + title: l10n.repeatDayOfMonth, + filled: true, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: YaruChoiceChipBar( + style: YaruChoiceChipBarStyle.wrap, + labels: [for (var day = 1; day <= 31; day += 1) Text('$day')], + isSelected: [ + for (var day = 1; day <= 31; day += 1) + _value.byMonthDay.contains(day), + ], + selectedFirst: false, + clearOnSelect: false, + onSelected: _value.bySetPosition == null + ? (index) { + final day = index + 1; + _toggleMonthDay( + day, + !_value.byMonthDay.contains(day), + ); + } + : null, + ), + ), + BusyMaxComboRow( + title: l10n.repeatOrdinal, + values: [ + 0, + for (final position in _ordinalPositions) + if (widget.limits.ordinalPositions.contains(position)) + position, + ], + selected: _value.bySetPosition ?? 0, + labelFor: (value) => value == 0 + ? l10n.repeatSpecificDays + : _localizedOrdinal(context, value), + onSelected: _setOrdinal, + ), + if (_value.bySetPosition != null) + BusyMaxComboRow( + title: l10n.repeatOn, + values: [for (final choice in _ordinalDayChoices) choice.key], + selected: _ordinalDayChoiceKey(_value.byDay), + labelFor: (value) => _localizedOrdinalDay( + context, + _ordinalDayChoices + .firstWhere((choice) => choice.key == value) + .days, + ), + onSelected: (value) { + final days = _ordinalDayChoices + .firstWhere((choice) => choice.key == value) + .days; + setState(() => _value = _value.copyWith(byDay: days)); + }, + ), + ], + ), + if (_value.frequency == RecurrenceFrequency.yearly) + BusyMaxGroupedList( + title: l10n.repeatMonths, + filled: true, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + child: YaruChoiceChipBar( + style: YaruChoiceChipBarStyle.wrap, + labels: [ + for (var month = 1; month <= 12; month += 1) + Text(_localizedMonth(context, month)), + ], + isSelected: [ + for (var month = 1; month <= 12; month += 1) + _value.byMonth.contains(month), + ], + selectedFirst: false, + clearOnSelect: false, + onSelected: (index) { + final month = index + 1; + _toggleMonth(month, !_value.byMonth.contains(month)); + }, + ), + ), + ], + ), + if (repeats) + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxComboRow<_RecurrenceEnd>( + title: l10n.repeatEnd, + values: _RecurrenceEnd.values, + selected: _end, + labelFor: (value) => switch (value) { + _RecurrenceEnd.never => l10n.repeatNever, + _RecurrenceEnd.until => l10n.repeatUntil, + _RecurrenceEnd.count => l10n.repeatAfter, + }, + onSelected: _setEnd, + ), + if (_end == _RecurrenceEnd.until) + DesktopDateValueRow( + label: l10n.repeatUntil, + date: + _value.untilDateFor(timeZone: widget.timeZone) ?? + _dateString(_oneMonthAfter(widget.baseDate)), + useNativePicker: widget.useNativeDatePicker, + onChanged: _setUntilDate, + ), + if (_end == _RecurrenceEnd.count) + YaruListTile.square( + title: TextField( + controller: _countController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: l10n.repeatCount, + ), + onChanged: (value) { + final parsed = int.tryParse(value); + setState(() { + if (parsed != null && parsed >= 1 && parsed <= 3500) { + _value = _value.copyWith(count: parsed); + } + }); + }, + ), + ), + ], + ), + ], + ); + } + + bool get _isValid { + if (!_value.repeats) return true; + final interval = int.tryParse(_intervalController.text); + if (interval == null || interval < 1 || interval > 366) return false; + if (_value.frequency == RecurrenceFrequency.weekly && + _value.byDay.isEmpty) { + return false; + } + if ((_value.frequency == RecurrenceFrequency.monthly || + _value.frequency == RecurrenceFrequency.yearly) && + _value.byMonthDay.isEmpty && + (_value.bySetPosition == null || _value.byDay.isEmpty)) { + return false; + } + if (_value.frequency == RecurrenceFrequency.yearly && + _value.byMonth.isEmpty) { + return false; + } + if (_end == _RecurrenceEnd.until && _value.untilRaw == null) return false; + if (_end == _RecurrenceEnd.count) { + final count = int.tryParse(_countController.text); + if (count == null || count < 1 || count > 3500) return false; + } + return widget.limits.supports(_value); + } + + void _setFrequency(RecurrenceFrequency frequency) { + final base = widget.baseDate; + setState(() { + _value = switch (frequency) { + RecurrenceFrequency.none => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + count: null, + untilRaw: null, + ), + RecurrenceFrequency.weekly => _value.copyWith( + frequency: frequency, + byDay: [_weekdayCode(base.weekday)], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ), + RecurrenceFrequency.monthly => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: const [], + byMonthDay: [base.day], + bySetPosition: null, + ), + RecurrenceFrequency.yearly => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: [base.month], + byMonthDay: [base.day], + bySetPosition: null, + ), + RecurrenceFrequency.daily => _value.copyWith( + frequency: frequency, + byDay: const [], + byMonth: const [], + byMonthDay: const [], + bySetPosition: null, + ), + }; + }); + } + + void _toggleWeekday(String day, bool selected) { + final values = [..._value.byDay]; + if (selected) { + if (!values.contains(day)) values.add(day); + } else if (values.length > 1) { + values.remove(day); + } + values.sort( + (left, right) => + rfcWeekdays.indexOf(left).compareTo(rfcWeekdays.indexOf(right)), + ); + setState(() => _value = _value.copyWith(byDay: values)); + } + + void _toggleMonthDay(int day, bool selected) { + var values = [..._value.byMonthDay]; + if (!widget.limits.allowMultipleMonthDays) { + values = [day]; + } else if (selected) { + if (!values.contains(day)) values.add(day); + } else if (values.length > 1) { + values.remove(day); + } + values.sort(); + setState(() { + _value = _value.copyWith( + byMonthDay: values, + byDay: const [], + bySetPosition: null, + ); + }); + } + + void _toggleMonth(int month, bool selected) { + var values = [..._value.byMonth]; + if (!widget.limits.allowMultipleMonths) { + values = [month]; + } else if (selected) { + if (!values.contains(month)) values.add(month); + } else if (values.length > 1) { + values.remove(month); + } + values.sort(); + setState(() => _value = _value.copyWith(byMonth: values)); + } + + void _setOrdinal(int? value) { + setState(() { + if (value == null || value == 0) { + _value = _value.copyWith( + bySetPosition: null, + byDay: const [], + byMonthDay: _value.byMonthDay.isEmpty + ? [widget.baseDate.day] + : [_value.byMonthDay.first], + ); + } else { + _value = _value.copyWith( + bySetPosition: value, + byDay: _value.byDay.isEmpty + ? [_weekdayCode(widget.baseDate.weekday)] + : _value.byDay, + byMonthDay: const [], + ); + } + }); + } + + void _setEnd(_RecurrenceEnd value) { + setState(() { + _end = value; + _value = switch (value) { + _RecurrenceEnd.never => _value.copyWith(count: null, untilRaw: null), + _RecurrenceEnd.count => _value.copyWith( + count: int.tryParse(_countController.text) ?? 10, + untilRaw: null, + ), + _RecurrenceEnd.until => + _value + .copyWith(count: null) + .withUntilDate( + _value.untilDateFor(timeZone: widget.timeZone) ?? + _dateString(_oneMonthAfter(widget.baseDate)), + allDay: widget.allDay, + floating: widget.floating, + baseDate: widget.baseDate, + timeZone: widget.timeZone, + ), + }; + }); + } + + void _setUntilDate(String value) { + final date = DateTime.tryParse(value); + if (date == null) return; + final firstDate = DateTime( + widget.minimumDate.year, + widget.minimumDate.month, + widget.minimumDate.day, + ); + if (date.isBefore(firstDate)) return; + setState(() { + _value = _value.withUntilDate( + _dateString(date), + allDay: widget.allDay, + floating: widget.floating, + baseDate: widget.baseDate, + timeZone: widget.timeZone, + ); + }); + } + + String _frequencyLabel(RecurrenceFrequency frequency) => switch (frequency) { + RecurrenceFrequency.none => context.l10n.repeatNone, + RecurrenceFrequency.daily => context.l10n.repeatDaily, + RecurrenceFrequency.weekly => context.l10n.repeatWeekly, + RecurrenceFrequency.monthly => context.l10n.repeatMonthly, + RecurrenceFrequency.yearly => context.l10n.repeatYearly, + }; +} + +const _ordinalPositions = [1, 2, 3, 4, 5, -2, -1]; + +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 _weekdayCode(int weekday) => rfcWeekdays[(weekday - 1).clamp(0, 6)]; + +String _ordinalDayChoiceKey(List days) { + for (final choice in _ordinalDayChoices) { + if (_sameDaySet(choice.days, days)) return choice.key; + } + return days.firstOrNull ?? 'MO'; +} + +String _localizedWeekday( + BuildContext context, + String day, { + required bool abbreviated, +}) { + final index = rfcWeekdays.indexOf(day); + if (index < 0) return day; + final date = DateTime(2024, 1, 1 + index); + final locale = Localizations.localeOf(context).toLanguageTag(); + return DateFormat(abbreviated ? 'EEE' : 'EEEE', locale).format(date); +} + +String _localizedMonth(BuildContext context, int month) { + if (month < 1 || month > 12) return '$month'; + return DateFormat( + 'MMM', + Localizations.localeOf(context).toLanguageTag(), + ).format(DateTime(2024, month)); +} + +String _localizedOrdinal(BuildContext context, int value) => switch (value) { + 1 => context.l10n.repeatFirst, + 2 => context.l10n.repeatSecond, + 3 => context.l10n.repeatThird, + 4 => context.l10n.repeatFourth, + 5 => context.l10n.repeatFifth, + -2 => context.l10n.repeatSecondToLast, + -1 => context.l10n.repeatLast, + _ => '$value', +}; + +String _localizedOrdinalDay(BuildContext context, List days) { + if (days.length == 1) { + return _localizedWeekday(context, days.single, abbreviated: false); + } + if (_sameDaySet(days, _ordinalDayChoices[7].days)) { + return context.l10n.repeatAnyDay; + } + if (_sameDaySet(days, _ordinalDayChoices[8].days)) { + return context.l10n.repeatWeekday; + } + if (_sameDaySet(days, _ordinalDayChoices[9].days)) { + return context.l10n.repeatWeekendDay; + } + return days + .map((day) => _localizedWeekday(context, day, abbreviated: false)) + .join(', '); +} + +bool _sameDaySet(List left, List right) { + if (left.length != right.length) return false; + final leftValues = {...left}; + return leftValues.length == left.length && leftValues.containsAll(right); +} + +DateTime _oneMonthAfter(DateTime value) { + final firstOfTarget = DateTime(value.year, value.month + 1); + final lastDay = DateTime(firstOfTarget.year, firstOfTarget.month + 1, 0).day; + final day = value.day > lastDay ? lastDay : value.day; + return DateTime(firstOfTarget.year, firstOfTarget.month, day); +} + +String _dateString(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; diff --git a/test/features/calendar/domain/calendar_management_capabilities_test.dart b/test/features/calendar/domain/calendar_management_capabilities_test.dart new file mode 100644 index 00000000..68a27359 --- /dev/null +++ b/test/features/calendar/domain/calendar_management_capabilities_test.dart @@ -0,0 +1,67 @@ +import 'package:busymax/src/calendar_providers/calendar_provider_capabilities.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Google and Microsoft expose cloud calendar management', () { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + final capabilities = calendarManagementCapabilities(provider); + expect(capabilities.supportsCreate, isTrue); + expect(capabilities.supportsRename, isTrue); + expect(capabilities.supportsDelete, isTrue); + expect(capabilities.supportsColor, isTrue); + } + }); + + test('DAV calendar collection management is disabled', () { + for (final provider in [BusyProvider.appleICloud, BusyProvider.nextcloud]) { + final capabilities = calendarManagementCapabilities(provider); + expect(capabilities.supportsCreate, isFalse); + expect(capabilities.supportsRename, isFalse); + expect(capabilities.supportsDelete, isFalse); + expect(capabilities.supportsColor, isFalse); + } + }); + + test( + 'DAV source actions remain disabled even when the source is writable', + () { + const source = CalendarSourceEntity( + id: 'dav-calendar', + accountId: 'nextcloud-account', + provider: BusyProvider.nextcloud, + providerCalendarId: '/calendars/user/work/', + summary: 'Work', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + + expect(source.capabilities.canCreateEvents, isTrue); + expect(source.capabilities.canRenameCalendar, isFalse); + expect(source.capabilities.canDeleteCalendar, isFalse); + expect(source.capabilities.canChangeCalendarColor, isFalse); + }, + ); + + test('primary cloud calendar cannot be deleted', () { + const source = CalendarSourceEntity( + id: 'primary-calendar', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'primary', + summary: 'Primary', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + primaryCalendar: true, + ); + + expect(source.capabilities.canRenameCalendar, isTrue); + expect(source.capabilities.canDeleteCalendar, isFalse); + expect(source.capabilities.canChangeCalendarColor, isTrue); + }); +} diff --git a/test/features/calendar/domain/event_move_policy_test.dart b/test/features/calendar/domain/event_move_policy_test.dart new file mode 100644 index 00000000..55b17739 --- /dev/null +++ b/test/features/calendar/domain/event_move_policy_test.dart @@ -0,0 +1,96 @@ +import 'package:busymax/src/features/calendar/domain/event_move_policy.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Google uses native movement only within one account', () { + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + ), + CalendarEventMoveStrategy.googleNative, + ); + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + destinationAccountId: 'other-account', + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + }); + + test('Google special events and individual occurrences are copied', () { + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + eventType: 'birthday', + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + expect( + _strategy( + sourceProvider: BusyProvider.google, + destinationProvider: BusyProvider.google, + recurring: true, + recurringScope: RecurringEventMutationScope.singleOccurrence, + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + }); + + test('same-account CalDAV movement uses the DAV resource operation', () { + for (final provider in [BusyProvider.nextcloud, BusyProvider.appleICloud]) { + expect( + _strategy( + sourceProvider: provider, + destinationProvider: provider, + sourceDavCollectionId: 'source-collection', + destinationDavCollectionId: 'destination-collection', + ), + CalendarEventMoveStrategy.davNative, + ); + } + }); + + test('Microsoft movement uses explicit copy then delete', () { + expect( + _strategy( + sourceProvider: BusyProvider.microsoft, + destinationProvider: BusyProvider.microsoft, + ), + CalendarEventMoveStrategy.copyThenDelete, + ); + }); +} + +CalendarEventMoveStrategy _strategy({ + required BusyProvider sourceProvider, + required BusyProvider destinationProvider, + String sourceAccountId = 'account', + String destinationAccountId = 'account', + String? sourceDavCollectionId, + String? destinationDavCollectionId, + String? eventType = 'default', + bool recurring = false, + RecurringEventMutationScope? recurringScope, +}) { + return calendarEventMoveStrategy( + sourceAccountId: sourceAccountId, + sourceId: 'source', + sourceProviderCalendarId: 'source-calendar', + sourceProvider: sourceProvider, + sourceDavCollectionId: sourceDavCollectionId, + destinationAccountId: destinationAccountId, + destinationId: 'destination', + destinationProviderCalendarId: 'destination-calendar', + destinationProvider: destinationProvider, + destinationDavCollectionId: destinationDavCollectionId, + eventType: eventType, + recurring: recurring, + recurringScope: recurringScope, + ); +} diff --git a/test/features/calendar/presentation/calendar_color_dialog_test.dart b/test/features/calendar/presentation/calendar_color_dialog_test.dart new file mode 100644 index 00000000..e6e6363b --- /dev/null +++ b/test/features/calendar/presentation/calendar_color_dialog_test.dart @@ -0,0 +1,49 @@ +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/features/calendar/presentation/calendar_color_dialog.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('Google calendar color dialog returns the selected RGB color', ( + tester, + ) async { + final headerBarService = LinuxHeaderBarService(isLinux: false); + addTearDown(headerBarService.dispose); + Future? result; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + result = showCalendarColorDialog( + context, + provider: BusyProvider.google, + currentBackgroundColor: '#3584e4', + currentColorId: null, + headerBarService: headerBarService, + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Calendar color'), findsOneWidget); + + await tester.tap(find.byTooltip('Color 2')); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + final choice = await result; + expect(choice!.backgroundColor, '#1c71d8'); + }); +} diff --git a/test/features/recurrence/domain/event_recurrence_codec_test.dart b/test/features/recurrence/domain/event_recurrence_codec_test.dart new file mode 100644 index 00000000..056db973 --- /dev/null +++ b/test/features/recurrence/domain/event_recurrence_codec_test.dart @@ -0,0 +1,228 @@ +import 'package:busymax/src/features/recurrence/domain/event_recurrence_codec.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RFC 5545 recurrence', () { + 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', + ], baseDate: DateTime(2026, 6, 8, 9)); + + expect(rule.isSupported, isTrue); + expect(rule.frequency, RecurrenceFrequency.weekly); + expect(rule.interval, 2); + expect(rule.byDay, ['MO', 'WE', 'FR']); + expect(rule.count, 12); + expect( + EventRecurrenceCodec.encode( + BusyProvider.google, + rule, + baseDate: DateTime(2026, 6, 8, 9), + allDay: false, + timeZone: 'America/Vancouver', + ), + const ['RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=12'], + ); + }); + + test('round-trips the second Tuesday through a timed UNTIL', () { + final rule = EventRecurrenceCodec.decode(BusyProvider.nextcloud, const { + 'rules': [ + 'FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;' + 'UNTIL=20261209T170000Z', + ], + 'dates': [], + 'excludedDates': [], + }, baseDate: DateTime(2026, 6, 9, 9)); + + expect(rule.isSupported, isTrue); + expect(rule.byDay, ['TU']); + expect(rule.bySetPosition, 2); + expect(rule.untilDateFor(timeZone: 'America/Vancouver'), '2026-12-09'); + expect( + EventRecurrenceCodec.encode( + BusyProvider.nextcloud, + rule, + baseDate: DateTime(2026, 6, 9, 9), + allDay: false, + timeZone: 'America/Vancouver', + ), + const { + 'rules': [ + 'FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;' + 'UNTIL=20261209T170000Z', + ], + 'dates': [], + 'excludedDates': [], + }, + ); + }); + + test('keeps recurrence sets with exception dates opaque', () { + final rule = EventRecurrenceCodec.decode(BusyProvider.google, const [ + 'RRULE:FREQ=WEEKLY;BYDAY=MO', + 'EXDATE:20260615T160000Z', + ], baseDate: DateTime(2026, 6, 8, 9)); + + expect(rule.isSupported, isFalse); + expect(rule.rawRules, ['FREQ=WEEKLY;BYDAY=MO']); + expect(rule.exceptionDates, ['EXDATE:20260615T160000Z']); + }); + }); + + group('Microsoft Graph recurrence', () { + test('maps a biweekly weekday set ending after occurrences', () { + final rule = RecurrenceRule.fromIcalendar( + rules: const ['FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=9'], + baseDate: DateTime(2026, 6, 8, 9), + ); + + expect( + EventRecurrenceCodec.encode( + BusyProvider.microsoft, + rule, + baseDate: DateTime(2026, 6, 8, 9), + allDay: false, + timeZone: 'Pacific Standard Time', + ), + const { + 'pattern': { + 'type': 'weekly', + 'interval': 2, + 'daysOfWeek': ['monday', 'wednesday', 'friday'], + 'firstDayOfWeek': 'monday', + }, + 'range': { + 'type': 'numbered', + 'startDate': '2026-06-08', + 'numberOfOccurrences': 9, + }, + }, + ); + }); + + test('maps the second Tuesday ending on a date', () { + const recurrence = { + 'pattern': { + 'type': 'relativeMonthly', + 'interval': 1, + 'daysOfWeek': ['tuesday'], + 'index': 'second', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-06-09', + 'endDate': '2026-12-31', + 'recurrenceTimeZone': 'Pacific Standard Time', + }, + }; + final rule = EventRecurrenceCodec.decode( + BusyProvider.microsoft, + recurrence, + baseDate: DateTime(2026, 6, 9, 9), + ); + + expect(rule.isSupported, isTrue); + expect(rule.frequency, RecurrenceFrequency.monthly); + expect(rule.byDay, ['TU']); + expect(rule.bySetPosition, 2); + expect(rule.untilDate, '2026-12-31'); + expect( + EventRecurrenceCodec.encode( + BusyProvider.microsoft, + rule, + baseDate: DateTime(2026, 6, 9, 9), + allDay: false, + timeZone: 'Pacific Standard Time', + original: recurrence, + ), + recurrence, + ); + }); + + test('rejects RFC shapes Graph cannot represent', () { + final fifthTuesday = RecurrenceRule.fromIcalendar( + rules: const ['FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=5'], + baseDate: DateTime(2026, 6, 9), + ); + final multipleMonthDays = RecurrenceRule.fromIcalendar( + rules: const ['FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1,15'], + baseDate: DateTime(2026, 6, 1), + ); + + expect( + EventRecurrenceCodec.canEncode(BusyProvider.microsoft, fifthTuesday), + isFalse, + ); + expect( + EventRecurrenceCodec.canEncode( + BusyProvider.microsoft, + multipleMonthDays, + ), + isFalse, + ); + }); + + test('requires the documented Graph range and calendar values', () { + const missingStartDate = { + 'pattern': { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': ['monday'], + 'firstDayOfWeek': 'monday', + }, + 'range': {'type': 'noEnd'}, + }; + const invalidEndDate = { + 'pattern': { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': ['monday'], + 'firstDayOfWeek': 'monday', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-06-08', + 'endDate': '2026-02-31', + }, + }; + const invalidMonth = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [13], + byMonthDay: [1], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + + expect( + EventRecurrenceCodec.decode( + BusyProvider.microsoft, + missingStartDate, + baseDate: DateTime(2026, 6, 8), + ).isSupported, + isFalse, + ); + expect( + EventRecurrenceCodec.decode( + BusyProvider.microsoft, + invalidEndDate, + baseDate: DateTime(2026, 6, 8), + ).isSupported, + isFalse, + ); + expect( + EventRecurrenceCodec.canEncode(BusyProvider.microsoft, invalidMonth), + isFalse, + ); + }); + }); +} From 3bf68ffb241b651d7fa268cb1de7a7ec14a4d3fd Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 01:10:12 -0700 Subject: [PATCH 02/69] feat: advance calendar scheduling, editing, and provider sync behavior --- lib/l10n/app_ar.arb | 23 +- lib/l10n/app_de.arb | 23 +- lib/l10n/app_en.arb | 27 +- lib/l10n/app_es.arb | 23 +- lib/l10n/app_et.arb | 23 +- lib/l10n/app_fa.arb | 23 +- lib/l10n/app_fi.arb | 23 +- lib/l10n/app_fr.arb | 23 +- lib/l10n/app_hi.arb | 23 +- lib/l10n/app_it.arb | 23 +- lib/l10n/app_ja.arb | 23 +- lib/l10n/app_ko.arb | 23 +- lib/l10n/app_pt.arb | 23 +- lib/l10n/app_ru.arb | 23 +- lib/l10n/app_vi.arb | 23 +- lib/l10n/app_zh.arb | 23 +- lib/l10n/app_zh_Hans.arb | 23 +- lib/l10n/app_zh_Hant.arb | 23 +- lib/l10n/generated/app_localizations.dart | 96 +- lib/l10n/generated/app_localizations_ar.dart | 64 +- lib/l10n/generated/app_localizations_de.dart | 67 +- lib/l10n/generated/app_localizations_en.dart | 67 +- lib/l10n/generated/app_localizations_es.dart | 66 +- lib/l10n/generated/app_localizations_et.dart | 65 +- lib/l10n/generated/app_localizations_fa.dart | 65 +- lib/l10n/generated/app_localizations_fi.dart | 66 +- lib/l10n/generated/app_localizations_fr.dart | 67 +- lib/l10n/generated/app_localizations_hi.dart | 66 +- lib/l10n/generated/app_localizations_it.dart | 66 +- lib/l10n/generated/app_localizations_ja.dart | 64 +- lib/l10n/generated/app_localizations_ko.dart | 64 +- lib/l10n/generated/app_localizations_pt.dart | 66 +- lib/l10n/generated/app_localizations_ru.dart | 66 +- lib/l10n/generated/app_localizations_vi.dart | 64 +- lib/l10n/generated/app_localizations_zh.dart | 183 +- lib/src/app/app_bootstrap.dart | 17 + .../calendar_providers/calendar_colors.dart | 86 +- .../calendar_providers/calendar_mutation.dart | 13 + .../calendar_provider_capabilities.dart | 32 + .../cloud_calendar_client.dart | 8 + lib/src/core/time/provider_date_time.dart | 78 +- lib/src/dav/ical/ical_recurrence.dart | 113 +- lib/src/dav/ical/ical_semantics.dart | 6 + lib/src/dav/ical/ical_task_recurrence.dart | 487 +--- lib/src/dav/mutation/dav_mutation_patch.dart | 1 + .../dav/mutation/dav_pending_operations.dart | 57 +- .../mutation/dav_projection_mutations.dart | 252 ++- .../dav/storage/dav_object_repository.dart | 9 +- .../calendar/data/calendar_event_detail.dart | 10 +- .../calendar/data/calendar_repository.dart | 2005 +++++++++++++++-- .../calendar/presentation/event_editor.dart | 673 ++++-- .../presentation/event_editor_draft.dart | 50 +- .../presentation/schedule_sidebar.dart | 191 +- .../presentation/schedule_workspace.dart | 80 +- .../sync/calendar_pending_ops_replayer.dart | 899 +++++++- .../presentation/ical_task_fields_editor.dart | 630 +----- .../google_calendar_api_client.dart | 52 +- .../google_calendar_mapper.dart | 82 +- .../microsoft_calendar_api_client.dart | 13 + .../microsoft_calendar_mapper.dart | 2 +- lib/src/schedule/schedule_item.dart | 4 + lib/src/schedule/schedule_repository.dart | 28 +- test/core/time/provider_date_time_test.dart | 18 + test/dav/ical/ical_recurrence_test.dart | 56 + test/dav/ical/ical_task_recurrence_test.dart | 11 + .../dav/mutation/dav_mutation_patch_test.dart | 152 ++ ..._repository_mutation_integration_test.dart | 178 ++ .../data/calendar_repository_test.dart | 505 ++++- .../presentation/event_editor_test.dart | 701 ++++-- .../event_guest_delivery_dialog_test.dart | 81 + .../presentation/schedule_views_test.dart | 4 +- .../schedule/schedule_search_test.dart | 75 + .../sync/calendar_event_clear_patch_test.dart | 4 + .../calendar_pending_ops_replayer_test.dart | 903 +++++++- .../sync/calendar_sync_engine_test.dart | 11 + .../google_calendar_api_client_test.dart | 78 + .../microsoft_calendar_api_client_test.dart | 25 + 77 files changed, 8327 insertions(+), 2033 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 3556542b..3ab41fcd 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -33,6 +33,18 @@ "calendarTasks": "المهام", "calendar": "التقويم", "calendars": "التقويمات", + "newCalendar": "تقويم جديد", + "calendarColor": "لون التقويم", + "calendarColorOption": "اللون {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "لا يدعم هذا المزوّد إدارة التقويمات في BusyMax.", + "primaryCalendarCannotDelete": "لا يمكن حذف التقويم الأساسي.", + "calendarCreateFailed": "تعذّر إنشاء التقويم: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "تعذّر تحديث التقويم: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "تعذّر حذف التقويم: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "حدث جديد", "refreshCalendar": "تحديث التقويم", "openInProvider": "فتح في الخدمة", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "هذا الحدث", + "thisAndFollowingEvents": "هذا الحدث والأحداث التالية", + "thisAndFutureUnavailable": "غير مدعوم من موفّر الخدمة هذا.", + "thisAndFutureMoveUnavailable": "لا يمكن نقل هذا الحدث والأحداث التالية بأمان. اختر هذا الحدث أو السلسلة كاملة.", + "entireSeriesMoveUnavailable": "قاعدة التكرار غير متوفرة محليًا. انقل هذا الحدث بدلاً من ذلك.", + "copyEventAndDeleteOriginal": "هل تريد نسخ الحدث وحذف الأصل؟", + "copyEventMoveWarning": "لا يستطيع BusyMax نقل هذا الحدث مباشرةً من {source} إلى {destination}. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.", + "copyAndDelete": "نسخ وحذف", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "يجب ألا يكون موعد الاستحقاق قبل وقت البدء.", "taskStartDueTimeModeMismatch": "عيّن وقتًا لكل من البدء والاستحقاق، أو اجعل المهمة طوال اليوم.", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} مرات", "repeatUntilSummary": "until {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "لا يمكن استخدام هذا التكرار مع {provider}.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7f9f4888..1401d24c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -33,6 +33,18 @@ "calendarTasks": "Aufgaben", "calendar": "Kalender", "calendars": "Kalender", + "newCalendar": "Neuer Kalender", + "calendarColor": "Kalenderfarbe", + "calendarColorOption": "Farbe {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Dieser Anbieter unterstützt die Kalenderverwaltung in BusyMax nicht.", + "primaryCalendarCannotDelete": "Der primäre Kalender kann nicht gelöscht werden.", + "calendarCreateFailed": "Kalender konnte nicht erstellt werden: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Kalender konnte nicht aktualisiert werden: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Kalender konnte nicht gelöscht werden: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Neuer Termin", "refreshCalendar": "Kalender aktualisieren", "openInProvider": "Beim Anbieter öffnen", @@ -541,8 +553,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Dieses Ereignis", + "thisAndFollowingEvents": "Dieses und folgende Ereignisse", + "thisAndFutureUnavailable": "Von diesem Anbieter nicht unterstützt.", + "thisAndFutureMoveUnavailable": "Dieses und die folgenden Ereignisse können nicht sicher verschoben werden. Wählen Sie dieses Ereignis oder die gesamte Serie.", + "entireSeriesMoveUnavailable": "Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen dieses Ereignis.", + "copyEventAndDeleteOriginal": "Ereignis kopieren und Original löschen?", + "copyEventMoveWarning": "BusyMax kann dieses Ereignis nicht direkt von {source} nach {destination} verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Ereignis-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.", + "copyAndDelete": "Kopieren und löschen", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "Die Fälligkeit darf nicht vor dem Beginn liegen.", "taskStartDueTimeModeMismatch": "Lege für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder mache die Aufgabe ganztägig.", @@ -630,6 +648,7 @@ "repeatTimesSummary": "{count} mal", "repeatUntilSummary": "bis {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Diese Wiederholung kann nicht mit {provider} verwendet werden.", "duplicateTask": "Aufgabe duplizieren", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 18abf8cc..664a7669 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -33,6 +33,18 @@ "calendarTasks": "Tasks", "calendar": "Calendar", "calendars": "Calendars", + "newCalendar": "New calendar", + "calendarColor": "Calendar color", + "calendarColorOption": "Color {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "This provider does not support calendar management in BusyMax.", + "primaryCalendarCannotDelete": "The primary calendar cannot be deleted.", + "calendarCreateFailed": "Could not create the calendar: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Could not update the calendar: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Could not delete the calendar: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "New event", "refreshCalendar": "Refresh calendar", "openInProvider": "Open in provider", @@ -419,6 +431,8 @@ "repeatUntilSummary": "until {date}", "@repeatUntilSummary": {"placeholders": {"date": {"type": "String"}}}, "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "This recurrence cannot be used with {provider}.", + "@recurrenceUnsupportedByProvider": {"placeholders": {"provider": {"type": "String"}}}, "importance": "Importance", "importanceLow": "Low", "importanceNormal": "Normal", @@ -623,9 +637,16 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "singleOccurrence": "This event", + "thisAndFollowingEvents": "This and following events", + "thisAndFutureUnavailable": "Not supported by this provider.", + "thisAndFutureMoveUnavailable": "This and following events cannot be moved safely. Choose this event or the entire series.", + "entireSeriesMoveUnavailable": "The recurrence rule is not available locally. Move this event instead.", + "copyEventAndDeleteOriginal": "Copy event and delete original?", + "copyEventMoveWarning": "BusyMax cannot move this event directly from {source} to {destination}. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.", + "@copyEventMoveWarning": {"placeholders": {"source": {"type": "String"}, "destination": {"type": "String"}}}, + "copyAndDelete": "Copy and delete", + "chooseRecurringEventScope": "Choose which events this change applies to.", "taskDueBeforeStart": "Due must not be before start.", "taskStartDueTimeModeMismatch": "Set times for both start and due, or make the task all day.", "deleteCalendarConfirmation": "Delete \"{title}\"?", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e2201aab..1433f929 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -33,6 +33,18 @@ "calendarTasks": "Tareas", "calendar": "Calendario", "calendars": "Calendarios", + "newCalendar": "Nuevo calendario", + "calendarColor": "Color del calendario", + "calendarColorOption": "Color {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Este proveedor no admite la gestión de calendarios en BusyMax.", + "primaryCalendarCannotDelete": "El calendario principal no se puede eliminar.", + "calendarCreateFailed": "No se pudo crear el calendario: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "No se pudo actualizar el calendario: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "No se pudo eliminar el calendario: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Nuevo evento", "refreshCalendar": "Actualizar calendario", "openInProvider": "Abrir en el proveedor", @@ -541,8 +553,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Este evento", + "thisAndFollowingEvents": "Este evento y los siguientes", + "thisAndFutureUnavailable": "No compatible con este proveedor.", + "thisAndFutureMoveUnavailable": "No se pueden mover de forma segura este evento y los siguientes. Elige este evento o toda la serie.", + "entireSeriesMoveUnavailable": "La regla de repetición no está disponible localmente. Mueve solo este evento.", + "copyEventAndDeleteOriginal": "¿Copiar el evento y eliminar el original?", + "copyEventMoveWarning": "BusyMax no puede mover este evento directamente de {source} a {destination}. Primero creará la copia y eliminará el original solo cuando la copia se complete correctamente. Los identificadores del evento cambiarán; los estados de respuesta de los asistentes pueden restablecerse y pueden enviarse invitaciones o cancelaciones; y es posible que no se conserven los enlaces de conferencia, los archivos adjuntos, los recordatorios, los campos específicos del proveedor ni las excepciones de repetición.", + "copyAndDelete": "Copiar y eliminar", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "El vencimiento no puede ser anterior al inicio.", "taskStartDueTimeModeMismatch": "Define una hora tanto para el inicio como para el vencimiento, o configura la tarea para todo el día.", @@ -630,6 +648,7 @@ "repeatTimesSummary": "{count} veces", "repeatUntilSummary": "hasta el {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Esta repetición no se puede usar con {provider}.", "duplicateTask": "Duplicar tarea", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 8843b672..f16f9118 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -33,6 +33,18 @@ "calendarTasks": "Ülesanded", "calendar": "Kalender", "calendars": "Kalendrid", + "newCalendar": "Uus kalender", + "calendarColor": "Kalendri värv", + "calendarColorOption": "Värv {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "See teenusepakkuja ei toeta BusyMaxis kalendrite haldamist.", + "primaryCalendarCannotDelete": "Põhikalendrit ei saa kustutada.", + "calendarCreateFailed": "Kalendri loomine nurjus: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Kalendri värskendamine nurjus: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Kalendri kustutamine nurjus: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Uus sündmus", "refreshCalendar": "Värskenda kalendrit", "openInProvider": "Ava teenuses", @@ -656,8 +668,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "See sündmus", + "thisAndFollowingEvents": "See ja järgmised sündmused", + "thisAndFutureUnavailable": "See teenusepakkuja ei toeta seda.", + "thisAndFutureMoveUnavailable": "Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Vali see sündmus või kogu sari.", + "entireSeriesMoveUnavailable": "Kordusreegel pole kohalikult saadaval. Teisalda selle asemel ainult see sündmus.", + "copyEventAndDeleteOriginal": "Kas kopeerida sündmus ja kustutada algne?", + "copyEventMoveWarning": "BusyMax ei saa seda sündmust otse kalendrist {source} kalendrisse {destination} teisaldada. Esmalt luuakse koopia ja algne sündmus kustutatakse alles pärast edukat kopeerimist. Sündmuse ID-d muutuvad; osalejate vastuseolekud võivad lähtestuda ning võidakse saata kutseid või tühistusi; konverentsilingid, manused, meeldetuletused, teenusepakkuja väljad ja korduse erandid ei pruugi üle kanduda.", + "copyAndDelete": "Kopeeri ja kustuta", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "Tähtaeg ei tohi olla enne algust.", "taskStartDueTimeModeMismatch": "Määra nii algus- kui ka tähtaja kellaaeg või tee ülesanne kogu päeva ülesandeks.", @@ -745,6 +763,7 @@ "repeatTimesSummary": "{count} times", "repeatUntilSummary": "until {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Seda kordust ei saa teenusega {provider} kasutada.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 9db2c330..cfa7cb46 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -33,6 +33,18 @@ "calendarTasks": "کارها", "calendar": "تقویم", "calendars": "تقویم‌ها", + "newCalendar": "تقویم جدید", + "calendarColor": "رنگ تقویم", + "calendarColorOption": "رنگ {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "این ارائه‌دهنده از مدیریت تقویم در BusyMax پشتیبانی نمی‌کند.", + "primaryCalendarCannotDelete": "تقویم اصلی را نمی‌توان حذف کرد.", + "calendarCreateFailed": "ایجاد تقویم ممکن نشد: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "به‌روزرسانی تقویم ممکن نشد: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "حذف تقویم ممکن نشد: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "رویداد جدید", "refreshCalendar": "تازه‌سازی تقویم", "openInProvider": "باز کردن در سرویس", @@ -542,8 +554,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "این رویداد", + "thisAndFollowingEvents": "این رویداد و رویدادهای بعدی", + "thisAndFutureUnavailable": "این ارائه‌دهنده از آن پشتیبانی نمی‌کند.", + "thisAndFutureMoveUnavailable": "انتقال امن این رویداد و رویدادهای بعدی ممکن نیست. این رویداد یا کل مجموعه را انتخاب کنید.", + "entireSeriesMoveUnavailable": "قاعدهٔ تکرار به‌صورت محلی در دسترس نیست. به‌جای آن فقط این رویداد را منتقل کنید.", + "copyEventAndDeleteOriginal": "رویداد کپی و نسخهٔ اصلی حذف شود؟", + "copyEventMoveWarning": "BusyMax نمی‌تواند این رویداد را مستقیماً از {source} به {destination} منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.", + "copyAndDelete": "کپی و حذف", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "زمان سررسید نباید پیش از زمان شروع باشد.", "taskStartDueTimeModeMismatch": "برای شروع و سررسید هر دو زمان تعیین کنید، یا کار را تمام‌روز کنید.", @@ -631,6 +649,7 @@ "repeatTimesSummary": "{count} بار", "repeatUntilSummary": "تا {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "این تکرار را نمی‌توان با {provider} استفاده کرد.", "duplicateTask": "تکراری‌سازی وظیفه", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 74225053..d6f1e5d3 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -33,6 +33,18 @@ "calendarTasks": "Tehtävät", "calendar": "Kalenteri", "calendars": "Kalenterit", + "newCalendar": "Uusi kalenteri", + "calendarColor": "Kalenterin väri", + "calendarColorOption": "Väri {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Tämä palveluntarjoaja ei tue kalenterien hallintaa BusyMaxissa.", + "primaryCalendarCannotDelete": "Ensisijaista kalenteria ei voi poistaa.", + "calendarCreateFailed": "Kalenteria ei voitu luoda: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Kalenteria ei voitu päivittää: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Kalenteria ei voitu poistaa: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Uusi tapahtuma", "refreshCalendar": "Päivitä kalenteri", "openInProvider": "Avaa palvelussa", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Tämä tapahtuma", + "thisAndFollowingEvents": "Tämä ja seuraavat tapahtumat", + "thisAndFutureUnavailable": "Tämä palveluntarjoaja ei tue tätä.", + "thisAndFutureMoveUnavailable": "Tätä ja seuraavia tapahtumia ei voi siirtää turvallisesti. Valitse tämä tapahtuma tai koko sarja.", + "entireSeriesMoveUnavailable": "Toistumissääntö ei ole saatavilla paikallisesti. Siirrä sen sijaan vain tämä tapahtuma.", + "copyEventAndDeleteOriginal": "Kopioidaanko tapahtuma ja poistetaanko alkuperäinen?", + "copyEventMoveWarning": "BusyMax ei voi siirtää tätä tapahtumaa suoraan kalenterista {source} kalenteriin {destination}. Kopio luodaan ensin, ja alkuperäinen poistetaan vasta onnistuneen kopioinnin jälkeen. Tapahtuman tunnisteet muuttuvat; osallistujien vastaustilat voivat nollautua ja kutsuja tai peruutuksia voidaan lähettää; kokouslinkit, liitteet, muistutukset, palveluntarjoajakohtaiset kentät ja toistumisen poikkeukset eivät välttämättä siirry.", + "copyAndDelete": "Kopioi ja poista", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "Määräaika ei saa olla ennen alkamisaikaa.", "taskStartDueTimeModeMismatch": "Aseta kellonaika sekä alkamiselle että määräajalle tai tee tehtävästä koko päivän tehtävä.", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} kertaa", "repeatUntilSummary": "{date} asti", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Tätä toistoa ei voi käyttää palvelussa {provider}.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0474e8d2..de3b2713 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -33,6 +33,18 @@ "calendarTasks": "Tâches", "calendar": "Calendrier", "calendars": "Calendriers", + "newCalendar": "Nouveau calendrier", + "calendarColor": "Couleur du calendrier", + "calendarColorOption": "Couleur {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Ce fournisseur ne prend pas en charge la gestion des calendriers dans BusyMax.", + "primaryCalendarCannotDelete": "Le calendrier principal ne peut pas être supprimé.", + "calendarCreateFailed": "Impossible de créer le calendrier : {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Impossible de mettre à jour le calendrier : {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Impossible de supprimer le calendrier : {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Nouvel événement", "refreshCalendar": "Actualiser le calendrier", "openInProvider": "Ouvrir dans le service", @@ -541,8 +553,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Cet événement", + "thisAndFollowingEvents": "Cet événement et les suivants", + "thisAndFutureUnavailable": "Non pris en charge par ce fournisseur.", + "thisAndFutureMoveUnavailable": "Cet événement et les suivants ne peuvent pas être déplacés de manière fiable. Choisissez cet événement ou toute la série.", + "entireSeriesMoveUnavailable": "La règle de récurrence n’est pas disponible localement. Déplacez uniquement cet événement.", + "copyEventAndDeleteOriginal": "Copier l’événement et supprimer l’original ?", + "copyEventMoveWarning": "BusyMax ne peut pas déplacer directement cet événement de {source} vers {destination}. La copie sera créée en premier et l’original ne sera supprimé qu’après la réussite de la copie. Les identifiants changeront ; les statuts de réponse des participants pourront être réinitialisés et des invitations ou annulations envoyées ; les liens de conférence, pièces jointes, rappels, champs propres au fournisseur et exceptions de récurrence pourront ne pas être conservés.", + "copyAndDelete": "Copier et supprimer", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "L’échéance ne peut pas précéder le début.", "taskStartDueTimeModeMismatch": "Définissez une heure pour le début et l’échéance, ou passez la tâche en journée entière.", @@ -630,6 +648,7 @@ "repeatTimesSummary": "{count} fois", "repeatUntilSummary": "jusqu'au {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Cette récurrence ne peut pas être utilisée avec {provider}.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index c7ebe000..84b676fb 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -33,6 +33,18 @@ "calendarTasks": "कार्य", "calendar": "कैलेंडर", "calendars": "कैलेंडर", + "newCalendar": "नया कैलेंडर", + "calendarColor": "कैलेंडर का रंग", + "calendarColorOption": "रंग {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "यह प्रदाता BusyMax में कैलेंडर प्रबंधन का समर्थन नहीं करता है।", + "primaryCalendarCannotDelete": "प्राथमिक कैलेंडर को हटाया नहीं जा सकता।", + "calendarCreateFailed": "कैलेंडर नहीं बनाया जा सका: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "कैलेंडर अपडेट नहीं किया जा सका: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "कैलेंडर हटाया नहीं जा सका: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "नया ईवेंट", "refreshCalendar": "कैलेंडर रीफ़्रेश करें", "openInProvider": "सेवा में खोलें", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "यह इवेंट", + "thisAndFollowingEvents": "यह और इसके बाद के इवेंट", + "thisAndFutureUnavailable": "यह प्रदाता इसका समर्थन नहीं करता।", + "thisAndFutureMoveUnavailable": "इस और इसके बाद के इवेंट सुरक्षित रूप से नहीं ले जाए जा सकते। यह इवेंट या पूरी शृंखला चुनें।", + "entireSeriesMoveUnavailable": "दोहराव का नियम स्थानीय रूप से उपलब्ध नहीं है। इसके बजाय केवल यह इवेंट ले जाएँ।", + "copyEventAndDeleteOriginal": "इवेंट की कॉपी बनाकर मूल इवेंट मिटाएँ?", + "copyEventMoveWarning": "BusyMax इस इवेंट को {source} से {destination} में सीधे नहीं ले जा सकता। यह पहले कॉपी बनाएगा और कॉपी सफल होने के बाद ही मूल इवेंट मिटाएगा। इवेंट ID बदलेंगे; सहभागियों की प्रतिक्रिया स्थितियां रीसेट हो सकती हैं और आमंत्रण या रद्दीकरण भेजे जा सकते हैं; और कॉन्फ़्रेंस लिंक, अटैचमेंट, रिमाइंडर, प्रदाता-विशिष्ट फ़ील्ड तथा दोहराव अपवाद शायद स्थानांतरित न हों।", + "copyAndDelete": "कॉपी बनाएँ और मिटाएँ", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "नियत समय प्रारंभ समय से पहले नहीं हो सकता।", "taskStartDueTimeModeMismatch": "प्रारंभ और नियत समय दोनों सेट करें, या कार्य को पूरे दिन का बनाएँ।", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} times", "repeatUntilSummary": "until {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "इस पुनरावृत्ति का उपयोग {provider} के साथ नहीं किया जा सकता।", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 5a923f0e..a4b38f8b 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -33,6 +33,18 @@ "calendarTasks": "Attività", "calendar": "Calendario", "calendars": "Calendari", + "newCalendar": "Nuovo calendario", + "calendarColor": "Colore del calendario", + "calendarColorOption": "Colore {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Questo provider non supporta la gestione dei calendari in BusyMax.", + "primaryCalendarCannotDelete": "Il calendario principale non può essere eliminato.", + "calendarCreateFailed": "Impossibile creare il calendario: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Impossibile aggiornare il calendario: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Impossibile eliminare il calendario: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Nuovo evento", "refreshCalendar": "Aggiorna calendario", "openInProvider": "Apri nel servizio", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Questo evento", + "thisAndFollowingEvents": "Questo evento e quelli successivi", + "thisAndFutureUnavailable": "Non supportato da questo provider.", + "thisAndFutureMoveUnavailable": "Questo evento e quelli successivi non possono essere spostati in modo sicuro. Scegli questo evento o l’intera serie.", + "entireSeriesMoveUnavailable": "La regola di ricorrenza non è disponibile localmente. Sposta invece solo questo evento.", + "copyEventAndDeleteOriginal": "Copiare l’evento ed eliminare l’originale?", + "copyEventMoveWarning": "BusyMax non può spostare direttamente questo evento da {source} a {destination}. Creerà prima la copia ed eliminerà l’originale solo dopo che la copia sarà riuscita. Gli ID dell’evento cambieranno; gli stati di risposta dei partecipanti potrebbero essere reimpostati e potrebbero essere inviati inviti o annullamenti; i link alle riunioni, gli allegati, i promemoria, i campi specifici del provider e le eccezioni di ricorrenza potrebbero non essere trasferiti.", + "copyAndDelete": "Copia ed elimina", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "La scadenza non può precedere l'inizio.", "taskStartDueTimeModeMismatch": "Imposta un orario sia per l'inizio sia per la scadenza, oppure rendi l'attività valida per l'intera giornata.", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} volte", "repeatUntilSummary": "fino al {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Questa ricorrenza non può essere usata con {provider}.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3cc802be..38bf41d2 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -33,6 +33,18 @@ "calendarTasks": "タスク", "calendar": "カレンダー", "calendars": "カレンダー", + "newCalendar": "新しいカレンダー", + "calendarColor": "カレンダーの色", + "calendarColorOption": "色 {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "このプロバイダーのカレンダー管理は BusyMax ではサポートされていません。", + "primaryCalendarCannotDelete": "メインカレンダーは削除できません。", + "calendarCreateFailed": "カレンダーを作成できませんでした: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "カレンダーを更新できませんでした: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "カレンダーを削除できませんでした: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "新しい予定", "refreshCalendar": "カレンダーを更新", "openInProvider": "サービスで開く", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "この予定", + "thisAndFollowingEvents": "この予定とこれ以降の予定", + "thisAndFutureUnavailable": "このプロバイダーではサポートされていません。", + "thisAndFutureMoveUnavailable": "この予定とこれ以降の予定を安全に移動することはできません。この予定または予定全体を選択してください。", + "entireSeriesMoveUnavailable": "繰り返しルールがローカルにありません。代わりにこの予定だけを移動してください。", + "copyEventAndDeleteOriginal": "予定をコピーして元の予定を削除しますか?", + "copyEventMoveWarning": "BusyMax はこの予定を {source} から {destination} へ直接移動できません。先にコピーを作成し、コピーが成功した場合にのみ元の予定を削除します。予定 ID は変更されます。出席者の回答状況がリセットされ、招待やキャンセルが送信される場合があります。また、会議リンク、添付ファイル、リマインダー、プロバイダー固有のフィールド、繰り返しの例外は引き継がれない場合があります。", + "copyAndDelete": "コピーして削除", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "期限を開始より前に設定することはできません。", "taskStartDueTimeModeMismatch": "開始と期限の両方に時刻を設定するか、タスクを終日にしてください。", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count}回", "repeatUntilSummary": "{date}まで", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "この繰り返しは {provider} では使用できません。", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 428f5218..98faa292 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -33,6 +33,18 @@ "calendarTasks": "할 일", "calendar": "캘린더", "calendars": "캘린더", + "newCalendar": "새 캘린더", + "calendarColor": "캘린더 색상", + "calendarColorOption": "색상 {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "이 공급자는 BusyMax에서 캘린더 관리를 지원하지 않습니다.", + "primaryCalendarCannotDelete": "기본 캘린더는 삭제할 수 없습니다.", + "calendarCreateFailed": "캘린더를 만들 수 없습니다: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "캘린더를 업데이트할 수 없습니다: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "캘린더를 삭제할 수 없습니다: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "새 일정", "refreshCalendar": "캘린더 새로 고침", "openInProvider": "서비스에서 열기", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "이 일정", + "thisAndFollowingEvents": "이 일정 및 향후 일정", + "thisAndFutureUnavailable": "이 제공업체에서는 지원되지 않습니다.", + "thisAndFutureMoveUnavailable": "이 일정과 이후 일정을 안전하게 이동할 수 없습니다. 이 일정 또는 전체 반복 일정을 선택하세요.", + "entireSeriesMoveUnavailable": "반복 규칙을 로컬에서 사용할 수 없습니다. 이 일정만 이동하세요.", + "copyEventAndDeleteOriginal": "일정을 복사하고 원본을 삭제할까요?", + "copyEventMoveWarning": "BusyMax는 이 일정을 {source}에서 {destination}(으)로 직접 이동할 수 없습니다. 먼저 복사본을 만들고 복사가 성공한 뒤에만 원본을 삭제합니다. 일정 ID가 변경되고 참석자 응답 상태가 초기화되며 초대 또는 취소가 전송될 수 있습니다. 회의 링크, 첨부 파일, 미리 알림, 제공업체별 필드 및 반복 예외는 이전되지 않을 수 있습니다.", + "copyAndDelete": "복사 후 삭제", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "마감은 시작보다 빠를 수 없습니다.", "taskStartDueTimeModeMismatch": "시작과 마감에 모두 시간을 설정하거나 작업을 종일로 설정하세요.", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count}회", "repeatUntilSummary": "until {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "이 반복은 {provider}에서 사용할 수 없습니다.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 7c52989c..b709e425 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -33,6 +33,18 @@ "calendarTasks": "Tarefas", "calendar": "Calendário", "calendars": "Calendários", + "newCalendar": "Novo calendário", + "calendarColor": "Cor do calendário", + "calendarColorOption": "Cor {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Este provedor não oferece suporte ao gerenciamento de calendários no BusyMax.", + "primaryCalendarCannotDelete": "O calendário principal não pode ser excluído.", + "calendarCreateFailed": "Não foi possível criar o calendário: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Não foi possível atualizar o calendário: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Não foi possível excluir o calendário: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Novo evento", "refreshCalendar": "Atualizar calendário", "openInProvider": "Abrir no serviço", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Este evento", + "thisAndFollowingEvents": "Este evento e os seguintes", + "thisAndFutureUnavailable": "Não suportado por este provedor.", + "thisAndFutureMoveUnavailable": "Este evento e os seguintes não podem ser movidos com segurança. Escolha este evento ou a série inteira.", + "entireSeriesMoveUnavailable": "A regra de recorrência não está disponível localmente. Mova apenas este evento.", + "copyEventAndDeleteOriginal": "Copiar o evento e excluir o original?", + "copyEventMoveWarning": "O BusyMax não pode mover este evento diretamente de {source} para {destination}. Ele criará a cópia primeiro e excluirá o original somente depois que a cópia for concluída. Os IDs do evento mudarão; os status de resposta dos participantes poderão ser redefinidos e convites ou cancelamentos poderão ser enviados; e links de reunião, anexos, lembretes, campos específicos do provedor e exceções de recorrência poderão não ser transferidos.", + "copyAndDelete": "Copiar e excluir", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "O prazo não pode ser anterior ao início.", "taskStartDueTimeModeMismatch": "Defina horários para o início e o prazo, ou torne a tarefa de dia inteiro.", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} vezes", "repeatUntilSummary": "até {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Esta recorrência não pode ser usada com {provider}.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 953af511..253154cd 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -33,6 +33,18 @@ "calendarTasks": "Задачи", "calendar": "Календарь", "calendars": "Календари", + "newCalendar": "Новый календарь", + "calendarColor": "Цвет календаря", + "calendarColorOption": "Цвет {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Этот провайдер не поддерживает управление календарями в BusyMax.", + "primaryCalendarCannotDelete": "Основной календарь нельзя удалить.", + "calendarCreateFailed": "Не удалось создать календарь: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Не удалось обновить календарь: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Не удалось удалить календарь: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Новое событие", "refreshCalendar": "Обновить календарь", "openInProvider": "Открыть в сервисе", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Это событие", + "thisAndFollowingEvents": "Это и последующие события", + "thisAndFutureUnavailable": "Не поддерживается этим поставщиком.", + "thisAndFutureMoveUnavailable": "Это и последующие события нельзя безопасно переместить. Выберите это событие или всю серию.", + "entireSeriesMoveUnavailable": "Правило повтора недоступно локально. Переместите только это событие.", + "copyEventAndDeleteOriginal": "Скопировать событие и удалить оригинал?", + "copyEventMoveWarning": "BusyMax не может напрямую переместить это событие из {source} в {destination}. Сначала будет создана копия, а оригинал будет удалён только после успешного копирования. Идентификаторы события изменятся; статусы ответов участников могут быть сброшены, а приглашения или отмены — отправлены; ссылки на конференции, вложения, напоминания, поля поставщика и исключения повтора могут не сохраниться.", + "copyAndDelete": "Скопировать и удалить", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "Срок не может быть раньше начала.", "taskStartDueTimeModeMismatch": "Укажите время начала и срока или сделайте задачу на весь день.", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} раз", "repeatUntilSummary": "до {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Это повторение нельзя использовать с {provider}.", "duplicateTask": "Дублировать задачу", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6d935b89..62acb283 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -33,6 +33,18 @@ "calendarTasks": "Công việc", "calendar": "Lịch", "calendars": "Lịch", + "newCalendar": "Lịch mới", + "calendarColor": "Màu lịch", + "calendarColorOption": "Màu {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "Nhà cung cấp này không hỗ trợ quản lý lịch trong BusyMax.", + "primaryCalendarCannotDelete": "Không thể xóa lịch chính.", + "calendarCreateFailed": "Không thể tạo lịch: {error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "Không thể cập nhật lịch: {error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "Không thể xóa lịch: {error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "Sự kiện mới", "refreshCalendar": "Làm mới lịch", "openInProvider": "Mở trong dịch vụ", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "Sự kiện này", + "thisAndFollowingEvents": "Sự kiện này và các sự kiện tiếp theo", + "thisAndFutureUnavailable": "Nhà cung cấp này không hỗ trợ.", + "thisAndFutureMoveUnavailable": "Không thể di chuyển an toàn sự kiện này và các sự kiện tiếp theo. Hãy chọn sự kiện này hoặc toàn bộ chuỗi.", + "entireSeriesMoveUnavailable": "Quy tắc lặp lại không có sẵn trên thiết bị. Hãy chỉ di chuyển sự kiện này.", + "copyEventAndDeleteOriginal": "Sao chép sự kiện và xóa bản gốc?", + "copyEventMoveWarning": "BusyMax không thể di chuyển trực tiếp sự kiện này từ {source} sang {destination}. Ứng dụng sẽ tạo bản sao trước và chỉ xóa bản gốc sau khi sao chép thành công. ID sự kiện sẽ thay đổi; trạng thái phản hồi của người tham dự có thể bị đặt lại và lời mời hoặc thông báo hủy có thể được gửi; liên kết cuộc họp, tệp đính kèm, lời nhắc, trường riêng của nhà cung cấp và ngoại lệ lặp lại có thể không được chuyển sang.", + "copyAndDelete": "Sao chép và xóa", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "Hạn chót không được trước thời gian bắt đầu.", "taskStartDueTimeModeMismatch": "Đặt giờ cho cả thời gian bắt đầu và hạn chót, hoặc đặt công việc là cả ngày.", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} lần", "repeatUntilSummary": "cho đến {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "Không thể sử dụng kiểu lặp lại này với {provider}.", "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b7ae4f7a..a25a4845 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -33,6 +33,18 @@ "calendarTasks": "任务", "calendar": "日历", "calendars": "日历", + "newCalendar": "新建日历", + "calendarColor": "日历颜色", + "calendarColorOption": "颜色 {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", + "primaryCalendarCannotDelete": "无法删除主日历。", + "calendarCreateFailed": "无法创建日历:{error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "无法更新日历:{error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "无法删除日历:{error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "新建日程", "refreshCalendar": "刷新日历", "openInProvider": "在服务中打开", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "此事件", + "thisAndFollowingEvents": "此事件及后续事件", + "thisAndFutureUnavailable": "此提供商不支持。", + "thisAndFutureMoveUnavailable": "无法安全移动此活动及后续活动。请选择此活动或整个系列。", + "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此活动。", + "copyEventAndDeleteOriginal": "复制活动并删除原活动?", + "copyEventMoveWarning": "BusyMax 无法将此活动直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", + "copyAndDelete": "复制并删除", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count}次", "repeatUntilSummary": "至 {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", "duplicateTask": "复制任务", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index c74bfd03..b4e50ac3 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -33,6 +33,18 @@ "calendarTasks": "任务", "calendar": "日历", "calendars": "日历", + "newCalendar": "新建日历", + "calendarColor": "日历颜色", + "calendarColorOption": "颜色 {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", + "primaryCalendarCannotDelete": "无法删除主日历。", + "calendarCreateFailed": "无法创建日历:{error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "无法更新日历:{error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "无法删除日历:{error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "新建日程", "refreshCalendar": "刷新日历", "openInProvider": "在服务中打开", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "此事件", + "thisAndFollowingEvents": "此事件及后续事件", + "thisAndFutureUnavailable": "此提供商不支持。", + "thisAndFutureMoveUnavailable": "无法安全移动此活动及后续活动。请选择此活动或整个系列。", + "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此活动。", + "copyEventAndDeleteOriginal": "复制活动并删除原活动?", + "copyEventMoveWarning": "BusyMax 无法将此活动直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", + "copyAndDelete": "复制并删除", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count}次", "repeatUntilSummary": "至 {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", "duplicateTask": "复制任务", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 2fdca441..3f62626d 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -33,6 +33,18 @@ "calendarTasks": "待辦事項", "calendar": "行事曆", "calendars": "行事曆", + "newCalendar": "新增行事曆", + "calendarColor": "行事曆顏色", + "calendarColorOption": "顏色 {number}", + "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarManagementUnsupported": "此提供者不支援 BusyMax 中的行事曆管理。", + "primaryCalendarCannotDelete": "無法刪除主要行事曆。", + "calendarCreateFailed": "無法建立行事曆:{error}", + "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarUpdateFailed": "無法更新行事曆:{error}", + "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarDeleteFailed": "無法刪除行事曆:{error}", + "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, "newEvent": "新增活動", "refreshCalendar": "重新整理行事曆", "openInProvider": "在服務中開啟", @@ -478,8 +490,14 @@ "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", - "singleOccurrence": "This occurrence", - "thisAndFutureUnavailable": "This and future (not available)", + "singleOccurrence": "此事件", + "thisAndFollowingEvents": "此事件及後續事件", + "thisAndFutureUnavailable": "此提供者不支援。", + "thisAndFutureMoveUnavailable": "無法安全移動此活動及後續活動。請選擇此活動或整個系列。", + "entireSeriesMoveUnavailable": "本機沒有可用的重複規則。請只移動此活動。", + "copyEventAndDeleteOriginal": "複製活動並刪除原活動?", + "copyEventMoveWarning": "BusyMax 無法將此活動直接從 {source} 移動到 {destination}。應用程式會先建立副本,並且只在複製成功後刪除原活動。活動 ID 將會變更;參與者的回覆狀態可能會重設,並可能傳送邀請或取消通知;會議連結、附件、提醒、提供者特有的欄位和重複例外可能無法保留。", + "copyAndDelete": "複製並刪除", "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", "taskDueBeforeStart": "到期時間不得早於開始時間。", "taskStartDueTimeModeMismatch": "請同時設定開始與到期時間,或將工作設為全天。", @@ -567,6 +585,7 @@ "repeatTimesSummary": "{count} 次", "repeatUntilSummary": "到 {date}", "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "recurrenceUnsupportedByProvider": "此重複規則無法用於 {provider}。", "duplicateTask": "再製任務", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 02e7d186..19ec96c3 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -330,6 +330,54 @@ abstract class AppLocalizations { /// **'Calendars'** String get calendars; + /// No description provided for @newCalendar. + /// + /// In en, this message translates to: + /// **'New calendar'** + String get newCalendar; + + /// No description provided for @calendarColor. + /// + /// In en, this message translates to: + /// **'Calendar color'** + String get calendarColor; + + /// No description provided for @calendarColorOption. + /// + /// In en, this message translates to: + /// **'Color {number}'** + String calendarColorOption(int number); + + /// No description provided for @calendarManagementUnsupported. + /// + /// In en, this message translates to: + /// **'This provider does not support calendar management in BusyMax.'** + String get calendarManagementUnsupported; + + /// No description provided for @primaryCalendarCannotDelete. + /// + /// In en, this message translates to: + /// **'The primary calendar cannot be deleted.'** + String get primaryCalendarCannotDelete; + + /// No description provided for @calendarCreateFailed. + /// + /// In en, this message translates to: + /// **'Could not create the calendar: {error}'** + String calendarCreateFailed(String error); + + /// No description provided for @calendarUpdateFailed. + /// + /// In en, this message translates to: + /// **'Could not update the calendar: {error}'** + String calendarUpdateFailed(String error); + + /// No description provided for @calendarDeleteFailed. + /// + /// In en, this message translates to: + /// **'Could not delete the calendar: {error}'** + String calendarDeleteFailed(String error); + /// No description provided for @newEvent. /// /// In en, this message translates to: @@ -2412,6 +2460,12 @@ abstract class AppLocalizations { /// **'This recurrence rule uses options that this editor does not change.'** String get unsupportedRecurrencePreserved; + /// No description provided for @recurrenceUnsupportedByProvider. + /// + /// In en, this message translates to: + /// **'This recurrence cannot be used with {provider}.'** + String recurrenceUnsupportedByProvider(String provider); + /// No description provided for @importance. /// /// In en, this message translates to: @@ -3513,19 +3567,55 @@ abstract class AppLocalizations { /// No description provided for @singleOccurrence. /// /// In en, this message translates to: - /// **'This occurrence'** + /// **'This event'** String get singleOccurrence; + /// No description provided for @thisAndFollowingEvents. + /// + /// In en, this message translates to: + /// **'This and following events'** + String get thisAndFollowingEvents; + /// No description provided for @thisAndFutureUnavailable. /// /// In en, this message translates to: - /// **'This and future (not available)'** + /// **'Not supported by this provider.'** String get thisAndFutureUnavailable; + /// No description provided for @thisAndFutureMoveUnavailable. + /// + /// In en, this message translates to: + /// **'This and following events cannot be moved safely. Choose this event or the entire series.'** + String get thisAndFutureMoveUnavailable; + + /// No description provided for @entireSeriesMoveUnavailable. + /// + /// In en, this message translates to: + /// **'The recurrence rule is not available locally. Move this event instead.'** + String get entireSeriesMoveUnavailable; + + /// No description provided for @copyEventAndDeleteOriginal. + /// + /// In en, this message translates to: + /// **'Copy event and delete original?'** + String get copyEventAndDeleteOriginal; + + /// No description provided for @copyEventMoveWarning. + /// + /// In en, this message translates to: + /// **'BusyMax cannot move this event directly from {source} to {destination}. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.'** + String copyEventMoveWarning(String source, String destination); + + /// No description provided for @copyAndDelete. + /// + /// In en, this message translates to: + /// **'Copy and delete'** + String get copyAndDelete; + /// No description provided for @chooseRecurringEventScope. /// /// In en, this message translates to: - /// **'Choose whether this change applies to the entire series or only this occurrence.'** + /// **'Choose which events this change applies to.'** String get chooseRecurringEventScope; /// No description provided for @taskDueBeforeStart. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 93a7b41b..3159ace6 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -118,6 +118,39 @@ class AppLocalizationsAr extends AppLocalizations { @override String get calendars => 'التقويمات'; + @override + String get newCalendar => 'تقويم جديد'; + + @override + String get calendarColor => 'لون التقويم'; + + @override + String calendarColorOption(int number) { + return 'اللون $number'; + } + + @override + String get calendarManagementUnsupported => + 'لا يدعم هذا المزوّد إدارة التقويمات في BusyMax.'; + + @override + String get primaryCalendarCannotDelete => 'لا يمكن حذف التقويم الأساسي.'; + + @override + String calendarCreateFailed(String error) { + return 'تعذّر إنشاء التقويم: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'تعذّر تحديث التقويم: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'تعذّر حذف التقويم: $error'; + } + @override String get newEvent => 'حدث جديد'; @@ -1291,6 +1324,11 @@ class AppLocalizationsAr extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'لا يمكن استخدام هذا التكرار مع $provider.'; + } + @override String get importance => 'الأهمية'; @@ -1916,10 +1954,32 @@ class AppLocalizationsAr extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'هذا الحدث'; + + @override + String get thisAndFollowingEvents => 'هذا الحدث والأحداث التالية'; + + @override + String get thisAndFutureUnavailable => 'غير مدعوم من موفّر الخدمة هذا.'; + + @override + String get thisAndFutureMoveUnavailable => + 'لا يمكن نقل هذا الحدث والأحداث التالية بأمان. اختر هذا الحدث أو السلسلة كاملة.'; + + @override + String get entireSeriesMoveUnavailable => + 'قاعدة التكرار غير متوفرة محليًا. انقل هذا الحدث بدلاً من ذلك.'; + + @override + String get copyEventAndDeleteOriginal => 'هل تريد نسخ الحدث وحذف الأصل؟'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'لا يستطيع BusyMax نقل هذا الحدث مباشرةً من $source إلى $destination. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'نسخ وحذف'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 6f1ee48e..e523da3c 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -117,6 +117,40 @@ class AppLocalizationsDe extends AppLocalizations { @override String get calendars => 'Kalender'; + @override + String get newCalendar => 'Neuer Kalender'; + + @override + String get calendarColor => 'Kalenderfarbe'; + + @override + String calendarColorOption(int number) { + return 'Farbe $number'; + } + + @override + String get calendarManagementUnsupported => + 'Dieser Anbieter unterstützt die Kalenderverwaltung in BusyMax nicht.'; + + @override + String get primaryCalendarCannotDelete => + 'Der primäre Kalender kann nicht gelöscht werden.'; + + @override + String calendarCreateFailed(String error) { + return 'Kalender konnte nicht erstellt werden: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Kalender konnte nicht aktualisiert werden: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Kalender konnte nicht gelöscht werden: $error'; + } + @override String get newEvent => 'Neuer Termin'; @@ -1287,6 +1321,11 @@ class AppLocalizationsDe extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Diese Wiederholung kann nicht mit $provider verwendet werden.'; + } + @override String get importance => 'Wichtigkeit'; @@ -1913,10 +1952,34 @@ class AppLocalizationsDe extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Dieses Ereignis'; + + @override + String get thisAndFollowingEvents => 'Dieses und folgende Ereignisse'; + + @override + String get thisAndFutureUnavailable => + 'Von diesem Anbieter nicht unterstützt.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Dieses und die folgenden Ereignisse können nicht sicher verschoben werden. Wählen Sie dieses Ereignis oder die gesamte Serie.'; + + @override + String get entireSeriesMoveUnavailable => + 'Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen dieses Ereignis.'; + + @override + String get copyEventAndDeleteOriginal => + 'Ereignis kopieren und Original löschen?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax kann dieses Ereignis nicht direkt von $source nach $destination verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Ereignis-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Kopieren und löschen'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 921aaa36..34bbd588 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -117,6 +117,40 @@ class AppLocalizationsEn extends AppLocalizations { @override String get calendars => 'Calendars'; + @override + String get newCalendar => 'New calendar'; + + @override + String get calendarColor => 'Calendar color'; + + @override + String calendarColorOption(int number) { + return 'Color $number'; + } + + @override + String get calendarManagementUnsupported => + 'This provider does not support calendar management in BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'The primary calendar cannot be deleted.'; + + @override + String calendarCreateFailed(String error) { + return 'Could not create the calendar: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Could not update the calendar: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Could not delete the calendar: $error'; + } + @override String get newEvent => 'New event'; @@ -1279,6 +1313,11 @@ class AppLocalizationsEn extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'This recurrence cannot be used with $provider.'; + } + @override String get importance => 'Importance'; @@ -1897,14 +1936,36 @@ class AppLocalizationsEn extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'This event'; + + @override + String get thisAndFollowingEvents => 'This and following events'; + + @override + String get thisAndFutureUnavailable => 'Not supported by this provider.'; + + @override + String get thisAndFutureMoveUnavailable => + 'This and following events cannot be moved safely. Choose this event or the entire series.'; + + @override + String get entireSeriesMoveUnavailable => + 'The recurrence rule is not available locally. Move this event instead.'; + + @override + String get copyEventAndDeleteOriginal => 'Copy event and delete original?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax cannot move this event directly from $source to $destination. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Copy and delete'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Choose which events this change applies to.'; @override String get taskDueBeforeStart => 'Due must not be before start.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 8f81ebfb..ff235abe 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -119,6 +119,40 @@ class AppLocalizationsEs extends AppLocalizations { @override String get calendars => 'Calendarios'; + @override + String get newCalendar => 'Nuevo calendario'; + + @override + String get calendarColor => 'Color del calendario'; + + @override + String calendarColorOption(int number) { + return 'Color $number'; + } + + @override + String get calendarManagementUnsupported => + 'Este proveedor no admite la gestión de calendarios en BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'El calendario principal no se puede eliminar.'; + + @override + String calendarCreateFailed(String error) { + return 'No se pudo crear el calendario: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'No se pudo actualizar el calendario: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'No se pudo eliminar el calendario: $error'; + } + @override String get newEvent => 'Nuevo evento'; @@ -1288,6 +1322,11 @@ class AppLocalizationsEs extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Esta repetición no se puede usar con $provider.'; + } + @override String get importance => 'Importancia'; @@ -1914,10 +1953,33 @@ class AppLocalizationsEs extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Este evento'; + + @override + String get thisAndFollowingEvents => 'Este evento y los siguientes'; + + @override + String get thisAndFutureUnavailable => 'No compatible con este proveedor.'; + + @override + String get thisAndFutureMoveUnavailable => + 'No se pueden mover de forma segura este evento y los siguientes. Elige este evento o toda la serie.'; + + @override + String get entireSeriesMoveUnavailable => + 'La regla de repetición no está disponible localmente. Mueve solo este evento.'; + + @override + String get copyEventAndDeleteOriginal => + '¿Copiar el evento y eliminar el original?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax no puede mover este evento directamente de $source a $destination. Primero creará la copia y eliminará el original solo cuando la copia se complete correctamente. Los identificadores del evento cambiarán; los estados de respuesta de los asistentes pueden restablecerse y pueden enviarse invitaciones o cancelaciones; y es posible que no se conserven los enlaces de conferencia, los archivos adjuntos, los recordatorios, los campos específicos del proveedor ni las excepciones de repetición.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Copiar y eliminar'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 783e6f4a..235e4471 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -117,6 +117,39 @@ class AppLocalizationsEt extends AppLocalizations { @override String get calendars => 'Kalendrid'; + @override + String get newCalendar => 'Uus kalender'; + + @override + String get calendarColor => 'Kalendri värv'; + + @override + String calendarColorOption(int number) { + return 'Värv $number'; + } + + @override + String get calendarManagementUnsupported => + 'See teenusepakkuja ei toeta BusyMaxis kalendrite haldamist.'; + + @override + String get primaryCalendarCannotDelete => 'Põhikalendrit ei saa kustutada.'; + + @override + String calendarCreateFailed(String error) { + return 'Kalendri loomine nurjus: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Kalendri värskendamine nurjus: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Kalendri kustutamine nurjus: $error'; + } + @override String get newEvent => 'Uus sündmus'; @@ -1285,6 +1318,11 @@ class AppLocalizationsEt extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Seda kordust ei saa teenusega $provider kasutada.'; + } + @override String get importance => 'Tähtsus'; @@ -1904,10 +1942,33 @@ class AppLocalizationsEt extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'See sündmus'; + + @override + String get thisAndFollowingEvents => 'See ja järgmised sündmused'; + + @override + String get thisAndFutureUnavailable => 'See teenusepakkuja ei toeta seda.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Vali see sündmus või kogu sari.'; + + @override + String get entireSeriesMoveUnavailable => + 'Kordusreegel pole kohalikult saadaval. Teisalda selle asemel ainult see sündmus.'; + + @override + String get copyEventAndDeleteOriginal => + 'Kas kopeerida sündmus ja kustutada algne?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax ei saa seda sündmust otse kalendrist $source kalendrisse $destination teisaldada. Esmalt luuakse koopia ja algne sündmus kustutatakse alles pärast edukat kopeerimist. Sündmuse ID-d muutuvad; osalejate vastuseolekud võivad lähtestuda ning võidakse saata kutseid või tühistusi; konverentsilingid, manused, meeldetuletused, teenusepakkuja väljad ja korduse erandid ei pruugi üle kanduda.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Kopeeri ja kustuta'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 43bd2f52..678b5e4a 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -117,6 +117,39 @@ class AppLocalizationsFa extends AppLocalizations { @override String get calendars => 'تقویم‌ها'; + @override + String get newCalendar => 'تقویم جدید'; + + @override + String get calendarColor => 'رنگ تقویم'; + + @override + String calendarColorOption(int number) { + return 'رنگ $number'; + } + + @override + String get calendarManagementUnsupported => + 'این ارائه‌دهنده از مدیریت تقویم در BusyMax پشتیبانی نمی‌کند.'; + + @override + String get primaryCalendarCannotDelete => 'تقویم اصلی را نمی‌توان حذف کرد.'; + + @override + String calendarCreateFailed(String error) { + return 'ایجاد تقویم ممکن نشد: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'به‌روزرسانی تقویم ممکن نشد: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'حذف تقویم ممکن نشد: $error'; + } + @override String get newEvent => 'رویداد جدید'; @@ -1301,6 +1334,11 @@ class AppLocalizationsFa extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'این تکرار را نمی‌توان با $provider استفاده کرد.'; + } + @override String get importance => 'اهمیت'; @@ -1938,10 +1976,33 @@ class AppLocalizationsFa extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'این رویداد'; + + @override + String get thisAndFollowingEvents => 'این رویداد و رویدادهای بعدی'; + + @override + String get thisAndFutureUnavailable => + 'این ارائه‌دهنده از آن پشتیبانی نمی‌کند.'; + + @override + String get thisAndFutureMoveUnavailable => + 'انتقال امن این رویداد و رویدادهای بعدی ممکن نیست. این رویداد یا کل مجموعه را انتخاب کنید.'; + + @override + String get entireSeriesMoveUnavailable => + 'قاعدهٔ تکرار به‌صورت محلی در دسترس نیست. به‌جای آن فقط این رویداد را منتقل کنید.'; + + @override + String get copyEventAndDeleteOriginal => 'رویداد کپی و نسخهٔ اصلی حذف شود؟'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax نمی‌تواند این رویداد را مستقیماً از $source به $destination منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'کپی و حذف'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index c31d0646..5df4214e 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -119,6 +119,40 @@ class AppLocalizationsFi extends AppLocalizations { @override String get calendars => 'Kalenterit'; + @override + String get newCalendar => 'Uusi kalenteri'; + + @override + String get calendarColor => 'Kalenterin väri'; + + @override + String calendarColorOption(int number) { + return 'Väri $number'; + } + + @override + String get calendarManagementUnsupported => + 'Tämä palveluntarjoaja ei tue kalenterien hallintaa BusyMaxissa.'; + + @override + String get primaryCalendarCannotDelete => + 'Ensisijaista kalenteria ei voi poistaa.'; + + @override + String calendarCreateFailed(String error) { + return 'Kalenteria ei voitu luoda: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Kalenteria ei voitu päivittää: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Kalenteria ei voitu poistaa: $error'; + } + @override String get newEvent => 'Uusi tapahtuma'; @@ -1288,6 +1322,11 @@ class AppLocalizationsFi extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Tätä toistoa ei voi käyttää palvelussa $provider.'; + } + @override String get importance => 'Tärkeys'; @@ -1909,10 +1948,33 @@ class AppLocalizationsFi extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Tämä tapahtuma'; + + @override + String get thisAndFollowingEvents => 'Tämä ja seuraavat tapahtumat'; + + @override + String get thisAndFutureUnavailable => 'Tämä palveluntarjoaja ei tue tätä.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Tätä ja seuraavia tapahtumia ei voi siirtää turvallisesti. Valitse tämä tapahtuma tai koko sarja.'; + + @override + String get entireSeriesMoveUnavailable => + 'Toistumissääntö ei ole saatavilla paikallisesti. Siirrä sen sijaan vain tämä tapahtuma.'; + + @override + String get copyEventAndDeleteOriginal => + 'Kopioidaanko tapahtuma ja poistetaanko alkuperäinen?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax ei voi siirtää tätä tapahtumaa suoraan kalenterista $source kalenteriin $destination. Kopio luodaan ensin, ja alkuperäinen poistetaan vasta onnistuneen kopioinnin jälkeen. Tapahtuman tunnisteet muuttuvat; osallistujien vastaustilat voivat nollautua ja kutsuja tai peruutuksia voidaan lähettää; kokouslinkit, liitteet, muistutukset, palveluntarjoajakohtaiset kentät ja toistumisen poikkeukset eivät välttämättä siirry.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Kopioi ja poista'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 7bbb26ce..4c73efaf 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -118,6 +118,40 @@ class AppLocalizationsFr extends AppLocalizations { @override String get calendars => 'Calendriers'; + @override + String get newCalendar => 'Nouveau calendrier'; + + @override + String get calendarColor => 'Couleur du calendrier'; + + @override + String calendarColorOption(int number) { + return 'Couleur $number'; + } + + @override + String get calendarManagementUnsupported => + 'Ce fournisseur ne prend pas en charge la gestion des calendriers dans BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'Le calendrier principal ne peut pas être supprimé.'; + + @override + String calendarCreateFailed(String error) { + return 'Impossible de créer le calendrier : $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Impossible de mettre à jour le calendrier : $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Impossible de supprimer le calendrier : $error'; + } + @override String get newEvent => 'Nouvel événement'; @@ -1286,6 +1320,11 @@ class AppLocalizationsFr extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Cette récurrence ne peut pas être utilisée avec $provider.'; + } + @override String get importance => 'Importance'; @@ -1912,10 +1951,34 @@ class AppLocalizationsFr extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Cet événement'; + + @override + String get thisAndFollowingEvents => 'Cet événement et les suivants'; + + @override + String get thisAndFutureUnavailable => + 'Non pris en charge par ce fournisseur.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Cet événement et les suivants ne peuvent pas être déplacés de manière fiable. Choisissez cet événement ou toute la série.'; + + @override + String get entireSeriesMoveUnavailable => + 'La règle de récurrence n’est pas disponible localement. Déplacez uniquement cet événement.'; + + @override + String get copyEventAndDeleteOriginal => + 'Copier l’événement et supprimer l’original ?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax ne peut pas déplacer directement cet événement de $source vers $destination. La copie sera créée en premier et l’original ne sera supprimé qu’après la réussite de la copie. Les identifiants changeront ; les statuts de réponse des participants pourront être réinitialisés et des invitations ou annulations envoyées ; les liens de conférence, pièces jointes, rappels, champs propres au fournisseur et exceptions de récurrence pourront ne pas être conservés.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Copier et supprimer'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 604ac231..0c349852 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -119,6 +119,40 @@ class AppLocalizationsHi extends AppLocalizations { @override String get calendars => 'कैलेंडर'; + @override + String get newCalendar => 'नया कैलेंडर'; + + @override + String get calendarColor => 'कैलेंडर का रंग'; + + @override + String calendarColorOption(int number) { + return 'रंग $number'; + } + + @override + String get calendarManagementUnsupported => + 'यह प्रदाता BusyMax में कैलेंडर प्रबंधन का समर्थन नहीं करता है।'; + + @override + String get primaryCalendarCannotDelete => + 'प्राथमिक कैलेंडर को हटाया नहीं जा सकता।'; + + @override + String calendarCreateFailed(String error) { + return 'कैलेंडर नहीं बनाया जा सका: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'कैलेंडर अपडेट नहीं किया जा सका: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'कैलेंडर हटाया नहीं जा सका: $error'; + } + @override String get newEvent => 'नया ईवेंट'; @@ -1286,6 +1320,11 @@ class AppLocalizationsHi extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'इस पुनरावृत्ति का उपयोग $provider के साथ नहीं किया जा सकता।'; + } + @override String get importance => 'महत्त्व'; @@ -1903,10 +1942,33 @@ class AppLocalizationsHi extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'यह इवेंट'; + + @override + String get thisAndFollowingEvents => 'यह और इसके बाद के इवेंट'; + + @override + String get thisAndFutureUnavailable => 'यह प्रदाता इसका समर्थन नहीं करता।'; + + @override + String get thisAndFutureMoveUnavailable => + 'इस और इसके बाद के इवेंट सुरक्षित रूप से नहीं ले जाए जा सकते। यह इवेंट या पूरी शृंखला चुनें।'; + + @override + String get entireSeriesMoveUnavailable => + 'दोहराव का नियम स्थानीय रूप से उपलब्ध नहीं है। इसके बजाय केवल यह इवेंट ले जाएँ।'; + + @override + String get copyEventAndDeleteOriginal => + 'इवेंट की कॉपी बनाकर मूल इवेंट मिटाएँ?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax इस इवेंट को $source से $destination में सीधे नहीं ले जा सकता। यह पहले कॉपी बनाएगा और कॉपी सफल होने के बाद ही मूल इवेंट मिटाएगा। इवेंट ID बदलेंगे; सहभागियों की प्रतिक्रिया स्थितियां रीसेट हो सकती हैं और आमंत्रण या रद्दीकरण भेजे जा सकते हैं; और कॉन्फ़्रेंस लिंक, अटैचमेंट, रिमाइंडर, प्रदाता-विशिष्ट फ़ील्ड तथा दोहराव अपवाद शायद स्थानांतरित न हों।'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'कॉपी बनाएँ और मिटाएँ'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 652d8442..bb8697fe 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -119,6 +119,40 @@ class AppLocalizationsIt extends AppLocalizations { @override String get calendars => 'Calendari'; + @override + String get newCalendar => 'Nuovo calendario'; + + @override + String get calendarColor => 'Colore del calendario'; + + @override + String calendarColorOption(int number) { + return 'Colore $number'; + } + + @override + String get calendarManagementUnsupported => + 'Questo provider non supporta la gestione dei calendari in BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'Il calendario principale non può essere eliminato.'; + + @override + String calendarCreateFailed(String error) { + return 'Impossibile creare il calendario: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Impossibile aggiornare il calendario: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Impossibile eliminare il calendario: $error'; + } + @override String get newEvent => 'Nuovo evento'; @@ -1289,6 +1323,11 @@ class AppLocalizationsIt extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Questa ricorrenza non può essere usata con $provider.'; + } + @override String get importance => 'Importanza'; @@ -1915,10 +1954,33 @@ class AppLocalizationsIt extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Questo evento'; + + @override + String get thisAndFollowingEvents => 'Questo evento e quelli successivi'; + + @override + String get thisAndFutureUnavailable => 'Non supportato da questo provider.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Questo evento e quelli successivi non possono essere spostati in modo sicuro. Scegli questo evento o l’intera serie.'; + + @override + String get entireSeriesMoveUnavailable => + 'La regola di ricorrenza non è disponibile localmente. Sposta invece solo questo evento.'; + + @override + String get copyEventAndDeleteOriginal => + 'Copiare l’evento ed eliminare l’originale?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax non può spostare direttamente questo evento da $source a $destination. Creerà prima la copia ed eliminerà l’originale solo dopo che la copia sarà riuscita. Gli ID dell’evento cambieranno; gli stati di risposta dei partecipanti potrebbero essere reimpostati e potrebbero essere inviati inviti o annullamenti; i link alle riunioni, gli allegati, i promemoria, i campi specifici del provider e le eccezioni di ricorrenza potrebbero non essere trasferiti.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Copia ed elimina'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 09996619..a23746dc 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -117,6 +117,39 @@ class AppLocalizationsJa extends AppLocalizations { @override String get calendars => 'カレンダー'; + @override + String get newCalendar => '新しいカレンダー'; + + @override + String get calendarColor => 'カレンダーの色'; + + @override + String calendarColorOption(int number) { + return '色 $number'; + } + + @override + String get calendarManagementUnsupported => + 'このプロバイダーのカレンダー管理は BusyMax ではサポートされていません。'; + + @override + String get primaryCalendarCannotDelete => 'メインカレンダーは削除できません。'; + + @override + String calendarCreateFailed(String error) { + return 'カレンダーを作成できませんでした: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'カレンダーを更新できませんでした: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'カレンダーを削除できませんでした: $error'; + } + @override String get newEvent => '新しい予定'; @@ -1266,6 +1299,11 @@ class AppLocalizationsJa extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'この繰り返しは $provider では使用できません。'; + } + @override String get importance => '重要度'; @@ -1878,10 +1916,32 @@ class AppLocalizationsJa extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'この予定'; + + @override + String get thisAndFollowingEvents => 'この予定とこれ以降の予定'; + + @override + String get thisAndFutureUnavailable => 'このプロバイダーではサポートされていません。'; + + @override + String get thisAndFutureMoveUnavailable => + 'この予定とこれ以降の予定を安全に移動することはできません。この予定または予定全体を選択してください。'; + + @override + String get entireSeriesMoveUnavailable => + '繰り返しルールがローカルにありません。代わりにこの予定だけを移動してください。'; + + @override + String get copyEventAndDeleteOriginal => '予定をコピーして元の予定を削除しますか?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax はこの予定を $source から $destination へ直接移動できません。先にコピーを作成し、コピーが成功した場合にのみ元の予定を削除します。予定 ID は変更されます。出席者の回答状況がリセットされ、招待やキャンセルが送信される場合があります。また、会議リンク、添付ファイル、リマインダー、プロバイダー固有のフィールド、繰り返しの例外は引き継がれない場合があります。'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'コピーして削除'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 1be1eed7..1985eb23 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -117,6 +117,39 @@ class AppLocalizationsKo extends AppLocalizations { @override String get calendars => '캘린더'; + @override + String get newCalendar => '새 캘린더'; + + @override + String get calendarColor => '캘린더 색상'; + + @override + String calendarColorOption(int number) { + return '색상 $number'; + } + + @override + String get calendarManagementUnsupported => + '이 공급자는 BusyMax에서 캘린더 관리를 지원하지 않습니다.'; + + @override + String get primaryCalendarCannotDelete => '기본 캘린더는 삭제할 수 없습니다.'; + + @override + String calendarCreateFailed(String error) { + return '캘린더를 만들 수 없습니다: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return '캘린더를 업데이트할 수 없습니다: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return '캘린더를 삭제할 수 없습니다: $error'; + } + @override String get newEvent => '새 일정'; @@ -1266,6 +1299,11 @@ class AppLocalizationsKo extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return '이 반복은 $provider에서 사용할 수 없습니다.'; + } + @override String get importance => '중요도'; @@ -1878,10 +1916,32 @@ class AppLocalizationsKo extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => '이 일정'; + + @override + String get thisAndFollowingEvents => '이 일정 및 향후 일정'; + + @override + String get thisAndFutureUnavailable => '이 제공업체에서는 지원되지 않습니다.'; + + @override + String get thisAndFutureMoveUnavailable => + '이 일정과 이후 일정을 안전하게 이동할 수 없습니다. 이 일정 또는 전체 반복 일정을 선택하세요.'; + + @override + String get entireSeriesMoveUnavailable => + '반복 규칙을 로컬에서 사용할 수 없습니다. 이 일정만 이동하세요.'; + + @override + String get copyEventAndDeleteOriginal => '일정을 복사하고 원본을 삭제할까요?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax는 이 일정을 $source에서 $destination(으)로 직접 이동할 수 없습니다. 먼저 복사본을 만들고 복사가 성공한 뒤에만 원본을 삭제합니다. 일정 ID가 변경되고 참석자 응답 상태가 초기화되며 초대 또는 취소가 전송될 수 있습니다. 회의 링크, 첨부 파일, 미리 알림, 제공업체별 필드 및 반복 예외는 이전되지 않을 수 있습니다.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => '복사 후 삭제'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index c7063501..d59ac4a8 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -119,6 +119,40 @@ class AppLocalizationsPt extends AppLocalizations { @override String get calendars => 'Calendários'; + @override + String get newCalendar => 'Novo calendário'; + + @override + String get calendarColor => 'Cor do calendário'; + + @override + String calendarColorOption(int number) { + return 'Cor $number'; + } + + @override + String get calendarManagementUnsupported => + 'Este provedor não oferece suporte ao gerenciamento de calendários no BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'O calendário principal não pode ser excluído.'; + + @override + String calendarCreateFailed(String error) { + return 'Não foi possível criar o calendário: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Não foi possível atualizar o calendário: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Não foi possível excluir o calendário: $error'; + } + @override String get newEvent => 'Novo evento'; @@ -1289,6 +1323,11 @@ class AppLocalizationsPt extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Esta recorrência não pode ser usada com $provider.'; + } + @override String get importance => 'Importância'; @@ -1913,10 +1952,33 @@ class AppLocalizationsPt extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Este evento'; + + @override + String get thisAndFollowingEvents => 'Este evento e os seguintes'; + + @override + String get thisAndFutureUnavailable => 'Não suportado por este provedor.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Este evento e os seguintes não podem ser movidos com segurança. Escolha este evento ou a série inteira.'; + + @override + String get entireSeriesMoveUnavailable => + 'A regra de recorrência não está disponível localmente. Mova apenas este evento.'; + + @override + String get copyEventAndDeleteOriginal => + 'Copiar o evento e excluir o original?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'O BusyMax não pode mover este evento diretamente de $source para $destination. Ele criará a cópia primeiro e excluirá o original somente depois que a cópia for concluída. Os IDs do evento mudarão; os status de resposta dos participantes poderão ser redefinidos e convites ou cancelamentos poderão ser enviados; e links de reunião, anexos, lembretes, campos específicos do provedor e exceções de recorrência poderão não ser transferidos.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Copiar e excluir'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 90697731..47129b6b 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -117,6 +117,40 @@ class AppLocalizationsRu extends AppLocalizations { @override String get calendars => 'Календари'; + @override + String get newCalendar => 'Новый календарь'; + + @override + String get calendarColor => 'Цвет календаря'; + + @override + String calendarColorOption(int number) { + return 'Цвет $number'; + } + + @override + String get calendarManagementUnsupported => + 'Этот провайдер не поддерживает управление календарями в BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'Основной календарь нельзя удалить.'; + + @override + String calendarCreateFailed(String error) { + return 'Не удалось создать календарь: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Не удалось обновить календарь: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Не удалось удалить календарь: $error'; + } + @override String get newEvent => 'Новое событие'; @@ -1291,6 +1325,11 @@ class AppLocalizationsRu extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Это повторение нельзя использовать с $provider.'; + } + @override String get importance => 'Важность'; @@ -1916,10 +1955,33 @@ class AppLocalizationsRu extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Это событие'; + + @override + String get thisAndFollowingEvents => 'Это и последующие события'; + + @override + String get thisAndFutureUnavailable => 'Не поддерживается этим поставщиком.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Это и последующие события нельзя безопасно переместить. Выберите это событие или всю серию.'; + + @override + String get entireSeriesMoveUnavailable => + 'Правило повтора недоступно локально. Переместите только это событие.'; + + @override + String get copyEventAndDeleteOriginal => + 'Скопировать событие и удалить оригинал?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax не может напрямую переместить это событие из $source в $destination. Сначала будет создана копия, а оригинал будет удалён только после успешного копирования. Идентификаторы события изменятся; статусы ответов участников могут быть сброшены, а приглашения или отмены — отправлены; ссылки на конференции, вложения, напоминания, поля поставщика и исключения повтора могут не сохраниться.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Скопировать и удалить'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 970835be..5933b9de 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -117,6 +117,39 @@ class AppLocalizationsVi extends AppLocalizations { @override String get calendars => 'Lịch'; + @override + String get newCalendar => 'Lịch mới'; + + @override + String get calendarColor => 'Màu lịch'; + + @override + String calendarColorOption(int number) { + return 'Màu $number'; + } + + @override + String get calendarManagementUnsupported => + 'Nhà cung cấp này không hỗ trợ quản lý lịch trong BusyMax.'; + + @override + String get primaryCalendarCannotDelete => 'Không thể xóa lịch chính.'; + + @override + String calendarCreateFailed(String error) { + return 'Không thể tạo lịch: $error'; + } + + @override + String calendarUpdateFailed(String error) { + return 'Không thể cập nhật lịch: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Không thể xóa lịch: $error'; + } + @override String get newEvent => 'Sự kiện mới'; @@ -1285,6 +1318,11 @@ class AppLocalizationsVi extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'Không thể sử dụng kiểu lặp lại này với $provider.'; + } + @override String get importance => 'Mức độ quan trọng'; @@ -1904,10 +1942,32 @@ class AppLocalizationsVi extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => 'Sự kiện này'; + + @override + String get thisAndFollowingEvents => 'Sự kiện này và các sự kiện tiếp theo'; + + @override + String get thisAndFutureUnavailable => 'Nhà cung cấp này không hỗ trợ.'; + + @override + String get thisAndFutureMoveUnavailable => + 'Không thể di chuyển an toàn sự kiện này và các sự kiện tiếp theo. Hãy chọn sự kiện này hoặc toàn bộ chuỗi.'; + + @override + String get entireSeriesMoveUnavailable => + 'Quy tắc lặp lại không có sẵn trên thiết bị. Hãy chỉ di chuyển sự kiện này.'; + + @override + String get copyEventAndDeleteOriginal => 'Sao chép sự kiện và xóa bản gốc?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax không thể di chuyển trực tiếp sự kiện này từ $source sang $destination. Ứng dụng sẽ tạo bản sao trước và chỉ xóa bản gốc sau khi sao chép thành công. ID sự kiện sẽ thay đổi; trạng thái phản hồi của người tham dự có thể bị đặt lại và lời mời hoặc thông báo hủy có thể được gửi; liên kết cuộc họp, tệp đính kèm, lời nhắc, trường riêng của nhà cung cấp và ngoại lệ lặp lại có thể không được chuyển sang.'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => 'Sao chép và xóa'; @override String get chooseRecurringEventScope => diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 26da6b92..f1d60d67 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -116,6 +116,38 @@ class AppLocalizationsZh extends AppLocalizations { @override String get calendars => '日历'; + @override + String get newCalendar => '新建日历'; + + @override + String get calendarColor => '日历颜色'; + + @override + String calendarColorOption(int number) { + return '颜色 $number'; + } + + @override + String get calendarManagementUnsupported => '此提供商不支持 BusyMax 中的日历管理。'; + + @override + String get primaryCalendarCannotDelete => '无法删除主日历。'; + + @override + String calendarCreateFailed(String error) { + return '无法创建日历:$error'; + } + + @override + String calendarUpdateFailed(String error) { + return '无法更新日历:$error'; + } + + @override + String calendarDeleteFailed(String error) { + return '无法删除日历:$error'; + } + @override String get newEvent => '新建日程'; @@ -1259,6 +1291,11 @@ class AppLocalizationsZh extends AppLocalizations { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return '此重复规则不能用于 $provider。'; + } + @override String get importance => '重要性'; @@ -1869,10 +1906,30 @@ class AppLocalizationsZh extends AppLocalizations { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => '此事件'; + + @override + String get thisAndFollowingEvents => '此事件及后续事件'; + + @override + String get thisAndFutureUnavailable => '此提供商不支持。'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get thisAndFutureMoveUnavailable => '无法安全移动此活动及后续活动。请选择此活动或整个系列。'; + + @override + String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此活动。'; + + @override + String get copyEventAndDeleteOriginal => '复制活动并删除原活动?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax 无法将此活动直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; + } + + @override + String get copyAndDelete => '复制并删除'; @override String get chooseRecurringEventScope => @@ -2007,6 +2064,38 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get calendars => '日历'; + @override + String get newCalendar => '新建日历'; + + @override + String get calendarColor => '日历颜色'; + + @override + String calendarColorOption(int number) { + return '颜色 $number'; + } + + @override + String get calendarManagementUnsupported => '此提供商不支持 BusyMax 中的日历管理。'; + + @override + String get primaryCalendarCannotDelete => '无法删除主日历。'; + + @override + String calendarCreateFailed(String error) { + return '无法创建日历:$error'; + } + + @override + String calendarUpdateFailed(String error) { + return '无法更新日历:$error'; + } + + @override + String calendarDeleteFailed(String error) { + return '无法删除日历:$error'; + } + @override String get newEvent => '新建日程'; @@ -3150,6 +3239,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return '此重复规则不能用于 $provider。'; + } + @override String get importance => '重要性'; @@ -3760,10 +3854,30 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => '此事件'; + + @override + String get thisAndFollowingEvents => '此事件及后续事件'; @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get thisAndFutureUnavailable => '此提供商不支持。'; + + @override + String get thisAndFutureMoveUnavailable => '无法安全移动此活动及后续活动。请选择此活动或整个系列。'; + + @override + String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此活动。'; + + @override + String get copyEventAndDeleteOriginal => '复制活动并删除原活动?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax 无法将此活动直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; + } + + @override + String get copyAndDelete => '复制并删除'; @override String get chooseRecurringEventScope => @@ -3898,6 +4012,38 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get calendars => '行事曆'; + @override + String get newCalendar => '新增行事曆'; + + @override + String get calendarColor => '行事曆顏色'; + + @override + String calendarColorOption(int number) { + return '顏色 $number'; + } + + @override + String get calendarManagementUnsupported => '此提供者不支援 BusyMax 中的行事曆管理。'; + + @override + String get primaryCalendarCannotDelete => '無法刪除主要行事曆。'; + + @override + String calendarCreateFailed(String error) { + return '無法建立行事曆:$error'; + } + + @override + String calendarUpdateFailed(String error) { + return '無法更新行事曆:$error'; + } + + @override + String calendarDeleteFailed(String error) { + return '無法刪除行事曆:$error'; + } + @override String get newEvent => '新增活動'; @@ -5041,6 +5187,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get unsupportedRecurrencePreserved => 'This recurrence rule uses options that this editor does not change.'; + @override + String recurrenceUnsupportedByProvider(String provider) { + return '此重複規則無法用於 $provider。'; + } + @override String get importance => '重要性'; @@ -5652,10 +5803,30 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get entireSeries => 'Entire series'; @override - String get singleOccurrence => 'This occurrence'; + String get singleOccurrence => '此事件'; + + @override + String get thisAndFollowingEvents => '此事件及後續事件'; + + @override + String get thisAndFutureUnavailable => '此提供者不支援。'; + + @override + String get thisAndFutureMoveUnavailable => '無法安全移動此活動及後續活動。請選擇此活動或整個系列。'; + + @override + String get entireSeriesMoveUnavailable => '本機沒有可用的重複規則。請只移動此活動。'; + + @override + String get copyEventAndDeleteOriginal => '複製活動並刪除原活動?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax 無法將此活動直接從 $source 移動到 $destination。應用程式會先建立副本,並且只在複製成功後刪除原活動。活動 ID 將會變更;參與者的回覆狀態可能會重設,並可能傳送邀請或取消通知;會議連結、附件、提醒、提供者特有的欄位和重複例外可能無法保留。'; + } @override - String get thisAndFutureUnavailable => 'This and future (not available)'; + String get copyAndDelete => '複製並刪除'; @override String get chooseRecurringEventScope => diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index d267e35b..47b22d77 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -798,6 +798,23 @@ final pendingMutationSyncRequesterForAccountProvider = return requester; }); +final pendingCalendarMutationSyncRequesterForAccountProvider = + Provider.family((ref, accountId) { + final requester = PendingMutationSyncRequester( + sync: () => ref + .read(accountSyncOperationsProvider) + .syncCalendar(accountId, full: false), + onSyncFailure: ref + .watch(desktopNotificationServiceProvider) + .notifySyncFailure, + onSyncError: (error) => + _markAccountReconnectRequiredForSyncError(ref, accountId, error), + canSync: ref.watch(networkConnectivityMonitorProvider).canUseNetwork, + ); + ref.onDispose(requester.dispose); + return requester; + }); + final pendingOpResolutionServiceProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); diff --git a/lib/src/calendar_providers/calendar_colors.dart b/lib/src/calendar_providers/calendar_colors.dart index 21af0b23..d9078af6 100644 --- a/lib/src/calendar_providers/calendar_colors.dart +++ b/lib/src/calendar_providers/calendar_colors.dart @@ -1,5 +1,65 @@ +import 'dart:math' as math; + import 'package:busymax/src/providers/busy_provider.dart'; +class CalendarColorChoice { + const CalendarColorChoice({ + required this.providerValue, + required this.backgroundColor, + }); + + final String providerValue; + final String backgroundColor; +} + +const googleCalendarColorChoices = [ + CalendarColorChoice(providerValue: '#3584e4', backgroundColor: '#3584e4'), + CalendarColorChoice(providerValue: '#1c71d8', backgroundColor: '#1c71d8'), + CalendarColorChoice(providerValue: '#33d17a', backgroundColor: '#33d17a'), + CalendarColorChoice(providerValue: '#26a269', backgroundColor: '#26a269'), + CalendarColorChoice(providerValue: '#2ec1c9', backgroundColor: '#2ec1c9'), + CalendarColorChoice(providerValue: '#f6d32d', backgroundColor: '#f6d32d'), + CalendarColorChoice(providerValue: '#ff7800', backgroundColor: '#ff7800'), + CalendarColorChoice(providerValue: '#e01b24', backgroundColor: '#e01b24'), + CalendarColorChoice(providerValue: '#c061cb', backgroundColor: '#c061cb'), + CalendarColorChoice(providerValue: '#9141ac', backgroundColor: '#9141ac'), + CalendarColorChoice(providerValue: '#986a44', backgroundColor: '#986a44'), + CalendarColorChoice(providerValue: '#77767b', backgroundColor: '#77767b'), +]; + +const microsoftCalendarColorChoices = [ + CalendarColorChoice(providerValue: 'lightBlue', backgroundColor: '#0078D4'), + CalendarColorChoice(providerValue: 'lightGreen', backgroundColor: '#107C10'), + CalendarColorChoice(providerValue: 'lightOrange', backgroundColor: '#D83B01'), + CalendarColorChoice(providerValue: 'lightGray', backgroundColor: '#7A7574'), + CalendarColorChoice(providerValue: 'lightYellow', backgroundColor: '#F2C811'), + CalendarColorChoice(providerValue: 'lightTeal', backgroundColor: '#008575'), + CalendarColorChoice(providerValue: 'lightPink', backgroundColor: '#E3008C'), + CalendarColorChoice(providerValue: 'lightBrown', backgroundColor: '#8E562E'), + CalendarColorChoice(providerValue: 'lightRed', backgroundColor: '#D13438'), +]; + +List calendarColorChoices(BusyProvider provider) { + return switch (provider) { + BusyProvider.google => googleCalendarColorChoices, + BusyProvider.microsoft => microsoftCalendarColorChoices, + BusyProvider.appleICloud || BusyProvider.nextcloud => const [], + }; +} + +String calendarColorForegroundHex(String backgroundColor) { + final normalized = backgroundColor.replaceFirst('#', ''); + if (normalized.length != 6) { + throw FormatException('Expected a six-digit RGB calendar color.'); + } + final value = int.parse(normalized, radix: 16); + final red = _linearColorChannel((value >> 16) & 0xff); + final green = _linearColorChannel((value >> 8) & 0xff); + final blue = _linearColorChannel(value & 0xff); + final luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + return luminance > 0.179 ? '#000000' : '#ffffff'; +} + String? calendarSourceBackgroundColorHex({ required BusyProvider provider, String? backgroundColor, @@ -16,21 +76,23 @@ String? calendarSourceBackgroundColorHex({ } String? microsoftCalendarColorHex(String? value) { - return switch (value?.trim()) { - 'lightBlue' => '#0078D4', - 'lightGreen' => '#107C10', - 'lightOrange' => '#D83B01', - 'lightGray' => '#7A7574', - 'lightYellow' => '#F2C811', - 'lightTeal' => '#008575', - 'lightPink' => '#E3008C', - 'lightBrown' => '#8E562E', - 'lightRed' => '#D13438', - _ => null, - }; + final normalized = value?.trim(); + for (final choice in microsoftCalendarColorChoices) { + if (choice.providerValue == normalized) { + return choice.backgroundColor; + } + } + return null; } String? _nonEmpty(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; } + +double _linearColorChannel(int value) { + final channel = value / 255; + return channel <= 0.04045 + ? channel / 12.92 + : math.pow((channel + 0.055) / 1.055, 2.4).toDouble(); +} diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 3e033c6b..e1cbfeb6 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -20,6 +20,15 @@ const calendarEventClearFieldsKey = '_clearFields'; const calendarEventRecurrenceField = 'recurrenceJson'; const calendarEventAttendeesField = 'attendeesJson'; const calendarEventGuestUpdatePolicyKey = '_guestUpdatePolicy'; +const calendarEventRecurringScopeKey = '_recurringScope'; +const calendarEventTargetProviderIdKey = '_targetProviderEventId'; +const calendarEventOriginalStartKey = '_originalStart'; +const calendarEventOriginalEndKey = '_originalEnd'; +const calendarEventDestinationCalendarIdKey = '_destinationCalendarId'; +const calendarEventDestinationSourceIdKey = '_destinationSourceId'; +const calendarEventCopyConfirmationRequiredKey = '_copyConfirmationRequired'; +const calendarEventCopyConfirmedKey = '_copyConfirmed'; +const calendarEventCopyDestinationEventIdKey = '_copyDestinationEventId'; enum CalendarGuestUpdatePolicy { send, doNotSend } @@ -54,6 +63,8 @@ class CalendarEventMutation { this.responseRequested, this.hideAttendees, this.allowNewTimeProposals, + this.providerEventId, + this.providerRaw, }); final String? title; @@ -83,4 +94,6 @@ class CalendarEventMutation { final bool? responseRequested; final bool? hideAttendees; final bool? allowNewTimeProposals; + final String? providerEventId; + final Map? providerRaw; } diff --git a/lib/src/calendar_providers/calendar_provider_capabilities.dart b/lib/src/calendar_providers/calendar_provider_capabilities.dart index 3e698cdc..201032a6 100644 --- a/lib/src/calendar_providers/calendar_provider_capabilities.dart +++ b/lib/src/calendar_providers/calendar_provider_capabilities.dart @@ -1,3 +1,5 @@ +import '../providers/busy_provider.dart'; + class CalendarProviderCapabilities { const CalendarProviderCapabilities({ required this.supportsCreateCalendar, @@ -32,6 +34,20 @@ class CalendarProviderCapabilities { final bool supportsEventDelta; } +class CalendarManagementCapabilities { + const CalendarManagementCapabilities({ + required this.supportsCreate, + required this.supportsRename, + required this.supportsDelete, + required this.supportsColor, + }); + + final bool supportsCreate; + final bool supportsRename; + final bool supportsDelete; + final bool supportsColor; +} + const googleCalendarProviderCapabilities = CalendarProviderCapabilities( supportsCreateCalendar: true, supportsDeleteCalendar: true, @@ -65,3 +81,19 @@ const microsoftCalendarProviderCapabilities = CalendarProviderCapabilities( supportsCalendarDelta: false, supportsEventDelta: true, ); + +CalendarManagementCapabilities calendarManagementCapabilities( + BusyProvider provider, +) { + final cloudCapabilities = switch (provider) { + BusyProvider.google => googleCalendarProviderCapabilities, + BusyProvider.microsoft => microsoftCalendarProviderCapabilities, + BusyProvider.appleICloud || BusyProvider.nextcloud => null, + }; + return CalendarManagementCapabilities( + supportsCreate: cloudCapabilities?.supportsCreateCalendar ?? false, + supportsRename: cloudCapabilities != null, + supportsDelete: cloudCapabilities?.supportsDeleteCalendar ?? false, + supportsColor: cloudCapabilities?.supportsCalendarColor ?? false, + ); +} diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index 6bfdd945..98c5d1bc 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -49,6 +49,14 @@ abstract interface class CloudCalendarClient { CalendarGuestUpdatePolicy.send, }); + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }); + Future respondToEvent({ required String calendarId, required String eventId, diff --git a/lib/src/core/time/provider_date_time.dart b/lib/src/core/time/provider_date_time.dart index a89570ec..a4093f89 100644 --- a/lib/src/core/time/provider_date_time.dart +++ b/lib/src/core/time/provider_date_time.dart @@ -59,6 +59,43 @@ DateTime? providerDateTimeAsUtcInstant(String? value, String? timeZone) { return parsed.toUtc(); } +/// Parses a provider timestamp as the wall-clock value shown by an editor. +/// +/// Explicit offsets already carry their wall time. UTC instants are converted +/// to [timeZone] when it is an IANA zone. Zone-less provider values are wall +/// times already and must not be shifted by the machine's local zone. +DateTime? providerDateTimeAsWallTime(String? value, String? timeZone) { + if (value == null || value.isEmpty) return null; + if (!value.contains('T')) { + return DateTime.tryParse( + value.length >= 10 ? value.substring(0, 10) : value, + ); + } + if (RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value)) { + return DateTime.tryParse( + value.replaceFirst(RegExp(r'[+-]\d{2}:?\d{2}$'), ''), + ); + } + final parsed = DateTime.tryParse(value); + if (parsed == null) return null; + if (parsed.isUtc) { + return providerUtcInstantAsWallTime(parsed, timeZone); + } + if (isUtcTimeZone(timeZone)) { + return DateTime.utc( + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); + } + return parsed; +} + bool providerDateTimeIsInstant(String? value, String? timeZone) { final parsed = DateTime.tryParse(value ?? ''); if (parsed == null || value == null || !value.contains('T')) { @@ -67,6 +104,24 @@ bool providerDateTimeIsInstant(String? value, String? timeZone) { return parsed.isUtc || isUtcTimeZone(timeZone); } +DateTime providerUtcInstantAsWallTime(DateTime value, String? timeZone) { + final instant = value.toUtc(); + if (isUtcTimeZone(timeZone)) return instant; + final location = _timeZoneLocation(timeZone); + if (location == null) return instant.toLocal(); + final zoned = time_zone.TZDateTime.from(instant, location); + return DateTime( + zoned.year, + zoned.month, + zoned.day, + zoned.hour, + zoned.minute, + zoned.second, + zoned.millisecond, + zoned.microsecond, + ); +} + bool isUtcTimeZone(String? timeZone) { final normalizedZone = timeZone?.trim().toLowerCase(); return normalizedZone == 'utc' || @@ -76,14 +131,9 @@ bool isUtcTimeZone(String? timeZone) { } DateTime? _providerWallTimeAsUtc(DateTime wall, String? timeZoneId) { - final id = timeZoneId?.trim(); - if (id == null || id.isEmpty) return null; - if (!_timeZonesInitialized) { - time_zone_data.initializeTimeZones(); - _timeZonesInitialized = true; - } + final location = _timeZoneLocation(timeZoneId); + if (location == null) return null; try { - final location = time_zone.getLocation(id); final instant = time_zone.TZDateTime( location, wall.year, @@ -99,6 +149,20 @@ DateTime? _providerWallTimeAsUtc(DateTime wall, String? timeZoneId) { instant.millisecondsSinceEpoch, isUtc: true, ); + } on ArgumentError { + return null; + } +} + +time_zone.Location? _timeZoneLocation(String? timeZoneId) { + final id = timeZoneId?.trim(); + if (id == null || id.isEmpty) return null; + if (!_timeZonesInitialized) { + time_zone_data.initializeTimeZones(); + _timeZonesInitialized = true; + } + try { + return time_zone.getLocation(id); } on time_zone.LocationNotFoundException { // Some providers use platform-specific zone labels. Preserve their prior // local-wall-time behavior when no IANA definition is available. diff --git a/lib/src/dav/ical/ical_recurrence.dart b/lib/src/dav/ical/ical_recurrence.dart index 720d0340..a63c709c 100644 --- a/lib/src/dav/ical/ical_recurrence.dart +++ b/lib/src/dav/ical/ical_recurrence.dart @@ -27,6 +27,7 @@ final class IcalOccurrence { const IcalOccurrence({ required this.master, required this.override, + required this.inheritedOverride, required this.recurrenceId, required this.start, required this.end, @@ -35,18 +36,29 @@ final class IcalOccurrence { final IcalSemanticComponent master; final IcalSemanticComponent? override; + final IcalSemanticComponent? inheritedOverride; final IcalTemporalValue recurrenceId; final IcalTemporalValue start; final IcalTemporalValue? end; final String occurrenceKey; - IcalSemanticComponent get effectiveComponent => override ?? master; + IcalSemanticComponent get effectiveComponent => + override ?? inheritedOverride ?? master; + IcalSemanticComponent get identityComponent => override ?? master; bool get isException => override != null; - bool get isCancelled => override?.isCancelled ?? master.isCancelled; - - String? get summary => override?.summary ?? master.summary; - String? get description => override?.description ?? master.description; - String? get location => override?.location ?? master.location; + bool get isCancelled => + override?.isCancelled ?? + inheritedOverride?.isCancelled ?? + master.isCancelled; + + String? get summary => + override?.summary ?? inheritedOverride?.summary ?? master.summary; + String? get description => + override?.description ?? + inheritedOverride?.description ?? + master.description; + String? get location => + override?.location ?? inheritedOverride?.location ?? master.location; } /// Expands an RFC 5545 recurrence set from its authoritative resource. @@ -168,6 +180,12 @@ final class IcalRecurrenceExpander { starts.putIfAbsent(key, () => recurrenceId); } + final rangeOverrides = + overrides.values + .where((component) => component.recurrenceRange == 'THISANDFUTURE') + .toList(growable: false) + ..sort((left, right) => _compareRecurrenceIds(left, right)); + if (starts.length > limits.maximumOccurrences) { throw _occurrenceLimitError(); } @@ -176,13 +194,22 @@ final class IcalRecurrenceExpander { for (final entry in starts.entries) { final originalStart = entry.value; final exception = overrides[entry.key]; + final inheritedOverride = exception == null + ? _rangeOverrideFor(originalStart, rangeOverrides) + : null; final effectiveStart = - exception?.start ?? exception?.due ?? originalStart; - final effectiveEnd = _occurrenceEnd( - exception, - effectiveStart, - masterDuration, - ); + exception?.start ?? + exception?.due ?? + _rangeAdjustedStart(originalStart, inheritedOverride) ?? + originalStart; + final effectiveEnd = exception != null + ? _occurrenceEnd(exception, effectiveStart, masterDuration) + : _rangeOccurrenceEnd( + inheritedOverride, + effectiveStart, + masterDuration, + timeZoneResolver, + ); if (!_overlaps( effectiveStart, effectiveEnd, @@ -196,6 +223,7 @@ final class IcalRecurrenceExpander { IcalOccurrence( master: master, override: exception, + inheritedOverride: inheritedOverride, recurrenceId: originalStart, start: effectiveStart, end: effectiveEnd, @@ -786,6 +814,67 @@ IcalTemporalValue? _occurrenceEnd( return _withWallValue(start, start.localValue.add(duration)); } +int _compareRecurrenceIds( + IcalSemanticComponent left, + IcalSemanticComponent right, +) => left.recurrenceId!.localValue.compareTo(right.recurrenceId!.localValue); + +IcalSemanticComponent? _rangeOverrideFor( + IcalTemporalValue recurrenceId, + List rangeOverrides, +) { + IcalSemanticComponent? active; + for (final candidate in rangeOverrides) { + if (candidate.recurrenceId!.localValue.isAfter(recurrenceId.localValue)) { + break; + } + active = candidate; + } + return active; +} + +IcalTemporalValue? _rangeAdjustedStart( + IcalTemporalValue originalStart, + IcalSemanticComponent? rangeOverride, +) { + final recurrenceId = rangeOverride?.recurrenceId; + final rangeStart = rangeOverride?.start ?? rangeOverride?.due; + if (recurrenceId == null || rangeStart == null) return null; + final delta = _wallDateTime( + rangeStart.localValue, + ).difference(_wallDateTime(recurrenceId.localValue)); + return _withWallValue( + rangeStart, + _wallDateTime(originalStart.localValue).add(delta), + ); +} + +IcalTemporalValue? _rangeOccurrenceEnd( + IcalSemanticComponent? rangeOverride, + IcalTemporalValue start, + Duration masterDuration, + IcalTimeZoneResolver timeZoneResolver, +) { + final hasRangeDuration = + rangeOverride?.duration != null || rangeOverride?.end != null; + final duration = hasRangeDuration + ? _componentDuration(rangeOverride!, timeZoneResolver) + : masterDuration; + if (duration == Duration.zero) return null; + return _withWallValue(start, start.localValue.add(duration)); +} + +DateTime _wallDateTime(DateTime value) => DateTime.utc( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, +); + bool _overlaps( IcalTemporalValue start, IcalTemporalValue? end, diff --git a/lib/src/dav/ical/ical_semantics.dart b/lib/src/dav/ical/ical_semantics.dart index 7da67ddb..bb326bb8 100644 --- a/lib/src/dav/ical/ical_semantics.dart +++ b/lib/src/dav/ical/ical_semantics.dart @@ -67,6 +67,7 @@ final class IcalSemanticComponent { required this.uid, required this.recurrenceIdKey, required this.recurrenceId, + required this.recurrenceRange, required this.summary, required this.description, required this.location, @@ -123,6 +124,10 @@ final class IcalSemanticComponent { component.firstProperty('RECURRENCE-ID'), ), recurrenceId: parseIcalTemporal(component.firstProperty('RECURRENCE-ID')), + recurrenceRange: component + .firstProperty('RECURRENCE-ID') + ?.parameterValue('RANGE') + ?.toUpperCase(), summary: _text(component, 'SUMMARY'), description: _text(component, 'DESCRIPTION'), location: _text(component, 'LOCATION'), @@ -160,6 +165,7 @@ final class IcalSemanticComponent { final String? uid; final String? recurrenceIdKey; final IcalTemporalValue? recurrenceId; + final String? recurrenceRange; final String? summary; final String? description; final String? location; diff --git a/lib/src/dav/ical/ical_task_recurrence.dart b/lib/src/dav/ical/ical_task_recurrence.dart index 737bb6d9..ff04e688 100644 --- a/lib/src/dav/ical/ical_task_recurrence.dart +++ b/lib/src/dav/ical/ical_task_recurrence.dart @@ -1,485 +1,4 @@ -import 'dart:convert'; +import '../../features/recurrence/domain/recurrence_rule.dart'; -enum IcalTaskRecurrenceFrequency { none, daily, weekly, monthly, yearly } - -/// The recurrence subset exposed by Nextcloud Tasks 0.18.x. -/// -/// Rules outside that subset remain available through [rawRules] and are not -/// rewritten. Supported rules cover frequency, interval, BYDAY, BYMONTH, -/// BYMONTHDAY, BYSETPOS, COUNT, and UNTIL. -final class IcalTaskRecurrence { - const IcalTaskRecurrence({ - required this.frequency, - required this.interval, - required this.byDay, - required this.byMonth, - required this.byMonthDay, - required this.bySetPosition, - required this.count, - required this.untilRaw, - required this.recurrenceDates, - required this.exceptionDates, - required this.rawRules, - required this.isSupported, - }); - - const IcalTaskRecurrence.none() - : frequency = IcalTaskRecurrenceFrequency.none, - interval = 1, - byDay = const [], - byMonth = const [], - byMonthDay = const [], - bySetPosition = null, - count = null, - untilRaw = null, - recurrenceDates = const [], - exceptionDates = const [], - rawRules = const [], - isSupported = true; - - factory IcalTaskRecurrence.fromJson(String? source, {DateTime? baseDate}) { - if (source == null || source.isEmpty) { - return const IcalTaskRecurrence.none(); - } - try { - final decoded = jsonDecode(source); - if (decoded is! Map) return _unsupported(const [], const [], const []); - final map = decoded.cast(); - final rules = _strings(map['rules']); - final dates = _strings(map['dates']); - final excludedDates = _strings(map['excludedDates']); - if (rules.isEmpty) { - return IcalTaskRecurrence( - frequency: IcalTaskRecurrenceFrequency.none, - interval: 1, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - count: null, - untilRaw: null, - recurrenceDates: dates, - exceptionDates: excludedDates, - rawRules: rules, - isSupported: true, - ); - } - if (rules.length != 1) return _unsupported(rules, dates, excludedDates); - final parts = {}; - for (final segment in rules.single.split(';')) { - final separator = segment.indexOf('='); - if (separator <= 0 || separator == segment.length - 1) { - return _unsupported(rules, dates, excludedDates); - } - final key = segment.substring(0, separator).toUpperCase(); - if (parts.containsKey(key) || !_supportedParts.contains(key)) { - return _unsupported(rules, dates, excludedDates); - } - parts[key] = segment.substring(separator + 1).toUpperCase(); - } - final frequency = switch (parts['FREQ']) { - 'DAILY' => IcalTaskRecurrenceFrequency.daily, - 'WEEKLY' => IcalTaskRecurrenceFrequency.weekly, - 'MONTHLY' => IcalTaskRecurrenceFrequency.monthly, - 'YEARLY' => IcalTaskRecurrenceFrequency.yearly, - _ => null, - }; - final interval = int.tryParse(parts['INTERVAL'] ?? '1'); - final count = parts['COUNT'] == null - ? null - : int.tryParse(parts['COUNT']!); - final byDay = _csv(parts['BYDAY']); - final byMonth = _integers(parts['BYMONTH']); - final byMonthDay = _integers(parts['BYMONTHDAY']); - final bySetPositions = _integers(parts['BYSETPOS']); - final until = parts['UNTIL']; - final valid = - frequency != null && - interval != null && - interval >= 1 && - interval <= 366 && - (count == null || (count >= 1 && count <= 3500)) && - !(count != null && until != null) && - (until == null || _validUntil(until)) && - byMonth.every((value) => value >= 1 && value <= 12) && - byMonthDay.every( - (value) => value != 0 && value >= -31 && value <= 31, - ) && - bySetPositions.length <= 1 && - bySetPositions.every( - (value) => value != 0 && value >= -366 && value <= 366, - ); - if (!valid) return _unsupported(rules, dates, excludedDates); - final supportedFrequency = frequency; - final supportedInterval = interval; - - final normalized = _normalizeEditorRule( - frequency: supportedFrequency, - byDay: byDay, - byMonth: byMonth, - byMonthDay: byMonthDay, - bySetPositions: bySetPositions, - baseDate: baseDate, - ); - if (normalized == null) { - return _unsupported(rules, dates, excludedDates); - } - return IcalTaskRecurrence( - frequency: supportedFrequency, - interval: supportedInterval, - byDay: List.unmodifiable(normalized.byDay), - byMonth: List.unmodifiable(normalized.byMonth), - byMonthDay: List.unmodifiable(normalized.byMonthDay), - bySetPosition: normalized.bySetPosition, - count: count, - untilRaw: until, - recurrenceDates: List.unmodifiable(dates), - exceptionDates: List.unmodifiable(excludedDates), - rawRules: List.unmodifiable(rules), - isSupported: true, - ); - } on Object { - return _unsupported(const [], const [], const []); - } - } - - final IcalTaskRecurrenceFrequency frequency; - final int interval; - final List byDay; - final List byMonth; - final List byMonthDay; - final int? bySetPosition; - final int? count; - final String? untilRaw; - final List recurrenceDates; - final List exceptionDates; - final List rawRules; - final bool isSupported; - - bool get repeats => frequency != IcalTaskRecurrenceFrequency.none; - - String? get untilDate { - final value = untilRaw; - if (value == null || value.length < 8) return null; - final parsed = _parseUntil(value); - if (parsed == null) return null; - final display = value.endsWith('Z') ? parsed.toLocal() : parsed; - return '${display.year.toString().padLeft(4, '0')}-' - '${display.month.toString().padLeft(2, '0')}-' - '${display.day.toString().padLeft(2, '0')}'; - } - - IcalTaskRecurrence copyWith({ - IcalTaskRecurrenceFrequency? frequency, - int? interval, - List? byDay, - List? byMonth, - List? byMonthDay, - Object? bySetPosition = _unchanged, - Object? count = _unchanged, - Object? untilRaw = _unchanged, - List? recurrenceDates, - List? exceptionDates, - }) => IcalTaskRecurrence( - frequency: frequency ?? this.frequency, - interval: interval ?? this.interval, - byDay: List.unmodifiable(byDay ?? this.byDay), - byMonth: List.unmodifiable(byMonth ?? this.byMonth), - byMonthDay: List.unmodifiable(byMonthDay ?? this.byMonthDay), - bySetPosition: bySetPosition == _unchanged - ? this.bySetPosition - : bySetPosition as int?, - count: count == _unchanged ? this.count : count as int?, - untilRaw: untilRaw == _unchanged ? this.untilRaw : untilRaw as String?, - recurrenceDates: List.unmodifiable(recurrenceDates ?? this.recurrenceDates), - exceptionDates: List.unmodifiable(exceptionDates ?? this.exceptionDates), - rawRules: const [], - isSupported: true, - ); - - IcalTaskRecurrence withUntilDate(String? date, {required bool allDay}) { - if (date == null || date.isEmpty) return copyWith(untilRaw: null); - final parsed = DateTime.tryParse(date); - if (parsed == null) return this; - final localDate = DateTime(parsed.year, parsed.month, parsed.day); - final basic = _basicDate(localDate); - if (allDay) return copyWith(untilRaw: basic); - final utc = localDate.toUtc(); - return copyWith( - untilRaw: - '${_basicDate(utc)}T' - '${utc.hour.toString().padLeft(2, '0')}' - '${utc.minute.toString().padLeft(2, '0')}' - '${utc.second.toString().padLeft(2, '0')}Z', - ); - } - - String toJsonString() => jsonEncode({ - 'rules': repeats ? [toRrule()] : const [], - 'dates': recurrenceDates, - 'excludedDates': exceptionDates, - }); - - String toRrule() { - if (!isSupported || !repeats) { - throw StateError('Only a supported repeating rule can be serialized.'); - } - final result = [ - 'FREQ=${frequency.name.toUpperCase()}', - 'INTERVAL=$interval', - if (byDay.isNotEmpty) 'BYDAY=${byDay.join(',')}', - if (byMonth.isNotEmpty) 'BYMONTH=${byMonth.join(',')}', - if (byMonthDay.isNotEmpty) 'BYMONTHDAY=${byMonthDay.join(',')}', - if (bySetPosition != null) 'BYSETPOS=$bySetPosition', - if (count != null) 'COUNT=$count', - if (untilRaw != null) 'UNTIL=$untilRaw', - ]; - return result.join(';'); - } -} - -const _supportedParts = { - 'FREQ', - 'INTERVAL', - 'BYDAY', - 'BYMONTH', - 'BYMONTHDAY', - 'BYSETPOS', - 'COUNT', - 'UNTIL', -}; - -const _unchanged = Object(); - -IcalTaskRecurrence _unsupported( - List rules, - List dates, - List excludedDates, -) => IcalTaskRecurrence( - frequency: IcalTaskRecurrenceFrequency.none, - interval: 1, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - count: null, - untilRaw: null, - recurrenceDates: List.unmodifiable(dates), - exceptionDates: List.unmodifiable(excludedDates), - rawRules: List.unmodifiable(rules), - isSupported: false, -); - -List _strings(Object? source) { - if (source == null) return const []; - if (source is! List) throw const FormatException(); - return [for (final value in source) value.toString()]; -} - -List _csv(String? source) => source == null || source.isEmpty - ? const [] - : source.split(',').where((value) => value.isNotEmpty).toList(); - -List _integers(String? source) { - final values = _csv(source); - final result = []; - for (final value in values) { - final parsed = int.tryParse(value); - if (parsed == null) throw const FormatException(); - result.add(parsed); - } - return result; -} - -typedef _NormalizedEditorRule = ({ - List byDay, - List byMonth, - List byMonthDay, - int? bySetPosition, -}); - -_NormalizedEditorRule? _normalizeEditorRule({ - required IcalTaskRecurrenceFrequency frequency, - required List byDay, - required List byMonth, - required List byMonthDay, - required List bySetPositions, - required DateTime? baseDate, -}) { - switch (frequency) { - case IcalTaskRecurrenceFrequency.none: - return null; - case IcalTaskRecurrenceFrequency.daily: - if (byDay.isNotEmpty || - byMonth.isNotEmpty || - byMonthDay.isNotEmpty || - bySetPositions.isNotEmpty) { - return null; - } - return ( - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ); - case IcalTaskRecurrenceFrequency.weekly: - if (byMonth.isNotEmpty || - byMonthDay.isNotEmpty || - bySetPositions.isNotEmpty || - byDay.any((value) => !_plainWeekdays.contains(value))) { - return null; - } - return ( - byDay: byDay.isEmpty && baseDate != null - ? [_weekdayFor(baseDate.weekday)] - : byDay, - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ); - case IcalTaskRecurrenceFrequency.monthly: - if (byMonth.isNotEmpty) return null; - return _normalizeMonthlyOrYearly( - byDay: byDay, - byMonth: const [], - byMonthDay: byMonthDay, - bySetPositions: bySetPositions, - defaultMonthDay: baseDate?.day, - ); - case IcalTaskRecurrenceFrequency.yearly: - final months = byMonth.isEmpty && baseDate != null - ? [baseDate.month] - : byMonth; - if (months.isEmpty) return null; - return _normalizeMonthlyOrYearly( - byDay: byDay, - byMonth: months, - byMonthDay: byMonthDay, - bySetPositions: bySetPositions, - defaultMonthDay: baseDate?.day, - ); - } -} - -_NormalizedEditorRule? _normalizeMonthlyOrYearly({ - required List byDay, - required List byMonth, - required List byMonthDay, - required List bySetPositions, - required int? defaultMonthDay, -}) { - if (byMonthDay.isNotEmpty) { - if (byDay.isNotEmpty || - bySetPositions.isNotEmpty || - byMonthDay.any((value) => value < 1 || value > 31)) { - return null; - } - return ( - byDay: const [], - byMonth: byMonth, - byMonthDay: byMonthDay, - bySetPosition: null, - ); - } - - if (byDay.length == 1 && bySetPositions.isEmpty) { - final ordinal = RegExp( - r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$', - ).firstMatch(byDay.single); - if (ordinal != null) { - final position = int.parse(ordinal.group(1)!); - if (_editorSetPositions.contains(position)) { - return ( - byDay: [ordinal.group(2)!], - byMonth: byMonth, - byMonthDay: const [], - bySetPosition: position, - ); - } - } - } - - if (byDay.isNotEmpty && - bySetPositions.length == 1 && - _allowedEditorByDay(byDay) && - _editorSetPositions.contains(bySetPositions.single)) { - return ( - byDay: byDay, - byMonth: byMonth, - byMonthDay: const [], - bySetPosition: bySetPositions.single, - ); - } - - if (byDay.isEmpty && bySetPositions.isEmpty && defaultMonthDay != null) { - return ( - byDay: const [], - byMonth: byMonth, - byMonthDay: [defaultMonthDay], - bySetPosition: null, - ); - } - return null; -} - -bool _allowedEditorByDay(List values) { - final sorted = [...values]..sort(); - return _editorDaySets.any((allowed) { - final candidate = [...allowed]..sort(); - if (candidate.length != sorted.length) return false; - for (var index = 0; index < sorted.length; index += 1) { - if (candidate[index] != sorted[index]) return false; - } - return true; - }); -} - -bool _validUntil(String value) => _parseUntil(value) != null; - -DateTime? _parseUntil(String value) { - final match = RegExp( - r'^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$', - ).firstMatch(value); - if (match == null) return null; - final year = int.parse(match.group(1)!); - final month = int.parse(match.group(2)!); - final day = int.parse(match.group(3)!); - final hasTime = match.group(4) != null; - final hour = hasTime ? int.parse(match.group(4)!) : 0; - final minute = hasTime ? int.parse(match.group(5)!) : 0; - final second = hasTime ? int.parse(match.group(6)!) : 0; - final parsed = match.group(7) == 'Z' - ? DateTime.utc(year, month, day, hour, minute, second) - : DateTime(year, month, day, hour, minute, second); - if (parsed.year != year || - parsed.month != month || - parsed.day != day || - parsed.hour != hour || - parsed.minute != minute || - parsed.second != second) { - return null; - } - return parsed; -} - -String _basicDate(DateTime value) => - '${value.year.toString().padLeft(4, '0')}' - '${value.month.toString().padLeft(2, '0')}' - '${value.day.toString().padLeft(2, '0')}'; - -String _weekdayFor(int weekday) => _plainWeekdays[(weekday - 1).clamp(0, 6)]; - -const _plainWeekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; -const _editorSetPositions = {1, 2, 3, 4, 5, -2, -1}; -const _editorDaySets = >[ - ['MO'], - ['TU'], - ['WE'], - ['TH'], - ['FR'], - ['SA'], - ['SU'], - ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], - ['MO', 'TU', 'WE', 'TH', 'FR'], - ['SA', 'SU'], -]; +typedef IcalTaskRecurrence = RecurrenceRule; +typedef IcalTaskRecurrenceFrequency = RecurrenceFrequency; diff --git a/lib/src/dav/mutation/dav_mutation_patch.dart b/lib/src/dav/mutation/dav_mutation_patch.dart index 57f54689..48c9c3b3 100644 --- a/lib/src/dav/mutation/dav_mutation_patch.dart +++ b/lib/src/dav/mutation/dav_mutation_patch.dart @@ -516,6 +516,7 @@ const _editableProperties = { 'COMPLETED', 'CATEGORIES', 'RELATED-TO', + 'RECURRENCE-ID', 'RRULE', 'RDATE', 'EXDATE', diff --git a/lib/src/dav/mutation/dav_pending_operations.dart b/lib/src/dav/mutation/dav_pending_operations.dart index b4bf855f..cba7d6f3 100644 --- a/lib/src/dav/mutation/dav_pending_operations.dart +++ b/lib/src/dav/mutation/dav_pending_operations.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; +import '../../calendar_providers/calendar_mutation.dart'; import '../../db/app_database.dart'; import '../../features/accounts/domain/account_connection_state.dart'; import '../../providers/busy_provider.dart'; @@ -393,8 +394,11 @@ final class DavPendingOperationQueue { objectId, ); final destination = await _context(accountId, destinationCollectionId); - if (source.provider != BusyProvider.nextcloud || - destination.provider != BusyProvider.nextcloud || + final providerSupportsMove = + source.provider == BusyProvider.nextcloud || + source.provider == BusyProvider.appleICloud; + if (!providerSupportsMove || + destination.provider != source.provider || source.collection.id == destination.collection.id) { throw _invalidPendingOperation(); } @@ -732,6 +736,17 @@ final class DavPendingOperationsReplayer { if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { continue; } + if (_copyConfirmationMissing(op)) { + final error = const DavException( + kind: DavErrorKind.protocol, + code: 'DavEventCopyNotConfirmed', + safeMessage: + 'The destination event was not confirmed, so the original was kept.', + ); + await _markFailed(op, error); + await _reportPermanentFailure(op, error); + continue; + } try { await _markInProgress(op); final result = await _replay(op); @@ -920,6 +935,7 @@ final class DavPendingOperationsReplayer { completedAtUtc: _nowUtc(), ); } + await _confirmDependentCopyDeletes(op, canonical?.hrefKey); await _database.pendingOpsDao.deleteOp(op.id); if (op.operationType == 'dav.create') { if (op.eventId != null) { @@ -945,6 +961,30 @@ final class DavPendingOperationsReplayer { return affected; } + Future _confirmDependentCopyDeletes( + PendingOp completedCreate, + String? destinationHref, + ) async { + if (completedCreate.operationType != 'dav.create' || + completedCreate.eventId == null) { + return; + } + final dependents = await (_database.select( + _database.pendingOps, + )..where((row) => row.dependsOnOpId.equals(completedCreate.id))).get(); + for (final dependent in dependents) { + final request = _requestObject(dependent); + if (request[calendarEventCopyConfirmationRequiredKey] != true) continue; + request[calendarEventCopyConfirmedKey] = true; + if (destinationHref != null) { + request[calendarEventCopyDestinationEventIdKey] = destinationHref; + } + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(dependent.id))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + } + } + Future _recordConflict(PendingOp op, DavMutationResult result) async { final analysis = result.conflict; if (analysis == null) throw _invalidPendingOperation(); @@ -1128,6 +1168,19 @@ final class DavPendingOperationsReplayer { Future _opExists(String id) async => await _database.pendingOpsDao.getOp(id) != null; + bool _copyConfirmationMissing(PendingOp op) { + final request = _requestObject(op); + return request[calendarEventCopyConfirmationRequiredKey] == true && + request[calendarEventCopyConfirmedKey] != true; + } + + Map _requestObject(PendingOp op) { + final decoded = jsonDecode(op.requestJson); + return decoded is Map + ? Map.from(decoded) + : {}; + } + Future _requiredCollection(PendingOp op) async { final id = _required(op.davCollectionId); final collection = await (_database.select( diff --git a/lib/src/dav/mutation/dav_projection_mutations.dart b/lib/src/dav/mutation/dav_projection_mutations.dart index b8c6ed2b..51e8f1d8 100644 --- a/lib/src/dav/mutation/dav_projection_mutations.dart +++ b/lib/src/dav/mutation/dav_projection_mutations.dart @@ -19,6 +19,7 @@ final class DavEventMutationInput { this.recurrence, this.recurrenceChanged = false, this.reminders, + this.remindersChanged = false, this.categories = const [], this.categoriesChanged = false, this.classification, @@ -36,6 +37,7 @@ final class DavEventMutationInput { final Object? recurrence; final bool recurrenceChanged; final Object? reminders; + final bool remindersChanged; final List categories; final bool categoriesChanged; final String? classification; @@ -75,7 +77,7 @@ DavNewObject buildDavEventObject( if (input.categories.isNotEmpty) DavPatchOperation.setRaw('CATEGORIES', _categories(input.categories)), ..._recurrenceOperations(input.recurrence), - ..._alarmOperations(reminderMinutes(input.reminders), startIndex: 0), + ..._eventAlarmOperations(input.reminders, startIndex: 0), ]; if (operations.isEmpty) return initial; final patch = DavMutationPatch( @@ -169,31 +171,8 @@ DavMutationPatch? buildDavEventUpdatePatch({ if (input.recurrenceChanged) { operations.addAll(_recurrenceOperations(input.recurrence)); } - final desiredReminders = reminderMinutes(input.reminders); - final editableAlarm = _editableDisplayAlarm(current); - if (!_sameIntegers(editableAlarm.minutes, desiredReminders)) { - if (editableAlarm.index != null) { - operations.add( - DavPatchOperation.replaceAlarm( - alarmIndex: editableAlarm.index!, - alarm: desiredReminders.isEmpty - ? null - : _displayAlarm(desiredReminders.first), - ), - ); - for (var index = 1; index < desiredReminders.length; index += 1) { - operations.add( - DavPatchOperation.replaceAlarm( - alarmIndex: current.alarms.length + index - 1, - alarm: _displayAlarm(desiredReminders[index]), - ), - ); - } - } else { - operations.addAll( - _alarmOperations(desiredReminders, startIndex: current.alarms.length), - ); - } + if (input.remindersChanged) { + operations.addAll(_eventAlarmUpdateOperations(current, input.reminders)); } if (operations.isEmpty) return null; return DavMutationPatch( @@ -213,6 +192,7 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ required String occurrenceKey, required String baselineRawIcs, required DavEventMutationInput input, + bool thisAndFuture = false, DateTime Function()? nowUtc, }) { final semantic = IcalSemanticDocument.parse(baselineRawIcs); @@ -224,14 +204,45 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ ); if (master.length != 1) throw _invalidMutation(); final recurrence = _recurrenceIdentity(occurrenceKey); - if (semantic.components.any( + final existing = semantic.components.where( (component) => component.componentType == 'VEVENT' && component.uid == uid && - component.recurrenceIdKey == recurrence.key, - )) { + _recurrenceValueKey(component) == recurrence.key, + ); + if (existing.length > 1 || (existing.isNotEmpty && !thisAndFuture)) { throw _invalidMutation(); } + if (existing.isNotEmpty) { + final current = existing.single; + final target = IcalComponentKey( + componentType: 'VEVENT', + uid: uid, + recurrenceIdKey: current.recurrenceIdKey, + ); + final update = buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baselineRawIcs, + input: input, + ); + final recurrenceProperty = current.documentComponent.firstProperty( + 'RECURRENCE-ID', + ); + if (recurrenceProperty == null) throw _invalidMutation(); + return DavMutationPatch( + target: target, + scope: DavMutationScope.occurrence, + operations: [ + ...?update?.operations, + if (current.recurrenceRange != 'THISANDFUTURE') + DavPatchOperation.setRaw( + 'RECURRENCE-ID', + recurrenceProperty.rawValue, + parameters: _withThisAndFuture(recurrenceProperty.parameters), + ), + ], + ); + } final timestamp = (nowUtc ?? (() => DateTime.now().toUtc()))().toUtc(); final start = _eventTemporal( input.start, @@ -251,7 +262,9 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ _property( 'RECURRENCE-ID', recurrence.raw, - parameters: recurrence.parameters, + parameters: thisAndFuture + ? _withThisAndFuture(recurrence.parameters) + : recurrence.parameters, ), _property('DTSTAMP', _utcIcal(timestamp)), if (sequence != null) _property('SEQUENCE', '${sequence + 1}'), @@ -268,8 +281,7 @@ DavMutationPatch buildDavEventOccurrenceExceptionPatch({ _property('TRANSP', value), if (input.categories.isNotEmpty) _property('CATEGORIES', _categories(input.categories)), - for (final minutes in reminderMinutes(input.reminders)) - _displayAlarm(minutes), + ..._eventAlarmComponents(input.reminders), ], originalBeginLine: 'BEGIN:VEVENT', originalEndLine: 'END:VEVENT', @@ -293,6 +305,7 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ required String uid, required String occurrenceKey, required String baselineRawIcs, + bool thisAndFuture = false, DateTime Function()? nowUtc, }) { final semantic = IcalSemanticDocument.parse(baselineRawIcs); @@ -301,7 +314,7 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ (component) => component.componentType == 'VEVENT' && component.uid == uid && - component.recurrenceIdKey == recurrence.key, + _recurrenceValueKey(component) == recurrence.key, ); final target = IcalComponentKey( componentType: 'VEVENT', @@ -310,10 +323,27 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ ); if (existing.length > 1) throw _invalidMutation(); if (existing.length == 1) { + final component = existing.single; + final recurrenceProperty = component.documentComponent.firstProperty( + 'RECURRENCE-ID', + ); + if (recurrenceProperty == null) throw _invalidMutation(); return DavMutationPatch( - target: target, + target: IcalComponentKey( + componentType: 'VEVENT', + uid: uid, + recurrenceIdKey: component.recurrenceIdKey, + ), scope: DavMutationScope.occurrence, - operations: [DavPatchOperation.setRaw('STATUS', 'CANCELLED')], + operations: [ + DavPatchOperation.setRaw('STATUS', 'CANCELLED'), + if (thisAndFuture && component.recurrenceRange != 'THISANDFUTURE') + DavPatchOperation.setRaw( + 'RECURRENCE-ID', + recurrenceProperty.rawValue, + parameters: _withThisAndFuture(recurrenceProperty.parameters), + ), + ], ); } final master = semantic.components.where( @@ -332,7 +362,9 @@ DavMutationPatch buildDavEventOccurrenceCancellationPatch({ _property( 'RECURRENCE-ID', recurrence.raw, - parameters: recurrence.parameters, + parameters: thisAndFuture + ? _withThisAndFuture(recurrence.parameters) + : recurrence.parameters, ), _property('DTSTAMP', _utcIcal(timestamp)), if (sequence != null) _property('SEQUENCE', '${sequence + 1}'), @@ -826,6 +858,9 @@ List reminderMinutes(Object? reminders) { } } } + if (map['minutes'] is List) { + values.addAll((map['minutes']! as List).whereType()); + } return values.where((value) => value >= 0).toSet().toList()..sort(); } @@ -981,16 +1016,109 @@ String _graphRecurrenceToRrule(Map recurrence) { return parts.join(';'); } -List _alarmOperations( - List minutes, { +List _eventAlarmOperations( + Object? reminders, { required int startIndex, -}) => [ - for (var index = 0; index < minutes.length; index += 1) - DavPatchOperation.replaceAlarm( - alarmIndex: startIndex + index, - alarm: _displayAlarm(minutes[index]), - ), -]; +}) { + final alarms = _eventAlarmComponents(reminders); + return [ + for (var index = 0; index < alarms.length; index += 1) + DavPatchOperation.replaceAlarm( + alarmIndex: startIndex + index, + alarm: alarms[index], + ), + ]; +} + +List _eventAlarmUpdateOperations( + IcalSemanticComponent current, + Object? reminders, +) { + final projected = _projectedEventAlarms(reminders); + if (projected != null) { + return [ + for ( + var index = 0; + index < current.alarms.length && index < projected.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: index, + alarm: projected[index], + ), + for ( + var index = current.alarms.length - 1; + index >= projected.length; + index -= 1 + ) + DavPatchOperation.replaceAlarm(alarmIndex: index, alarm: null), + for ( + var index = current.alarms.length; + index < projected.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: index, + alarm: projected[index], + ), + ]; + } + + final desiredReminders = reminderMinutes(reminders); + final editableAlarms = _editableDisplayAlarms(current); + final currentReminders = [for (final alarm in editableAlarms) alarm.minutes]; + if (_sameIntegers(currentReminders, desiredReminders)) return const []; + return [ + for ( + var index = 0; + index < editableAlarms.length && index < desiredReminders.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: editableAlarms[index].index, + alarm: _displayAlarm(desiredReminders[index]), + ), + for ( + var index = editableAlarms.length - 1; + index >= desiredReminders.length; + index -= 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: editableAlarms[index].index, + alarm: null, + ), + for ( + var index = editableAlarms.length; + index < desiredReminders.length; + index += 1 + ) + DavPatchOperation.replaceAlarm( + alarmIndex: current.alarms.length + index - editableAlarms.length, + alarm: _displayAlarm(desiredReminders[index]), + ), + ]; +} + +List _eventAlarmComponents(Object? reminders) { + return _projectedEventAlarms(reminders) ?? + [ + for (final minutes in reminderMinutes(reminders)) + _displayAlarm(minutes), + ]; +} + +List? _projectedEventAlarms(Object? reminders) { + if (reminders is! Map || !reminders.containsKey('alarms')) return null; + final values = reminders['alarms']; + if (values is! List) throw _invalidMutation(); + return [ + for (final value in values) + if (value is Map) + IcalTaskAlarm.fromJson(value.cast()).toComponent() + else + throw _invalidMutation(), + ]; +} IcalComponent _displayAlarm(int minutes) => IcalComponent( name: 'VALARM', @@ -1004,9 +1132,10 @@ IcalComponent _displayAlarm(int minutes) => IcalComponent( structurallyDirty: true, ); -({int? index, List minutes}) _editableDisplayAlarm( +List<({int index, int minutes})> _editableDisplayAlarms( IcalSemanticComponent component, ) { + final result = <({int index, int minutes})>[]; for (var index = 0; index < component.alarms.length; index += 1) { final alarm = component.alarms[index]; if (alarm.firstProperty('ACTION')?.rawValue.toUpperCase() != 'DISPLAY') { @@ -1017,10 +1146,10 @@ IcalComponent _displayAlarm(int minutes) => IcalComponent( ? null : RegExp(r'^-PT([0-9]+)M$').firstMatch(trigger); if (match != null) { - return (index: index, minutes: [int.parse(match.group(1)!)]); + result.add((index: index, minutes: int.parse(match.group(1)!))); } } - return (index: null, minutes: const []); + return result; } ({int? index, List minutes}) _editableTaskDisplayAlarm( @@ -1513,6 +1642,33 @@ _RecurrenceIdentity _recurrenceIdentity(String occurrenceKey) { return (raw: raw, parameters: parameters, key: key); } +String? _recurrenceValueKey(IcalSemanticComponent component) { + final property = component.documentComponent.firstProperty('RECURRENCE-ID'); + if (property == null) return null; + return icalRecurrenceIdKey( + IcalProperty( + group: property.group, + name: property.name, + parameters: [ + for (final parameter in property.parameters) + if (parameter.name.toUpperCase() != 'RANGE') parameter, + ], + rawValue: property.rawValue, + originalPhysicalLines: const [], + ), + ); +} + +List _withThisAndFuture(List parameters) => [ + for (final parameter in parameters) + if (parameter.name.toUpperCase() != 'RANGE') parameter, + const IcalParameter( + name: 'RANGE', + values: ['THISANDFUTURE'], + wasQuoted: false, + ), +]; + typedef _RecurrenceIdentity = ({ String raw, List parameters, diff --git a/lib/src/dav/storage/dav_object_repository.dart b/lib/src/dav/storage/dav_object_repository.dart index 9ad74909..34275972 100644 --- a/lib/src/dav/storage/dav_object_repository.dart +++ b/lib/src/dav/storage/dav_object_repository.dart @@ -910,11 +910,12 @@ final class DavObjectRepository { final now = commit.completedAtUtc.toUtc().millisecondsSinceEpoch; for (final occurrence in occurrences) { final component = occurrence.effectiveComponent; + final identityComponent = occurrence.identityComponent; final componentId = componentIds[_componentKey( - component.componentType, - component.uid!, - component.recurrenceIdKey, + identityComponent.componentType, + identityComponent.uid!, + identityComponent.recurrenceIdKey, )]!; final eventId = _stableProjectionId( 'dav-event', @@ -969,7 +970,7 @@ final class DavObjectRepository { davObjectId: Value(objectId), davComponentId: Value(componentId), icalUid: Value(component.uid), - recurrenceIdKey: Value(component.recurrenceIdKey), + recurrenceIdKey: Value(identityComponent.recurrenceIdKey), occurrenceKey: Value(occurrence.occurrenceKey), projectionVersion: const Value(davProjectionVersion), providerRecurringEventId: Value( diff --git a/lib/src/features/calendar/data/calendar_event_detail.dart b/lib/src/features/calendar/data/calendar_event_detail.dart index 2d881477..867dae7c 100644 --- a/lib/src/features/calendar/data/calendar_event_detail.dart +++ b/lib/src/features/calendar/data/calendar_event_detail.dart @@ -43,6 +43,8 @@ final class CalendarEventDetail { required this.categories, required this.organizer, required this.creator, + required this.guestsCanModify, + required this.locked, required this.colorId, required this.colorHex, required this.visibility, @@ -63,6 +65,8 @@ final class CalendarEventDetail { }); factory CalendarEventDetail.fromRow(CalendarEvent row) { + final raw = _decodeJson(row.rawJson); + final rawMap = raw is Map ? Map.from(raw) : const {}; return CalendarEventDetail( id: row.id, accountId: row.accountId, @@ -97,6 +101,8 @@ final class CalendarEventDetail { categories: _decodeJson(row.categoriesJson), organizer: _decodeJson(row.organizerJson), creator: _decodeJson(row.creatorJson), + guestsCanModify: rawMap['guestsCanModify'] == true, + locked: rawMap['locked'] == true, colorId: row.colorId, colorHex: row.colorHex, visibility: row.visibility, @@ -107,7 +113,7 @@ final class CalendarEventDetail { attachments: _decodeJson(row.attachmentsJson), isCancelled: row.isCancelled, isDeleted: row.isDeleted, - raw: _decodeJson(row.rawJson), + raw: raw, createdAtServer: row.createdAtServer, updatedAtServer: row.updatedAtServer, createdAtLocal: row.createdAtLocal, @@ -150,6 +156,8 @@ final class CalendarEventDetail { final Object? categories; final Object? organizer; final Object? creator; + final bool guestsCanModify; + final bool locked; final String? colorId; final String? colorHex; final String? visibility; diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 46ea9310..f4ebebb3 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -5,7 +5,10 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../../../calendar_providers/calendar_mutation.dart'; +import '../../../calendar_providers/calendar_colors.dart'; +import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../calendar_providers/calendar_sync_dto.dart'; +import '../../../core/time/provider_date_time.dart'; import '../../../dav/ical/ical_document.dart'; import '../../../dav/ical/ical_semantics.dart'; import '../../../dav/mutation/dav_mutation_patch.dart'; @@ -14,6 +17,8 @@ import '../../../dav/mutation/dav_projection_mutations.dart'; import '../../../dav/storage/dav_object_repository.dart'; import '../../../db/app_database.dart'; import '../../notifications/notification_schedule_service.dart'; +import '../../recurrence/domain/event_recurrence_codec.dart'; +import '../domain/event_move_policy.dart'; import '../presentation/event_editor_draft.dart'; import 'calendar_event_detail.dart'; @@ -99,14 +104,22 @@ class CalendarSourceCapabilities { required this.canCreateEvents, required this.canEditEvents, required this.canDeleteEvents, + required this.canRenameCalendar, + required this.canDeleteCalendar, + required this.canChangeCalendarColor, }); factory CalendarSourceCapabilities.fromSource(CalendarSourceEntity source) { final writable = !source.readOnly && !source.isDeleted; + final management = calendarManagementCapabilities(source.provider); return CalendarSourceCapabilities( canCreateEvents: writable, canEditEvents: writable, canDeleteEvents: writable, + canRenameCalendar: writable && management.supportsRename, + canDeleteCalendar: + writable && management.supportsDelete && !source.primaryCalendar, + canChangeCalendarColor: writable && management.supportsColor, ); } @@ -114,18 +127,26 @@ class CalendarSourceCapabilities { canCreateEvents: false, canEditEvents: false, canDeleteEvents: false, + canRenameCalendar: false, + canDeleteCalendar: false, + canChangeCalendarColor: false, ); final bool canCreateEvents; final bool canEditEvents; final bool canDeleteEvents; + final bool canRenameCalendar; + final bool canDeleteCalendar; + final bool canChangeCalendarColor; } enum CalendarMutationOperation { + createCalendar, createEvent, editEvent, deleteEvent, renameCalendar, + changeCalendarColor, deleteCalendar, } @@ -242,41 +263,189 @@ class CalendarRepository { await _onNotificationScheduleChanged?.call(); } + Future createLocalSource({ + required String accountId, + required String summary, + }) async { + final title = summary.trim(); + if (title.isEmpty) { + throw ArgumentError.value(summary, 'summary', 'Calendar title is empty.'); + } + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(accountId))).getSingle(); + final provider = BusyProviderCodec.requireStorageValue(account.provider); + if (!calendarManagementCapabilities(provider).supportsCreate) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.createCalendar, + sourceId: accountId, + ); + } + + final uuid = const Uuid(); + final providerCalendarId = 'local:${uuid.v4()}'; + final id = sourceId( + accountId: accountId, + provider: provider, + providerCalendarId: providerCalendarId, + ); + final operationId = uuid.v4(); + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); + await _database.transaction(() async { + await _database + .into(_database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: id, + accountId: accountId, + provider: provider.storageValue, + providerCalendarId: providerCalendarId, + summary: title, + rawJson: Value( + jsonEncode({ + 'id': providerCalendarId, + 'summary': title, + '_localCreated': true, + }), + ), + createdAtLocal: now.millisecondsSinceEpoch, + updatedAtLocal: now.millisecondsSinceEpoch, + ), + ); + await _database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: operationId, + accountId: accountId, + provider: Value(provider.storageValue), + entityType: 'calendar', + operation: 'create', + operationType: const Value('calendar.create'), + calendarSourceId: Value(id), + providerCalendarId: Value(providerCalendarId), + localTempId: Value(providerCalendarId), + requestJson: jsonEncode({'summary': title}), + createdAtUtc: nowUtc, + updatedAtUtc: nowUtc, + ), + ); + }); + return id; + } + Future renameLocalSource(String sourceId, String summary) async { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); - _requireWritableSource( + _requireCalendarSourceCapability( source, operation: CalendarMutationOperation.renameCalendar, + allowed: CalendarSourceEntity.fromRow( + source, + ).capabilities.canRenameCalendar, ); - final now = _now().millisecondsSinceEpoch; - final nowUtc = DateTime.now().toUtc().toIso8601String(); + final title = summary.trim(); + if (title.isEmpty) { + throw ArgumentError.value(summary, 'summary', 'Calendar title is empty.'); + } + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); + final createOp = await _pendingCalendarCreate(source.id); await _database.transaction(() async { await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( CalendarSourcesCompanion( - summary: Value(summary), - updatedAtLocal: Value(now), + summary: Value(title), + updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); - await _database.pendingOpsDao.enqueue( - PendingOpsCompanion.insert( - id: const Uuid().v4(), - accountId: source.accountId, - provider: Value(source.provider), - entityType: 'calendar', - operation: 'patch', - operationType: const Value('calendar.patch'), - calendarSourceId: Value(source.id), - providerCalendarId: Value(source.providerCalendarId), - requestJson: jsonEncode({'summary': summary}), - baselineRawJson: Value(source.rawJson), - createdAtUtc: nowUtc, - updatedAtUtc: nowUtc, + if (createOp != null) { + final request = _calendarPendingRequest(createOp)..['summary'] = title; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(createOp.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(nowUtc), + ), + ); + } else { + await _enqueueOrMergeCalendarPatch( + source, + request: {'summary': title}, + nowUtc: nowUtc, + ); + } + }); + } + + Future setSourceColor( + String sourceId, + CalendarColorChoice choice, + ) async { + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingle(); + final entity = CalendarSourceEntity.fromRow(source); + _requireCalendarSourceCapability( + source, + operation: CalendarMutationOperation.changeCalendarColor, + allowed: entity.capabilities.canChangeCalendarColor, + ); + final supportedChoice = calendarColorChoices(entity.provider).any( + (candidate) => + candidate.providerValue == choice.providerValue && + candidate.backgroundColor.toLowerCase() == + choice.backgroundColor.toLowerCase(), + ); + if (!supportedChoice) { + throw ArgumentError.value( + choice.providerValue, + 'choice', + 'Unsupported calendar color.', + ); + } + + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); + final createOp = await _pendingCalendarCreate(source.id); + final foregroundColor = calendarColorForegroundHex(choice.backgroundColor); + final request = switch (entity.provider) { + BusyProvider.google => { + 'backgroundColor': choice.backgroundColor, + 'foregroundColor': foregroundColor, + }, + BusyProvider.microsoft => { + 'colorId': choice.providerValue, + }, + BusyProvider.appleICloud || BusyProvider.nextcloud => throw StateError( + 'DAV calendar colors are not mutable.', + ), + }; + await _database.transaction(() async { + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).write( + CalendarSourcesCompanion( + backgroundColor: Value(choice.backgroundColor), + foregroundColor: entity.provider == BusyProvider.google + ? Value(foregroundColor) + : const Value.absent(), + colorId: Value( + entity.provider == BusyProvider.microsoft + ? choice.providerValue + : null, + ), + updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); + await _enqueueOrMergeCalendarPatch( + source, + request: request, + nowUtc: nowUtc, + dependsOnOpId: createOp?.id, + ); }); } @@ -284,20 +453,36 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); - _requireWritableSource( + _requireCalendarSourceCapability( source, operation: CalendarMutationOperation.deleteCalendar, + allowed: CalendarSourceEntity.fromRow( + source, + ).capabilities.canDeleteCalendar, ); - final now = _now().millisecondsSinceEpoch; - final nowUtc = DateTime.now().toUtc().toIso8601String(); + final now = _now(); + final nowUtc = now.toUtc().toIso8601String(); + final createOp = await _pendingCalendarCreate(source.id); await _database.transaction(() async { + await (_database.delete(_database.pendingOps)..where( + (row) => + row.accountId.equals(source.accountId) & + row.calendarSourceId.equals(source.id), + )) + .go(); + if (createOp != null) { + await (_database.delete( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).go(); + return; + } await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( CalendarSourcesCompanion( isDeleted: const Value(true), hidden: const Value(true), - updatedAtLocal: Value(now), + updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); await _database.pendingOpsDao.enqueue( @@ -528,7 +713,7 @@ class CalendarRepository { }); } - Future createLocalEvent( + Future createLocalEvent( EventEditorDraft draft, { CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, @@ -550,10 +735,12 @@ class CalendarRepository { if (source.davCollectionId != null) { return _createLocalDavEvent(source, draft); } + final calendarCreateOp = await _pendingCalendarCreate(source.id); final now = _now().millisecondsSinceEpoch; final provider = BusyProviderCodec.requireStorageValue(source.provider); final conferenceRequest = _conferenceRequest(draft, provider); final localEventId = 'local:${const Uuid().v4()}'; + final operationId = const Uuid().v4(); final startTimeZone = _effectiveStartTimeZone( draft, source.timeZone, @@ -631,7 +818,7 @@ class CalendarRepository { .into(_database.pendingOps) .insert( PendingOpsCompanion.insert( - id: const Uuid().v4(), + id: operationId, accountId: draft.accountId, provider: Value(source.provider), entityType: 'event', @@ -641,6 +828,7 @@ class CalendarRepository { providerCalendarId: Value(draft.providerCalendarId), eventId: Value(id), localTempId: Value(localEventId), + dependsOnOpId: Value(calendarCreateOp?.id), requestJson: requestJson, createdAtUtc: DateTime.now().toUtc().toIso8601String(), updatedAtUtc: DateTime.now().toUtc().toIso8601String(), @@ -651,6 +839,7 @@ class CalendarRepository { draft.accountId, ); await _onNotificationScheduleChanged?.call(); + return operationId; } Future updateLocalEvent( @@ -660,7 +849,8 @@ class CalendarRepository { }) async { final eventId = draft.eventId; if (eventId == null) { - return createLocalEvent(draft, guestUpdatePolicy: guestUpdatePolicy); + await createLocalEvent(draft, guestUpdatePolicy: guestUpdatePolicy); + return; } final source = await (_database.select( _database.calendarSources, @@ -668,34 +858,77 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).getSingle(); + final originalSource = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); + _requireFullEventEditingAllowed(existing); + final editBaseline = _eventEditBaseline(draft, existing); _requireWritableSource( source, operation: CalendarMutationOperation.editEvent, ); + if (source.accountId != draft.accountId || + source.providerCalendarId != draft.providerCalendarId) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: source.id, + ); + } final sourceChanged = draft.accountId != existing.accountId || draft.sourceId != existing.calendarSourceId || - draft.providerCalendarId != existing.providerCalendarId || - source.accountId != existing.accountId || - source.provider != existing.provider || - source.providerCalendarId != existing.providerCalendarId; + draft.providerCalendarId != existing.providerCalendarId; if (sourceChanged) { - throw UnsupportedError( - 'Moving an existing event to another calendar or account is not ' - 'supported.', + _requireWritableSource( + originalSource, + operation: CalendarMutationOperation.deleteEvent, + ); + return _moveLocalEvent( + originalSource: originalSource, + destinationSource: source, + existing: existing, + draft: draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + } + if (source.provider != existing.provider || + source.accountId != existing.accountId || + source.providerCalendarId != existing.providerCalendarId) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: source.id, ); } if (source.davCollectionId != null) { return _updateLocalDavEvent(source, existing, draft); } - if (draft.recurrenceChanged && existing.providerRecurringEventId != null) { + final provider = BusyProviderCodec.requireStorageValue(source.provider); + final recurringOccurrence = existing.providerRecurringEventId != null; + final recurringScope = draft.recurringMutationScope; + if (recurringOccurrence && recurringScope == null) { + throw UnsupportedError('A recurring-event editing scope is required.'); + } + if (recurringScope == RecurringEventMutationScope.thisAndFuture && + provider != BusyProvider.google) { + throw UnsupportedError( + '${provider.displayName} does not expose a documented ' + 'this-and-following mutation through this API.', + ); + } + if (recurringScope == RecurringEventMutationScope.thisAndFuture && + !_googleEventCanSplit(existing)) { + throw UnsupportedError( + 'This Google series contains event-type or conference data that ' + 'cannot be split safely.', + ); + } + if (draft.recurrenceChanged && recurringOccurrence) { throw UnsupportedError( 'Editing a recurring series from an individual occurrence is not ' 'supported.', ); } final now = _now().millisecondsSinceEpoch; - final provider = BusyProviderCodec.requireStorageValue(source.provider); final conferenceRequest = _conferenceRequest(draft, provider); final startTimeZone = _effectiveStartTimeZone( draft, @@ -708,106 +941,974 @@ class CalendarRepository { startTimeZone, _localTimeZone, ); - final requestJson = jsonEncode( - _eventRequest( - draft, - provider, - isCreate: false, + final seriesMutation = + recurringScope == RecurringEventMutationScope.entireSeries || + recurringScope == RecurringEventMutationScope.thisAndFuture; + final request = _eventDeltaRequest( + draft, + editBaseline, + provider, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conferenceRequest, + guestUpdatePolicy: guestUpdatePolicy, + ); + if (!_eventRequestHasMutation(request)) { + return; + } + if (recurringOccurrence) { + request[calendarEventRecurringScopeKey] = recurringScope!.name; + if (seriesMutation) { + request[calendarEventTargetProviderIdKey] = + existing.providerRecurringEventId; + request[calendarEventOriginalStartKey] = + existing.providerOriginalStartKey ?? _storedEventStart(existing); + request[calendarEventOriginalEndKey] = _storedEventEnd(existing); + } + } + final requestJson = jsonEncode(request); + await _database.transaction(() async { + final predecessor = await _latestPendingEventEdit( + accountId: draft.accountId, + eventId: eventId, + ); + final projection = _eventPatchProjection( + draft: draft, + existing: existing, + provider: provider, + request: request, startTimeZone: startTimeZone, endTimeZone: endTimeZone, conference: conferenceRequest, - guestUpdatePolicy: guestUpdatePolicy, + now: now, + ); + if (seriesMutation) { + await _writeCloudSeriesProjection( + existing: existing, + draft: draft, + scope: recurringScope!, + provider: provider, + conference: conferenceRequest, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + now: now, + ); + } else { + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).write(projection); + } + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: const Uuid().v4(), + accountId: draft.accountId, + provider: Value(source.provider), + entityType: 'event', + operation: 'patch', + operationType: const Value('event.patch'), + calendarSourceId: Value(draft.sourceId), + providerCalendarId: Value(draft.providerCalendarId), + eventId: Value(eventId), + dependsOnOpId: Value(predecessor?.id), + requestJson: requestJson, + baselineUpdatedUtc: seriesMutation + ? const Value(null) + : Value(editBaseline.updatedAtServer), + baselineRawJson: seriesMutation + ? const Value(null) + : Value(_eventDetailBaselineRawJson(editBaseline)), + createdAtUtc: DateTime.now().toUtc().toIso8601String(), + updatedAtUtc: DateTime.now().toUtc().toIso8601String(), + ), + ); + }); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + draft.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + + Future _moveLocalEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + final sourceProvider = BusyProviderCodec.requireStorageValue( + originalSource.provider, + ); + final destinationProvider = BusyProviderCodec.requireStorageValue( + destinationSource.provider, + ); + final recurring = existing.providerRecurringEventId != null; + final scope = draft.recurringMutationScope; + if (recurring && scope == null) { + throw UnsupportedError('A recurring-event movement scope is required.'); + } + if (scope == RecurringEventMutationScope.thisAndFuture) { + throw UnsupportedError( + 'This and following events cannot be moved safely. Move this event or ' + 'the entire series.', + ); + } + await _requireEventReadyToMove(existing); + + final strategy = calendarEventMoveStrategy( + sourceAccountId: originalSource.accountId, + sourceId: originalSource.id, + sourceProviderCalendarId: originalSource.providerCalendarId, + sourceProvider: sourceProvider, + sourceDavCollectionId: originalSource.davCollectionId, + destinationAccountId: destinationSource.accountId, + destinationId: destinationSource.id, + destinationProviderCalendarId: destinationSource.providerCalendarId, + destinationProvider: destinationProvider, + destinationDavCollectionId: destinationSource.davCollectionId, + eventType: existing.eventType, + recurring: recurring, + recurringScope: scope, + ); + switch (strategy) { + case CalendarEventMoveStrategy.none: + throw StateError('The destination calendar is unchanged.'); + case CalendarEventMoveStrategy.googleNative: + await _moveLocalGoogleEvent( + originalSource: originalSource, + destinationSource: destinationSource, + existing: existing, + draft: draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + case CalendarEventMoveStrategy.davNative: + await _moveLocalDavEvent( + originalSource: originalSource, + destinationSource: destinationSource, + existing: existing, + draft: draft, + ); + case CalendarEventMoveStrategy.copyThenDelete: + await _copyThenDeleteLocalEvent( + originalSource: originalSource, + destinationSource: destinationSource, + existing: existing, + draft: draft, + sourceProvider: sourceProvider, + destinationProvider: destinationProvider, + guestUpdatePolicy: guestUpdatePolicy, + ); + } + } + + Future _requireEventReadyToMove(CalendarEvent existing) async { + final pending = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.entityType.equals('event') & + row.eventId.equals(existing.id), + ) + ..limit(1)) + .getSingleOrNull(); + if (existing.syncStatus != 'synced' || pending != null) { + throw StateError( + 'Wait for this event to finish synchronizing before moving it.', + ); + } + } + + Future _moveLocalGoogleEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + final recurring = existing.providerRecurringEventId != null; + final scope = draft.recurringMutationScope; + final provider = BusyProvider.google; + final startTimeZone = _effectiveStartTimeZone( + draft, + destinationSource.timeZone, + _localTimeZone, + ); + final endTimeZone = _effectiveEndTimeZone( + draft, + destinationSource.timeZone, + startTimeZone, + _localTimeZone, + ); + final conferenceRequest = _conferenceRequest(draft, provider); + final seriesMutation = + recurring && scope == RecurringEventMutationScope.entireSeries; + final patchRequest = _eventDeltaRequest( + draft, + _eventEditBaseline(draft, existing), + provider, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conferenceRequest, + guestUpdatePolicy: guestUpdatePolicy, + ); + final targetProviderEventId = seriesMutation + ? existing.providerRecurringEventId! + : existing.providerEventId; + if (seriesMutation) { + patchRequest[calendarEventRecurringScopeKey] = scope!.name; + patchRequest[calendarEventTargetProviderIdKey] = targetProviderEventId; + patchRequest[calendarEventOriginalStartKey] = + existing.providerOriginalStartKey ?? _storedEventStart(existing); + patchRequest[calendarEventOriginalEndKey] = _storedEventEnd(existing); + } + final moveOperationId = const Uuid().v4(); + final patchOperationId = const Uuid().v4(); + final nowUtc = DateTime.now().toUtc().toIso8601String(); + final patchCreatedAtUtc = DateTime.parse( + nowUtc, + ).add(const Duration(microseconds: 1)).toIso8601String(); + final now = _now().millisecondsSinceEpoch; + await _database.transaction(() async { + if (seriesMutation && _eventRequestHasMutation(patchRequest)) { + await _writeCloudSeriesProjection( + existing: existing, + draft: draft, + scope: scope!, + provider: provider, + conference: conferenceRequest, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + now: now, + ); + } else { + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(existing.id))).write( + CalendarEventsCompanion( + syncStatus: const Value('pending'), + updatedAtLocal: Value(now), + ), + ); + } + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: moveOperationId, + accountId: existing.accountId, + provider: Value(existing.provider), + entityType: 'event', + operation: 'move', + operationType: const Value('event.move'), + calendarSourceId: Value(originalSource.id), + providerCalendarId: Value(originalSource.providerCalendarId), + eventId: Value(existing.id), + requestJson: jsonEncode({ + calendarEventDestinationCalendarIdKey: + destinationSource.providerCalendarId, + calendarEventDestinationSourceIdKey: destinationSource.id, + calendarEventTargetProviderIdKey: targetProviderEventId, + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + if (seriesMutation) + calendarEventRecurringScopeKey: + RecurringEventMutationScope.entireSeries.name, + }), + baselineUpdatedUtc: seriesMutation + ? const Value(null) + : Value(existing.updatedAtServer), + baselineRawJson: seriesMutation + ? const Value(null) + : Value(existing.baselineRawJson), + createdAtUtc: nowUtc, + updatedAtUtc: nowUtc, + ), + ); + if (_eventRequestHasMutation(patchRequest)) { + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: patchOperationId, + accountId: existing.accountId, + provider: Value(destinationSource.provider), + entityType: 'event', + operation: 'patch', + operationType: const Value('event.patch'), + calendarSourceId: Value(destinationSource.id), + providerCalendarId: Value(destinationSource.providerCalendarId), + eventId: Value(existing.id), + dependsOnOpId: Value(moveOperationId), + requestJson: jsonEncode(patchRequest), + createdAtUtc: patchCreatedAtUtc, + updatedAtUtc: patchCreatedAtUtc, + ), + ); + } + }); + await _rebuildEventNotificationsFor({existing.accountId}); + } + + Future _moveLocalDavEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + }) async { + _requireDavSchedulingUnchanged(draft, creating: false); + final sourceCollectionId = originalSource.davCollectionId; + final destinationCollectionId = destinationSource.davCollectionId; + final objectId = existing.davObjectId; + final uid = existing.icalUid; + final start = draft.start; + final end = draft.end; + if (sourceCollectionId == null || + destinationCollectionId == null || + objectId == null || + uid == null || + start == null || + end == null) { + throw StateError('The DAV event is not ready to move.'); + } + final queue = DavPendingOperationQueue( + database: _database, + nowUtc: () => _now().toUtc(), + ); + final baselineRawIcs = await queue.editableRawIcsForObject( + accountId: existing.accountId, + collectionId: sourceCollectionId, + objectId: objectId, + ); + final recurring = existing.providerRecurringEventId != null; + final target = IcalComponentKey( + componentType: 'VEVENT', + uid: uid, + recurrenceIdKey: recurring ? null : existing.recurrenceIdKey, + ); + final startTimeZone = _effectiveStartTimeZone( + draft, + destinationSource.timeZone, + _localTimeZone, + ); + final endTimeZone = _effectiveEndTimeZone( + draft, + destinationSource.timeZone, + startTimeZone, + _localTimeZone, + ); + var input = _davEventInput( + draft, + start: start, + end: end, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + ); + if (recurring) { + input = _seriesDavEventInput( + baselineRawIcs: baselineRawIcs, + uid: uid, + existing: existing, + desired: input, + ); + } + final patch = buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baselineRawIcs, + input: input, + ); + final candidate = + patch?.applyTo(baselineRawIcs, nowUtc: _now().toUtc()) ?? + baselineRawIcs; + await _database.transaction(() async { + await queue.enqueueMove( + accountId: existing.accountId, + sourceCollectionId: sourceCollectionId, + destinationCollectionId: destinationCollectionId, + objectId: objectId, + target: target, + localProjectionId: existing.id, + postMovePatch: patch, + ); + await DavObjectRepository( + database: _database, + ).projectLocalMutationCandidate( + accountId: existing.accountId, + collectionId: sourceCollectionId, + provider: BusyProviderCodec.requireStorageValue( + originalSource.provider, + ), + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); + await (_database.update(_database.calendarEvents)..where( + (row) => + row.accountId.equals(existing.accountId) & + row.davObjectId.equals(objectId), + )) + .write( + CalendarEventsCompanion( + calendarSourceId: Value(destinationSource.id), + providerCalendarId: Value(destinationSource.providerCalendarId), + davCollectionId: Value(destinationCollectionId), + syncStatus: const Value('pending'), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + }); + await _rebuildEventNotificationsFor({existing.accountId}); + } + + Future _copyThenDeleteLocalEvent({ + required CalendarSource originalSource, + required CalendarSource destinationSource, + required CalendarEvent existing, + required EventEditorDraft draft, + required BusyProvider sourceProvider, + required BusyProvider destinationProvider, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + final copyDraft = _eventCopyDraft( + draft, + existing: existing, + sourceProvider: sourceProvider, + destinationProvider: destinationProvider, + ); + final createOperationId = await createLocalEvent( + copyDraft, + guestUpdatePolicy: guestUpdatePolicy, + ); + try { + if (originalSource.davCollectionId != null) { + await _queueDavEventDeletionAfterCopy( + source: originalSource, + existing: existing, + recurringScope: draft.recurringMutationScope, + dependsOnOperationId: createOperationId, + ); + } else { + await _queueCloudEventDeletionAfterCopy( + existing: existing, + recurringScope: draft.recurringMutationScope, + guestUpdatePolicy: guestUpdatePolicy, + dependsOnOperationId: createOperationId, + ); + } + } on Object { + await _discardQueuedEventCreate(createOperationId); + await _rebuildEventNotificationsFor({destinationSource.accountId}); + rethrow; + } + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(existing.id))).write( + CalendarEventsCompanion( + syncStatus: const Value('pending'), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + await _rebuildEventNotificationsFor({ + originalSource.accountId, + destinationSource.accountId, + }); + } + + EventEditorDraft _eventCopyDraft( + EventEditorDraft draft, { + required CalendarEvent existing, + required BusyProvider sourceProvider, + required BusyProvider destinationProvider, + }) { + final recurring = existing.providerRecurringEventId != null; + final scope = draft.recurringMutationScope; + Object? recurrence = draft.recurrence; + if (recurring && scope == RecurringEventMutationScope.singleOccurrence) { + recurrence = null; + } else if (recurrence != null && sourceProvider != destinationProvider) { + final sourceRule = EventRecurrenceCodec.decode( + sourceProvider, + recurrence, + baseDate: draft.start, + ); + if (sourceRule.isSupported && sourceRule.repeats) { + if (!EventRecurrenceCodec.canEncode(destinationProvider, sourceRule)) { + throw UnsupportedError( + '${destinationProvider.displayName} cannot represent this ' + 'recurrence rule.', + ); + } + recurrence = EventRecurrenceCodec.encode( + destinationProvider, + sourceRule, + baseDate: draft.start ?? DateTime.now(), + allDay: draft.allDay, + timeZone: draft.startTimeZone, + ); + } else { + final destinationRule = EventRecurrenceCodec.decode( + destinationProvider, + recurrence, + baseDate: draft.start, + ); + if (!destinationRule.isSupported || !destinationRule.repeats) { + throw UnsupportedError( + '${destinationProvider.displayName} cannot represent this ' + 'recurrence rule.', + ); + } + } + } + if (recurring && scope == RecurringEventMutationScope.entireSeries) { + final rule = EventRecurrenceCodec.decode( + destinationProvider, + recurrence, + baseDate: draft.start, + ); + if (!rule.isSupported || !rule.repeats) { + throw UnsupportedError( + 'The recurrence rule is unavailable. Move one occurrence instead.', + ); + } + } + final attendees = + destinationProvider == BusyProvider.appleICloud || + destinationProvider == BusyProvider.nextcloud + ? const [] + : [ + for (final attendee in draft.attendees) + if (!attendee.self && !attendee.organizer) + EventAttendeeDraft( + email: attendee.email, + displayName: attendee.displayName, + optional: attendee.optional, + ), + ]; + final sameProviderAccount = + sourceProvider == destinationProvider && + existing.accountId == draft.accountId; + return EventEditorDraft( + accountId: draft.accountId, + sourceId: draft.sourceId, + providerCalendarId: draft.providerCalendarId, + title: draft.title, + allDay: draft.allDay, + start: draft.start, + end: draft.end, + startTimeZone: draft.startTimeZone, + endTimeZone: draft.endTimeZone, + location: draft.location, + description: draft.description, + descriptionContentType: destinationProvider == BusyProvider.microsoft + ? draft.descriptionContentType + : null, + descriptionHtml: destinationProvider == BusyProvider.microsoft + ? draft.descriptionHtml + : null, + recurrence: recurrence, + recurrenceChanged: true, + reminders: sourceProvider == destinationProvider + ? draft.reminders + : _eventRemindersForProvider(draft.reminders, destinationProvider), + remindersChanged: true, + attendees: attendees, + attendeesChanged: true, + importance: destinationProvider == BusyProvider.microsoft + ? draft.importance + : null, + showAs: _eventShowAsForProvider(draft.showAs, destinationProvider), + visibilityOrSensitivity: _eventVisibilityForProvider( + draft.visibilityOrSensitivity, + destinationProvider, ), + colorId: sameProviderAccount ? draft.colorId : null, + categories: destinationProvider == BusyProvider.google + ? const [] + : draft.categories, + categoriesChanged: true, + responseRequested: destinationProvider == BusyProvider.microsoft + ? draft.responseRequested + : null, + hideAttendees: + destinationProvider == BusyProvider.google || + destinationProvider == BusyProvider.microsoft + ? draft.hideAttendees + : null, + allowNewTimeProposals: destinationProvider == BusyProvider.microsoft + ? draft.allowNewTimeProposals + : null, + isOrganizer: true, ); + } + + Future _queueCloudEventDeletionAfterCopy({ + required CalendarEvent existing, + required RecurringEventMutationScope? recurringScope, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + required String dependsOnOperationId, + }) async { + final series = recurringScope == RecurringEventMutationScope.entireSeries; + final request = { + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + calendarEventCopyConfirmationRequiredKey: true, + if (recurringScope != null) + calendarEventRecurringScopeKey: recurringScope.name, + if (series) ...{ + calendarEventTargetProviderIdKey: existing.providerRecurringEventId, + calendarEventOriginalStartKey: + existing.providerOriginalStartKey ?? _storedEventStart(existing), + calendarEventOriginalEndKey: _storedEventEnd(existing), + }, + }; + final now = DateTime.now().toUtc().toIso8601String(); + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: const Uuid().v4(), + accountId: existing.accountId, + provider: Value(existing.provider), + entityType: 'event', + operation: 'delete', + operationType: const Value('event.delete'), + calendarSourceId: Value(existing.calendarSourceId), + providerCalendarId: Value(existing.providerCalendarId), + eventId: Value(existing.id), + dependsOnOpId: Value(dependsOnOperationId), + requestJson: jsonEncode(request), + baselineUpdatedUtc: series + ? const Value(null) + : Value(existing.updatedAtServer), + baselineRawJson: series + ? const Value(null) + : Value(existing.baselineRawJson), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + } + + Future _queueDavEventDeletionAfterCopy({ + required CalendarSource source, + required CalendarEvent existing, + required RecurringEventMutationScope? recurringScope, + required String dependsOnOperationId, + }) async { + final collectionId = source.davCollectionId; + final objectId = existing.davObjectId; + final uid = existing.icalUid; + if (collectionId == null || objectId == null || uid == null) { + throw StateError('The DAV event baseline is unavailable.'); + } + final queue = DavPendingOperationQueue( + database: _database, + nowUtc: () => _now().toUtc(), + ); + if (recurringScope == RecurringEventMutationScope.singleOccurrence) { + final occurrenceKey = existing.occurrenceKey; + if (occurrenceKey == null) { + throw StateError('The DAV occurrence identity is unavailable.'); + } + final raw = await queue.editableRawIcsForObject( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + ); + final operationId = await queue.enqueueUpdate( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + patch: buildDavEventOccurrenceCancellationPatch( + uid: uid, + occurrenceKey: occurrenceKey, + baselineRawIcs: raw, + thisAndFuture: false, + nowUtc: () => _now().toUtc(), + ), + dependsOnOperationId: dependsOnOperationId, + ); + await _requireCopyConfirmation(operationId); + return; + } + final object = await (_database.select( + _database.davObjects, + )..where((row) => row.id.equals(objectId))).getSingle(); + final semantic = IcalSemanticDocument.parse(object.rawIcsBody); + final targets = [ + for (final component in semantic.components) + if (component.componentType == 'VEVENT' && + component.uid == uid && + (recurringScope == RecurringEventMutationScope.entireSeries || + component.recurrenceIdKey == existing.recurrenceIdKey)) + IcalComponentKey( + componentType: component.componentType, + uid: uid, + recurrenceIdKey: component.recurrenceIdKey, + ), + ]; + if (targets.isEmpty) { + throw StateError('The DAV event component is unavailable.'); + } + final identities = targets.map(_icalComponentIdentity).toSet(); + final documents = { + for (final component in semantic.components) + if (identities.contains( + _icalComponentIdentity( + IcalComponentKey( + componentType: component.componentType, + uid: component.uid!, + recurrenceIdKey: component.recurrenceIdKey, + ), + ), + )) + component.documentComponent, + }; + final hasOtherComponent = semantic.document.calendarComponents.any( + (component) => + component.name != 'VTIMEZONE' && !documents.contains(component), + ); + if (!hasOtherComponent) { + final operationId = await queue.enqueueDelete( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + target: targets.first, + scope: recurringScope == RecurringEventMutationScope.entireSeries + ? DavMutationScope.recurrenceMaster + : DavMutationScope.object, + dependsOnOperationId: dependsOnOperationId, + ); + await _requireCopyConfirmation(operationId); + return; + } + final operationId = await queue.enqueueUpdate( + accountId: existing.accountId, + collectionId: collectionId, + objectId: objectId, + patch: buildDavComponentRemovalPatch( + targets: targets, + scope: recurringScope == RecurringEventMutationScope.entireSeries + ? DavMutationScope.recurrenceMaster + : DavMutationScope.object, + ), + dependsOnOperationId: dependsOnOperationId, + ); + await _requireCopyConfirmation(operationId); + } + + Future _requireCopyConfirmation(String operationId) async { + final operation = await (_database.select( + _database.pendingOps, + )..where((row) => row.id.equals(operationId))).getSingle(); + final request = _jsonMap(operation.requestJson) + ..[calendarEventCopyConfirmationRequiredKey] = true; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(operationId))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + } + + Future _discardQueuedEventCreate(String operationId) async { + final operation = await (_database.select( + _database.pendingOps, + )..where((row) => row.id.equals(operationId))).getSingleOrNull(); + if (operation == null || operation.attemptCount != 0) return; await _database.transaction(() async { - final predecessor = await _latestPendingEventEdit( - accountId: draft.accountId, - eventId: eventId, + await _database.pendingOpsDao.deleteOp(operationId); + final eventId = operation.eventId; + if (eventId != null) { + await (_database.delete( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).go(); + } + }); + } + + Future _rebuildEventNotificationsFor(Set accountIds) async { + for (final accountId in accountIds) { + await _notificationScheduleService().rebuildUpcomingEventNotifications( + accountId, + ); + } + await _onNotificationScheduleChanged?.call(); + } + + Future _writeCloudSeriesProjection({ + required CalendarEvent existing, + required EventEditorDraft draft, + required RecurringEventMutationScope scope, + required BusyProvider provider, + required Object? conference, + required String? startTimeZone, + required String? endTimeZone, + required int now, + }) async { + final recurringEventId = existing.providerRecurringEventId; + if (recurringEventId == null) { + throw StateError('The recurring event identifier is unavailable.'); + } + 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(recurringEventId) & + row.isDeleted.equals(false), + )) + .get(); + final existingStart = _storedEventStartDateTime(existing); + final existingEnd = _storedEventEndDateTime(existing); + final startChanged = + draft.allDay != existing.allDay || draft.start != existingStart; + final endChanged = + draft.allDay != existing.allDay || draft.end != existingEnd; + final startDelta = + startChanged && draft.start != null && existingStart != null + ? draft.start!.difference(existingStart) + : Duration.zero; + final endDelta = endChanged && draft.end != null && existingEnd != null + ? draft.end!.difference(existingEnd) + : Duration.zero; + final raw = _jsonMap(existing.rawJson); + final descriptionChanged = + (draft.description ?? '') != (existing.description ?? ''); + final locationChanged = (draft.location ?? '') != (existing.location ?? ''); + final remindersChanged = !_sameJsonValue( + _decodeStoredJson(existing.remindersJson), + draft.reminders, + ); + final colorChanged = draft.colorId != existing.colorId; + final visibilityChanged = + draft.visibilityOrSensitivity != existing.visibility; + final showAsChanged = draft.showAs != existing.transparencyOrShowAs; + final importanceChanged = draft.importance != raw['importance']?.toString(); + final responseRequestedChanged = + draft.responseRequested != raw['responseRequested']; + final hideAttendeesChanged = provider == BusyProvider.google + ? draft.hideAttendees != + (raw['guestsCanSeeOtherGuests'] is bool + ? !(raw['guestsCanSeeOtherGuests'] as bool) + : null) + : draft.hideAttendees != raw['hideAttendees']; + final newTimeProposalsChanged = + draft.allowNewTimeProposals != raw['allowNewTimeProposals']; + final rawChanged = + importanceChanged || + responseRequestedChanged || + hideAttendeesChanged || + newTimeProposalsChanged; + + for (final row in rows) { + if (!_seriesRowInScope(row, existing, scope)) continue; + final rowStart = _storedEventStartDateTime(row); + final rowEnd = _storedEventEndDateTime(row); + final shiftedStart = startChanged && rowStart != null + ? rowStart.add(startDelta) + : rowStart; + final shiftedEnd = endChanged && rowEnd != null + ? rowEnd.add(endDelta) + : rowEnd; + final companion = CalendarEventsCompanion( + title: draft.title.trim() != existing.title + ? Value(draft.title.trim()) + : const Value.absent(), + description: descriptionChanged + ? Value(draft.description) + : const Value.absent(), + location: locationChanged + ? Value(draft.location) + : const Value.absent(), + allDay: startChanged || endChanged + ? Value(draft.allDay) + : const Value.absent(), + startDate: startChanged + ? Value(draft.allDay ? _date(shiftedStart) : null) + : const Value.absent(), + startDateTime: startChanged + ? Value(draft.allDay ? null : shiftedStart?.toIso8601String()) + : const Value.absent(), + startTimeZone: + startChanged || draft.startTimeZone != existing.startTimeZone + ? Value(draft.allDay ? null : startTimeZone) + : const Value.absent(), + endDate: endChanged + ? Value(draft.allDay ? _date(shiftedEnd) : null) + : const Value.absent(), + endDateTime: endChanged + ? Value(draft.allDay ? null : shiftedEnd?.toIso8601String()) + : const Value.absent(), + endTimeZone: endChanged || draft.endTimeZone != existing.endTimeZone + ? Value(draft.allDay ? null : endTimeZone) + : const Value.absent(), + remindersJson: remindersChanged + ? Value(_json(draft.reminders)) + : const Value.absent(), + attendeesJson: draft.attendeesChanged + ? Value(_json(_localAttendeesJson(draft, provider))) + : const Value.absent(), + categoriesJson: draft.categoriesChanged + ? Value(_json(_categoriesJson(draft, provider))) + : const Value.absent(), + colorId: colorChanged ? Value(draft.colorId) : const Value.absent(), + visibility: visibilityChanged + ? Value(draft.visibilityOrSensitivity) + : const Value.absent(), + transparencyOrShowAs: showAsChanged + ? Value(draft.showAs) + : const Value.absent(), + conferenceJson: conference != null + ? Value(_json(conference)) + : const Value.absent(), + rawJson: rawChanged + ? Value( + jsonEncode( + _optimisticEventRaw( + draft, + provider, + existingJson: row.rawJson, + ), + ), + ) + : const Value.absent(), + updatedAtLocal: Value(now), + syncStatus: const Value('pending'), ); await (_database.update( _database.calendarEvents, - )..where((row) => row.id.equals(eventId))).write( + )..where((table) => table.id.equals(row.id))).write(companion); + } + } + + Future _markCloudSeriesDeleted({ + required CalendarEvent existing, + required RecurringEventMutationScope scope, + required int now, + }) async { + final recurringEventId = existing.providerRecurringEventId; + if (recurringEventId == null) { + throw StateError('The recurring event identifier is unavailable.'); + } + 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(recurringEventId) & + row.isDeleted.equals(false), + )) + .get(); + for (final row in rows) { + if (!_seriesRowInScope(row, existing, scope)) continue; + await (_database.update( + _database.calendarEvents, + )..where((table) => table.id.equals(row.id))).write( CalendarEventsCompanion( - accountId: Value(draft.accountId), - calendarSourceId: Value(draft.sourceId), - provider: Value(source.provider), - providerCalendarId: Value(draft.providerCalendarId), - title: Value(draft.title.trim()), - description: Value(draft.description), - location: Value(draft.location), - allDay: Value(draft.allDay), - startDate: Value(draft.allDay ? _date(draft.start) : null), - startDateTime: Value( - draft.allDay ? null : draft.start?.toIso8601String(), - ), - startTimeZone: Value(startTimeZone), - endDate: Value(draft.allDay ? _date(draft.end) : null), - endDateTime: Value( - draft.allDay ? null : draft.end?.toIso8601String(), - ), - endTimeZone: Value(endTimeZone), - recurrenceJson: draft.recurrenceChanged - ? Value(_json(draft.recurrence)) - : const Value.absent(), - remindersJson: Value(_json(draft.reminders)), - attendeesJson: draft.attendeesChanged - ? Value(_json(_localAttendeesJson(draft, provider))) - : const Value.absent(), - categoriesJson: draft.categoriesChanged - ? Value(_json(_categoriesJson(draft, provider))) - : const Value.absent(), - colorId: Value(draft.colorId), - visibility: Value(draft.visibilityOrSensitivity), - transparencyOrShowAs: Value(draft.showAs), - conferenceJson: Value(_json(conferenceRequest ?? draft.conference)), - organizerJson: Value( - _json( - _optimisticOrganizer( - draft, - provider, - existingJson: existing.organizerJson, - ), - ), - ), - rawJson: Value( - jsonEncode( - _optimisticEventRaw( - draft, - provider, - existingJson: existing.rawJson, - ), - ), - ), - updatedAtLocal: Value(now), + isDeleted: const Value(true), syncStatus: const Value('pending'), + updatedAtLocal: Value(now), ), ); - await _database - .into(_database.pendingOps) - .insert( - PendingOpsCompanion.insert( - id: const Uuid().v4(), - accountId: draft.accountId, - provider: Value(source.provider), - entityType: 'event', - operation: 'patch', - operationType: const Value('event.patch'), - calendarSourceId: Value(draft.sourceId), - providerCalendarId: Value(draft.providerCalendarId), - eventId: Value(eventId), - dependsOnOpId: Value(predecessor?.id), - requestJson: requestJson, - baselineUpdatedUtc: Value(existing.updatedAtServer), - baselineRawJson: Value(existing.baselineRawJson), - createdAtUtc: DateTime.now().toUtc().toIso8601String(), - updatedAtUtc: DateTime.now().toUtc().toIso8601String(), - ), - ); - }); - await _notificationScheduleService().rebuildUpcomingEventNotifications( - draft.accountId, - ); - await _onNotificationScheduleChanged?.call(); + } } Future _latestPendingEventEdit({ @@ -863,17 +1964,51 @@ class CalendarRepository { recurringScope: recurringScope, ); } + final provider = BusyProviderCodec.requireStorageValue(existing.provider); + final recurringOccurrence = existing.providerRecurringEventId != null; + if (recurringOccurrence && recurringScope == null) { + throw UnsupportedError('A recurring-event deletion scope is required.'); + } + if (recurringScope == RecurringEventMutationScope.thisAndFuture && + provider != BusyProvider.google) { + throw UnsupportedError( + '${provider.displayName} does not expose a documented ' + 'this-and-following mutation through this API.', + ); + } + final seriesMutation = + recurringScope == RecurringEventMutationScope.entireSeries || + recurringScope == RecurringEventMutationScope.thisAndFuture; + final request = { + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + if (recurringScope != null) + calendarEventRecurringScopeKey: recurringScope.name, + if (seriesMutation) ...{ + calendarEventTargetProviderIdKey: existing.providerRecurringEventId, + calendarEventOriginalStartKey: + existing.providerOriginalStartKey ?? _storedEventStart(existing), + calendarEventOriginalEndKey: _storedEventEnd(existing), + }, + }; final now = _now().millisecondsSinceEpoch; await _database.transaction(() async { - await (_database.update( - _database.calendarEvents, - )..where((row) => row.id.equals(eventId))).write( - CalendarEventsCompanion( - isDeleted: const Value(true), - syncStatus: const Value('pending'), - updatedAtLocal: Value(now), - ), - ); + if (seriesMutation) { + await _markCloudSeriesDeleted( + existing: existing, + scope: recurringScope!, + now: now, + ); + } else { + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).write( + CalendarEventsCompanion( + isDeleted: const Value(true), + syncStatus: const Value('pending'), + updatedAtLocal: Value(now), + ), + ); + } await _database .into(_database.pendingOps) .insert( @@ -887,11 +2022,13 @@ class CalendarRepository { calendarSourceId: Value(existing.calendarSourceId), providerCalendarId: Value(existing.providerCalendarId), eventId: Value(existing.id), - requestJson: jsonEncode({ - calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, - }), - baselineUpdatedUtc: Value(existing.updatedAtServer), - baselineRawJson: Value(existing.baselineRawJson), + requestJson: jsonEncode(request), + baselineUpdatedUtc: seriesMutation + ? const Value(null) + : Value(existing.updatedAtServer), + baselineRawJson: seriesMutation + ? const Value(null) + : Value(existing.baselineRawJson), createdAtUtc: DateTime.now().toUtc().toIso8601String(), updatedAtUtc: DateTime.now().toUtc().toIso8601String(), ), @@ -993,7 +2130,7 @@ class CalendarRepository { return existing.accountId; } - Future _createLocalDavEvent( + Future _createLocalDavEvent( CalendarSource source, EventEditorDraft draft, ) async { @@ -1038,6 +2175,7 @@ class CalendarRepository { 'uid': object.uid, 'localPendingCreate': true, }); + late final String operationId; await _database.transaction(() async { await _database .into(_database.calendarEvents) @@ -1081,20 +2219,22 @@ class CalendarRepository { syncStatus: const Value('pending'), ), ); - await DavPendingOperationQueue( - database: _database, - nowUtc: () => _now().toUtc(), - ).enqueueCreate( - accountId: draft.accountId, - collectionId: collectionId, - object: object, - localProjectionId: projectionId, - ); + operationId = + await DavPendingOperationQueue( + database: _database, + nowUtc: () => _now().toUtc(), + ).enqueueCreate( + accountId: draft.accountId, + collectionId: collectionId, + object: object, + localProjectionId: projectionId, + ); }); await _notificationScheduleService().rebuildUpcomingEventNotifications( draft.accountId, ); await _onNotificationScheduleChanged?.call(); + return operationId; } Future _updateLocalDavEvent( @@ -1112,9 +2252,7 @@ class CalendarRepository { } final recurring = existing.providerRecurringEventId != null; final recurringScope = draft.recurringMutationScope; - if (recurring && - (recurringScope == null || - recurringScope == RecurringEventMutationScope.thisAndFuture)) { + if (recurring && recurringScope == null) { throw UnsupportedError( 'A supported recurring-event editing scope is required.', ); @@ -1174,12 +2312,13 @@ class CalendarRepository { baselineRawIcs: baselineRawIcs, input: input, ); - } else if (recurringScope == RecurringEventMutationScope.singleOccurrence && - existing.recurrenceIdKey == null) { + } else if (recurringScope == RecurringEventMutationScope.thisAndFuture || + (recurringScope == RecurringEventMutationScope.singleOccurrence && + existing.recurrenceIdKey == null)) { final occurrenceKey = existing.occurrenceKey; if (occurrenceKey == null || objectId == null) { throw UnsupportedError( - 'One-occurrence editing requires a synchronized occurrence.', + 'Occurrence editing requires a synchronized occurrence.', ); } patch = buildDavEventOccurrenceExceptionPatch( @@ -1187,6 +2326,8 @@ class CalendarRepository { occurrenceKey: occurrenceKey, baselineRawIcs: baselineRawIcs, input: input, + thisAndFuture: + recurringScope == RecurringEventMutationScope.thisAndFuture, nowUtc: () => _now().toUtc(), ); } else { @@ -1257,9 +2398,7 @@ class CalendarRepository { throw StateError('The DAV event projection is incomplete.'); } final recurring = existing.providerRecurringEventId != null; - if (recurring && - (recurringScope == null || - recurringScope == RecurringEventMutationScope.thisAndFuture)) { + if (recurring && recurringScope == null) { throw UnsupportedError( 'A supported recurring-event deletion scope is required.', ); @@ -1271,9 +2410,10 @@ class CalendarRepository { final objectId = existing.davObjectId; await _database.transaction(() async { if (objectId == null) { - if (recurringScope == RecurringEventMutationScope.singleOccurrence) { + if (recurringScope == RecurringEventMutationScope.singleOccurrence || + recurringScope == RecurringEventMutationScope.thisAndFuture) { throw UnsupportedError( - 'One-occurrence deletion requires a synchronized occurrence.', + 'Occurrence deletion requires a synchronized occurrence.', ); } final cancelled = await queue.cancelUnsentCreate( @@ -1300,7 +2440,8 @@ class CalendarRepository { throw StateError('The DAV event baseline is unavailable.'); } final provider = BusyProviderCodec.requireStorageValue(source.provider); - if (recurringScope == RecurringEventMutationScope.singleOccurrence) { + if (recurringScope == RecurringEventMutationScope.singleOccurrence || + recurringScope == RecurringEventMutationScope.thisAndFuture) { final occurrenceKey = existing.occurrenceKey; if (occurrenceKey == null) { throw StateError('The DAV occurrence identity is unavailable.'); @@ -1314,6 +2455,8 @@ class CalendarRepository { uid: uid, occurrenceKey: occurrenceKey, baselineRawIcs: editableRawIcs, + thisAndFuture: + recurringScope == RecurringEventMutationScope.thisAndFuture, nowUtc: () => _now().toUtc(), ); final candidate = patch.applyTo(editableRawIcs, nowUtc: _now().toUtc()); @@ -1479,6 +2622,68 @@ class CalendarRepository { ); } + Future _pendingCalendarCreate(String sourceId) { + return (_database.select(_database.pendingOps) + ..where( + (row) => + row.calendarSourceId.equals(sourceId) & + row.operationType.equals('calendar.create') & + row.state.equals('pending'), + ) + ..limit(1)) + .getSingleOrNull(); + } + + Future _enqueueOrMergeCalendarPatch( + CalendarSource source, { + required Map request, + required String nowUtc, + String? dependsOnOpId, + }) async { + final existing = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.calendarSourceId.equals(source.id) & + row.operationType.equals('calendar.patch') & + row.state.equals('pending') & + row.attemptCount.equals(0), + ) + ..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)]) + ..limit(1)) + .getSingleOrNull(); + if (existing != null) { + final merged = _calendarPendingRequest(existing)..addAll(request); + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(existing.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(merged)), + dependsOnOpId: Value(existing.dependsOnOpId ?? dependsOnOpId), + updatedAtUtc: Value(nowUtc), + ), + ); + return; + } + await _database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: const Uuid().v4(), + accountId: source.accountId, + provider: Value(source.provider), + entityType: 'calendar', + operation: 'patch', + operationType: const Value('calendar.patch'), + calendarSourceId: Value(source.id), + providerCalendarId: Value(source.providerCalendarId), + dependsOnOpId: Value(dependsOnOpId), + requestJson: jsonEncode(request), + baselineRawJson: Value(source.rawJson), + createdAtUtc: nowUtc, + updatedAtUtc: nowUtc, + ), + ); + } + NotificationScheduleService _notificationScheduleService() { return NotificationScheduleService( database: _database, @@ -1632,6 +2837,40 @@ void _requireWritableSource( throw CalendarMutationNotAllowed(operation: operation, sourceId: source.id); } +void _requireFullEventEditingAllowed(CalendarEvent event) { + final provider = BusyProviderCodec.requireStorageValue(event.provider); + if (provider != BusyProvider.google) return; + final raw = _jsonMap(event.rawJson); + final organizer = _jsonMap(event.organizerJson); + final allowed = + raw['locked'] != true && + (organizer['self'] == true || raw['guestsCanModify'] == true); + if (allowed) return; + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: event.calendarSourceId, + ); +} + +void _requireCalendarSourceCapability( + CalendarSource source, { + required CalendarMutationOperation operation, + required bool allowed, +}) { + if (allowed) { + return; + } + throw CalendarMutationNotAllowed(operation: operation, sourceId: source.id); +} + +Map _calendarPendingRequest(PendingOp op) { + final decoded = jsonDecode(op.requestJson); + if (decoded is! Map) { + return {}; + } + return decoded.cast(); +} + void _requireDavSchedulingUnchanged( EventEditorDraft draft, { required bool creating, @@ -1668,6 +2907,7 @@ DavEventMutationInput _davEventInput( recurrence: draft.recurrence, recurrenceChanged: draft.recurrenceChanged, reminders: draft.reminders, + remindersChanged: draft.remindersChanged, categories: draft.categories, categoriesChanged: draft.categoriesChanged, classification: draft.visibilityOrSensitivity, @@ -1798,6 +3038,208 @@ String _pendingCreateRawIcs(PendingOp operation) { String? _json(Object? value) => value == null ? null : jsonEncode(value); +CalendarEventDetail _eventEditBaseline( + EventEditorDraft draft, + CalendarEvent existing, +) { + final original = draft.originalDetail; + if (original != null && original.id == existing.id) { + return original; + } + return CalendarEventDetail.fromRow(existing); +} + +String? _eventDetailBaselineRawJson(CalendarEventDetail detail) { + final baseline = detail.baselineRaw ?? detail.raw; + return baseline == null ? null : jsonEncode(baseline); +} + +DateTime? _eventDetailStartDateTime(CalendarEventDetail detail) { + return providerDateTimeAsWallTime( + detail.allDay ? detail.startDate : detail.startDateTime, + detail.startTimeZone, + ); +} + +DateTime? _eventDetailEndDateTime(CalendarEventDetail detail) { + return providerDateTimeAsWallTime( + detail.allDay ? detail.endDate : detail.endDateTime, + detail.endTimeZone, + ); +} + +CalendarEventsCompanion _eventPatchProjection({ + required EventEditorDraft draft, + required CalendarEvent existing, + required BusyProvider provider, + required Map request, + required String? startTimeZone, + required String? endTimeZone, + required Object? conference, + required int now, +}) { + final rangeChanged = request.containsKey('allDay'); + final rawChanged = switch (provider) { + BusyProvider.google => request.containsKey('hideAttendees'), + BusyProvider.microsoft => + request.containsKey('importance') || + request.containsKey('responseRequested') || + request.containsKey('hideAttendees') || + request.containsKey('allowNewTimeProposals'), + BusyProvider.appleICloud || BusyProvider.nextcloud => false, + }; + return CalendarEventsCompanion( + title: request.containsKey('title') + ? Value(draft.title.trim()) + : const Value.absent(), + description: request.containsKey('description') + ? Value(draft.description) + : const Value.absent(), + location: request.containsKey('location') + ? Value(draft.location) + : const Value.absent(), + allDay: rangeChanged ? Value(draft.allDay) : const Value.absent(), + startDate: rangeChanged + ? Value(draft.allDay ? _date(draft.start) : null) + : const Value.absent(), + startDateTime: rangeChanged + ? Value(draft.allDay ? null : draft.start?.toIso8601String()) + : const Value.absent(), + startTimeZone: rangeChanged ? Value(startTimeZone) : const Value.absent(), + endDate: rangeChanged + ? Value(draft.allDay ? _date(draft.end) : null) + : const Value.absent(), + endDateTime: rangeChanged + ? Value(draft.allDay ? null : draft.end?.toIso8601String()) + : const Value.absent(), + endTimeZone: rangeChanged ? Value(endTimeZone) : const Value.absent(), + recurrenceJson: request.containsKey(calendarEventRecurrenceField) + ? Value(_json(draft.recurrence)) + : const Value.absent(), + remindersJson: request.containsKey('remindersJson') + ? Value(_json(draft.reminders)) + : const Value.absent(), + attendeesJson: request.containsKey(calendarEventAttendeesField) + ? Value(_json(_localAttendeesJson(draft, provider))) + : const Value.absent(), + categoriesJson: request.containsKey('categoriesJson') + ? Value(_json(_categoriesJson(draft, provider))) + : const Value.absent(), + colorId: request.containsKey('colorId') + ? Value(draft.colorId) + : const Value.absent(), + visibility: + request.containsKey('visibility') || request.containsKey('sensitivity') + ? Value(draft.visibilityOrSensitivity) + : const Value.absent(), + transparencyOrShowAs: request.containsKey('transparencyOrShowAs') + ? Value(draft.showAs) + : const Value.absent(), + conferenceJson: request.containsKey('conferenceJson') + ? Value(_json(conference)) + : const Value.absent(), + rawJson: rawChanged + ? Value( + jsonEncode( + _optimisticEventRawForPatch( + draft, + provider, + request: request, + existingJson: existing.rawJson, + ), + ), + ) + : const Value.absent(), + updatedAtLocal: Value(now), + syncStatus: const Value('pending'), + ); +} + +Map _eventDeltaRequest( + EventEditorDraft draft, + CalendarEventDetail original, + BusyProvider provider, { + required String? startTimeZone, + required String? endTimeZone, + required Object? conference, + required CalendarGuestUpdatePolicy guestUpdatePolicy, +}) { + final full = _eventRequest( + draft, + provider, + isCreate: false, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conference, + guestUpdatePolicy: guestUpdatePolicy, + ); + final result = { + calendarEventGuestUpdatePolicyKey: guestUpdatePolicy.name, + }; + void copy(String key) { + if (full.containsKey(key)) result[key] = full[key]; + } + + if (draft.title.trim() != original.title) copy('title'); + if ((draft.description ?? '') != (original.description ?? '')) { + result['description'] = draft.description ?? ''; + copy('descriptionContentType'); + copy('descriptionHtml'); + } + if ((draft.location ?? '') != (original.location ?? '')) { + result['location'] = draft.location ?? ''; + } + final rangeChanged = + draft.allDay != original.allDay || + draft.start != _eventDetailStartDateTime(original) || + draft.end != _eventDetailEndDateTime(original) || + draft.startTimeZone != original.startTimeZone || + draft.endTimeZone != original.endTimeZone; + if (rangeChanged) { + copy('allDay'); + copy('start'); + copy('end'); + copy('startTimeZone'); + copy('endTimeZone'); + } + if (draft.recurrenceChanged) copy(calendarEventRecurrenceField); + if (!_sameJsonValue(original.reminders, draft.reminders)) { + copy('remindersJson'); + } + if (draft.attendeesChanged) copy(calendarEventAttendeesField); + if (draft.colorId != original.colorId) copy('colorId'); + if (draft.categoriesChanged) copy('categoriesJson'); + if (draft.visibilityOrSensitivity != original.visibility) { + copy(provider == BusyProvider.google ? 'visibility' : 'sensitivity'); + } + if (draft.showAs != original.transparencyOrShowAs) { + copy('transparencyOrShowAs'); + } + final raw = _jsonObjectMap(original.raw); + if (draft.importance != raw['importance']?.toString()) copy('importance'); + if (conference != null) copy('conferenceJson'); + if (draft.responseRequested != raw['responseRequested']) { + copy('responseRequested'); + } + final existingHidden = provider == BusyProvider.google + ? raw['guestsCanSeeOtherGuests'] is bool + ? !(raw['guestsCanSeeOtherGuests'] as bool) + : null + : raw['hideAttendees']; + if (draft.hideAttendees != existingHidden) copy('hideAttendees'); + if (draft.allowNewTimeProposals != raw['allowNewTimeProposals']) { + copy('allowNewTimeProposals'); + } + final clearFields = { + for (final value in full[calendarEventClearFieldsKey] as List? ?? const []) + value.toString(), + }..removeWhere((field) => !result.containsKey(field)); + if (clearFields.isNotEmpty) { + result[calendarEventClearFieldsKey] = clearFields.toList(); + } + return result; +} + Map _eventRequest( EventEditorDraft draft, BusyProvider provider, { @@ -1889,12 +3331,9 @@ String? _effectiveEndTimeZone( return null; } final explicit = _nonBlank(draft.endTimeZone); - if (explicit != null && !_isUtcTimeZone(explicit)) { - return explicit; - } - return _nonBlank(startTimeZone) ?? + return explicit ?? + _nonBlank(startTimeZone) ?? _nonBlank(localTimeZone) ?? - explicit ?? _nonBlank(sourceTimeZone) ?? 'UTC'; } @@ -1905,23 +3344,12 @@ String _effectiveTimedEventZone({ required String? localTimeZone, }) { final explicit = _nonBlank(explicitTimeZone); - if (explicit != null && !_isUtcTimeZone(explicit)) { - return explicit; - } - return _nonBlank(localTimeZone) ?? - explicit ?? + return explicit ?? + _nonBlank(localTimeZone) ?? _nonBlank(sourceTimeZone) ?? 'UTC'; } -bool _isUtcTimeZone(String value) { - final normalized = value.trim().toLowerCase(); - return normalized == 'utc' || - normalized == 'etc/utc' || - normalized == 'gmt' || - normalized == 'etc/gmt'; -} - String? _nonBlank(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; @@ -2005,6 +3433,69 @@ Map _optimisticEventRaw( return raw; } +Map _optimisticEventRawForPatch( + EventEditorDraft draft, + BusyProvider provider, { + required Map request, + String? existingJson, +}) { + final raw = {..._jsonMap(existingJson)}; + switch (provider) { + case BusyProvider.google: + if (request.containsKey('hideAttendees')) { + final hidden = draft.hideAttendees; + if (hidden == null) { + raw.remove('guestsCanSeeOtherGuests'); + } else { + raw['guestsCanSeeOtherGuests'] = !hidden; + } + } + case BusyProvider.microsoft: + _setOrRemoveRawField( + raw, + request: request, + key: 'importance', + value: draft.importance, + ); + _setOrRemoveRawField( + raw, + request: request, + key: 'responseRequested', + value: draft.responseRequested, + ); + _setOrRemoveRawField( + raw, + request: request, + key: 'hideAttendees', + value: draft.hideAttendees, + ); + _setOrRemoveRawField( + raw, + request: request, + key: 'allowNewTimeProposals', + value: draft.allowNewTimeProposals, + ); + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + break; + } + return raw; +} + +void _setOrRemoveRawField( + Map raw, { + required Map request, + required String key, + required Object? value, +}) { + if (!request.containsKey(key)) return; + if (value == null) { + raw.remove(key); + } else { + raw[key] = value; + } +} + List> _jsonMapList(String? value) { if (value == null || value.isEmpty) return const []; try { @@ -2029,6 +3520,10 @@ Map _jsonMap(String? value) { } } +Map _jsonObjectMap(Object? value) { + return value is Map ? Map.from(value) : const {}; +} + List _conferenceSolutions(String? rawJson) { final raw = _jsonMap(rawJson); final allowed = raw['allowedOnlineMeetingProviders']; @@ -2101,6 +3596,164 @@ Object? _categoriesJson(EventEditorDraft draft, BusyProvider provider) { return draft.categories; } +String? _storedEventStart(CalendarEvent event) => + event.allDay ? event.startDate : event.startDateTime; + +String? _storedEventEnd(CalendarEvent event) => + event.allDay ? event.endDate : event.endDateTime; + +DateTime? _storedEventStartDateTime(CalendarEvent event) => + providerDateTimeAsWallTime(_storedEventStart(event), event.startTimeZone); + +DateTime? _storedEventEndDateTime(CalendarEvent event) => + providerDateTimeAsWallTime(_storedEventEnd(event), event.endTimeZone); + +bool _seriesRowInScope( + CalendarEvent row, + CalendarEvent target, + RecurringEventMutationScope scope, +) { + if (scope == RecurringEventMutationScope.entireSeries) return true; + if (scope != RecurringEventMutationScope.thisAndFuture) { + return row.id == target.id; + } + final targetStart = _seriesOriginalStart(target); + final rowStart = _seriesOriginalStart(row); + if (targetStart == null || rowStart == null) return row.id == target.id; + return !rowStart.isBefore(targetStart); +} + +bool _googleEventCanSplit(CalendarEvent event) { + final eventType = event.eventType; + if (eventType != null && eventType.isNotEmpty && eventType != 'default') { + return false; + } + final conference = _decodeStoredJson(event.conferenceJson); + if (conference == null) return true; + if (conference is! Map || conference.isEmpty) return conference is Map; + final solution = conference['conferenceSolution']; + final key = solution is Map ? solution['key'] : null; + return key is Map && key['type']?.toString() == 'hangoutsMeet'; +} + +DateTime? _seriesOriginalStart(CalendarEvent event) { + return DateTime.tryParse(event.providerOriginalStartKey ?? '') ?? + _storedEventStartDateTime(event); +} + +Object? _decodeStoredJson(String? value) { + if (value == null || value.isEmpty) return null; + try { + return jsonDecode(value); + } on FormatException { + return null; + } +} + +bool _sameJsonValue(Object? left, Object? right) { + return jsonEncode(_normalizedJson(left)) == + jsonEncode(_normalizedJson(right)); +} + +bool _eventRequestHasMutation(Map request) { + const metadata = { + calendarEventGuestUpdatePolicyKey, + calendarEventRecurringScopeKey, + calendarEventTargetProviderIdKey, + calendarEventOriginalStartKey, + calendarEventOriginalEndKey, + calendarEventDestinationCalendarIdKey, + calendarEventDestinationSourceIdKey, + }; + return request.entries.any( + (entry) => + !metadata.contains(entry.key) && + (entry.value != null || entry.key == calendarEventClearFieldsKey), + ); +} + +Object _eventRemindersForProvider(Object? reminders, BusyProvider provider) { + final minutes = []; + if (reminders is Map) { + final map = reminders.cast(); + final single = map['reminderMinutesBeforeStart']; + if (single is int && single > 0) minutes.add(single); + final overrides = map['overrides']; + if (overrides is List) { + for (final override in overrides) { + if (override is! Map) continue; + final value = override['minutes']; + if (value is int && value > 0 && !minutes.contains(value)) { + minutes.add(value); + } + } + } + final davMinutes = map['minutes']; + if (davMinutes is List) { + for (final value in davMinutes.whereType()) { + if (value > 0 && !minutes.contains(value)) minutes.add(value); + } + } + } + if (provider == BusyProvider.microsoft) { + return minutes.isEmpty + ? const {'isReminderOn': false} + : {'isReminderOn': true, 'reminderMinutesBeforeStart': minutes.first}; + } + return { + 'useDefault': false, + 'overrides': [ + for (final value in minutes) {'method': 'popup', 'minutes': value}, + ], + }; +} + +String _eventShowAsForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'free' || 'tentative' || 'busy' || 'oof' || 'workingElsewhere' => value!, + 'transparent' => 'free', + _ => 'busy', + }; + } + return switch (value) { + 'opaque' || 'transparent' => value!, + 'free' => 'transparent', + _ => 'opaque', + }; +} + +String _eventVisibilityForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'normal' || 'personal' || 'private' || 'confidential' => value!, + _ => 'normal', + }; + } + return switch (value) { + 'default' || 'public' || 'private' || 'confidential' => value!, + 'personal' => 'private', + _ => 'default', + }; +} + +Object? _normalizedJson(Object? value) { + if (value is Map) { + final entries = value.entries.toList() + ..sort( + (left, right) => left.key.toString().compareTo(right.key.toString()), + ); + return { + for (final entry in entries) + entry.key.toString(): _normalizedJson(entry.value), + }; + } + if (value is List) { + return [for (final item in value) _normalizedJson(item)]; + } + return value; +} + String? _date(DateTime? value) { if (value == null) { return null; diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 4c3eff56..72ec1d9d 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -13,8 +13,12 @@ import '../../../platform/linux_header_bar_service.dart'; import '../../../schedule/schedule_projection.dart'; 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/presentation/recurrence_editor.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; import '../data/calendar_repository.dart'; +import '../domain/event_move_policy.dart'; import 'event_description_editor.dart'; import 'event_editor_draft.dart'; import 'event_guest_delivery_dialog.dart'; @@ -47,6 +51,7 @@ Future showBusyMaxEventEditorDialog( draft: draft, initialDraft: initialDraft, sources: sources, + accounts: accounts, headerBarService: headerBarService, ), ), @@ -110,10 +115,30 @@ Future _completeEventEditorSave( required EventEditorDraft draft, required EventEditorDraft initialDraft, required List sources, + required List accounts, LinuxHeaderBarService? headerBarService, }) async { + final move = _eventMoveContext( + initialDraft: initialDraft, + draft: draft, + sources: sources, + ); + if (move != null && + move.strategy == CalendarEventMoveStrategy.copyThenDelete) { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.copyEventAndDeleteOriginal, + message: context.l10n.copyEventMoveWarning( + _calendarMoveLabel(move.source, accounts), + _calendarMoveLabel(move.destination, accounts), + ), + confirmLabel: context.l10n.copyAndDelete, + headerBarService: headerBarService, + ); + if (!confirmed || !context.mounted) return; + } var guestUpdatePolicy = CalendarGuestUpdatePolicy.send; - final provider = _providerForDraft(draft, sources); + final provider = _guestDeliveryProvider(move, draft, sources); if (provider != null && draft.isOrganizer == true && _hasExternalGuests(draft.attendees, initialDraft.attendees)) { @@ -132,6 +157,82 @@ Future _completeEventEditorSave( ); } +BusyProvider? _guestDeliveryProvider( + _EventMoveContext? move, + EventEditorDraft draft, + List sources, +) { + final destination = _providerForDraft(draft, sources); + if (destination == BusyProvider.google || + destination == BusyProvider.microsoft) { + return destination; + } + final source = move?.source.provider; + return source == BusyProvider.google || source == BusyProvider.microsoft + ? source + : null; +} + +_EventMoveContext? _eventMoveContext({ + required EventEditorDraft initialDraft, + required EventEditorDraft draft, + required List sources, +}) { + if (initialDraft.eventId == null) return null; + CalendarSourceEntity? source; + CalendarSourceEntity? destination; + for (final candidate in sources) { + if (candidate.id == initialDraft.sourceId) source = candidate; + if (candidate.id == draft.sourceId) destination = candidate; + } + if (source == null || destination == null) return null; + final strategy = calendarEventMoveStrategy( + sourceAccountId: source.accountId, + sourceId: source.id, + sourceProviderCalendarId: source.providerCalendarId, + sourceProvider: source.provider, + sourceDavCollectionId: source.davCollectionId, + destinationAccountId: destination.accountId, + destinationId: destination.id, + destinationProviderCalendarId: destination.providerCalendarId, + destinationProvider: destination.provider, + destinationDavCollectionId: destination.davCollectionId, + eventType: initialDraft.eventType, + recurring: initialDraft.providerRecurringEventId != null, + recurringScope: draft.recurringMutationScope, + ); + if (strategy == CalendarEventMoveStrategy.none) return null; + return _EventMoveContext( + source: source, + destination: destination, + strategy: strategy, + ); +} + +String _calendarMoveLabel( + CalendarSourceEntity source, + List accounts, +) { + for (final account in accounts) { + if (account.id == source.accountId) { + return '${account.selectorLabel} · ${source.summary}'; + } + } + return '${source.provider.displayName} · ${source.summary}'; +} + +class _EventMoveContext { + const _EventMoveContext({ + required this.source, + required this.destination, + required this.strategy, + }); + + final CalendarSourceEntity source; + final CalendarSourceEntity destination; + final CalendarEventMoveStrategy strategy; +} + Future _completeEventEditorDelete( BuildContext context, { required String eventId, @@ -277,14 +378,15 @@ class _EventEditorState extends State { final schedulingReadOnly = provider == BusyProvider.appleICloud || provider == BusyProvider.nextcloud; - final davRecurring = - schedulingReadOnly && _draft.providerRecurringEventId != null; + final recurringOccurrence = _draft.providerRecurringEventId != null; final timeFieldsValid = _draft.allDay || (_startTimeValid && _endTimeValid); final recurringScopeValid = - !davRecurring || + !recurringOccurrence || (_draft.recurringMutationScope != null && - _draft.recurringMutationScope != - RecurringEventMutationScope.thisAndFuture); + _supportsRecurringScope( + currentSource, + _draft.recurringMutationScope!, + )); final canSave = dirty && _draft.canSave && timeFieldsValid && recurringScopeValid; return CallbackShortcuts( @@ -352,7 +454,7 @@ class _EventEditorState extends State { children: [ BusyMaxTimeModeRow( allDay: _draft.allDay, - onChanged: _setAllDay, + onChanged: (value) => _setAllDay(value, provider), ), ], ), @@ -376,9 +478,7 @@ class _EventEditorState extends State { }, timeZone: _draft.startTimeZone, onTimeZoneChanged: (value) { - setState(() { - _draft = _draft.copyWith(startTimeZone: value); - }); + _setStartTimeZone(value, provider); }, allowEmpty: false, onValidityChanged: (valid) { @@ -422,11 +522,11 @@ class _EventEditorState extends State { ), ], ), - if (davRecurring) + if (recurringOccurrence) BusyMaxGroupedList( title: l10n.recurringEventScope, filled: true, - children: _recurringScopeRows(), + children: _recurringScopeRows(provider), ), if (_draft.providerRecurringEventId == null) BusyMaxGroupedList( @@ -510,7 +610,9 @@ class _EventEditorState extends State { ), ), destructive: true, - onTap: davRecurring && _draft.recurringMutationScope == null + onTap: + recurringOccurrence && + _draft.recurringMutationScope == null ? null : _deleteCurrentEvent, ), @@ -588,99 +690,165 @@ class _EventEditorState extends State { } bool get _requiresRecurringScope { - if (_draft.providerRecurringEventId == null) return false; - for (final source in widget.sources) { - if (source.id != _draft.sourceId) continue; - return source.provider == BusyProvider.appleICloud || - source.provider == BusyProvider.nextcloud; - } - return false; + return _draft.providerRecurringEventId != null; } - List _recurringScopeRows() { + List _recurringScopeRows(BusyProvider provider) { final l10n = context.l10n; + final source = _sourceForId(_draft.sourceId); + final supportsFollowing = + source != null && + _supportsRecurringScope( + source, + RecurringEventMutationScope.thisAndFuture, + ); + final supportsEntire = + source != null && + _supportsRecurringScope( + source, + RecurringEventMutationScope.entireSeries, + ); + final moving = _isMovingTo(source); return [ BusyMaxActionRow( - title: l10n.entireSeries, + title: l10n.singleOccurrence, subtitle: l10n.chooseRecurringEventScope, leading: Icon( _draft.recurringMutationScope == - RecurringEventMutationScope.entireSeries + RecurringEventMutationScope.singleOccurrence ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), onTap: () => setState(() { _draft = _draft.copyWith( - recurringMutationScope: RecurringEventMutationScope.entireSeries, + recurringMutationScope: + RecurringEventMutationScope.singleOccurrence, ); }), ), BusyMaxActionRow( - title: l10n.singleOccurrence, + title: l10n.thisAndFollowingEvents, + subtitle: supportsFollowing + ? null + : moving + ? l10n.thisAndFutureMoveUnavailable + : l10n.thisAndFutureUnavailable, leading: Icon( _draft.recurringMutationScope == - RecurringEventMutationScope.singleOccurrence + RecurringEventMutationScope.thisAndFuture ? Icons.radio_button_checked - : Icons.radio_button_unchecked, + : supportsFollowing + ? Icons.radio_button_unchecked + : Icons.update_disabled_outlined, ), - onTap: () => setState(() { - _draft = _draft.copyWith( - recurringMutationScope: - RecurringEventMutationScope.singleOccurrence, - ); - }), + enabled: supportsFollowing, + onTap: supportsFollowing + ? () => setState(() { + _draft = _draft.copyWith( + recurringMutationScope: + RecurringEventMutationScope.thisAndFuture, + ); + }) + : null, ), BusyMaxActionRow( - title: l10n.thisAndFutureUnavailable, - leading: const Icon(Icons.update_disabled_outlined), - enabled: false, + title: l10n.entireSeries, + subtitle: supportsEntire ? null : l10n.entireSeriesMoveUnavailable, + leading: Icon( + _draft.recurringMutationScope == + RecurringEventMutationScope.entireSeries + ? Icons.radio_button_checked + : supportsEntire + ? Icons.radio_button_unchecked + : Icons.update_disabled_outlined, + ), + enabled: supportsEntire, + onTap: supportsEntire + ? () => setState(() { + _draft = _draft.copyWith( + recurringMutationScope: + RecurringEventMutationScope.entireSeries, + ); + }) + : null, ), ]; } Widget _repeatRow(BusyProvider provider) { final l10n = context.l10n; - final labels = { - 'none': l10n.doesNotRepeat, - 'daily': l10n.repeatDaily, - 'weekly': l10n.repeatWeekly, - 'monthly': l10n.repeatMonthly, - 'yearly': l10n.repeatYearly, - }; - return BusyMaxComboRow( + final recurrence = EventRecurrenceCodec.decode( + provider, + _draft.recurrence, + baseDate: _draft.start, + ); + if (!recurrence.isSupported) { + return BusyMaxActionRow( + title: l10n.repeat, + subtitle: l10n.unsupportedRecurrencePreserved, + leading: const Icon(Icons.repeat), + enabled: false, + ); + } + return BusyMaxActionRow( title: l10n.repeat, leading: const Icon(Icons.repeat), - values: labels.keys.toList(), - selected: _recurrenceType(_draft.recurrence), - labelFor: (value) => labels[value] ?? l10n.doesNotRepeat, - onSelected: (value) { - setState(() { - _draft = value == 'none' - ? _draft.copyWith(clearRecurrence: true) - : _draft.copyWith( - recurrence: _recurrenceFor(provider, value, _draft.start), - ); - }); - }, + subtitle: recurrenceRuleSummary( + context, + recurrence, + timeZone: _draft.startTimeZone, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => unawaited(_editRecurrence(provider, recurrence)), ); } + Future _editRecurrence( + BusyProvider provider, + RecurrenceRule initial, + ) async { + final baseDate = _draft.start ?? DateTime.now(); + final result = await showRecurrenceEditorDialog( + context, + initial: initial, + allDay: _draft.allDay, + floating: _isFloatingDavTime( + provider, + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + ), + baseDate: baseDate, + minimumDate: baseDate, + timeZone: _draft.startTimeZone, + limits: EventRecurrenceCodec.limitsFor(provider), + useNativeDatePicker: false, + headerBarService: widget.headerBarService, + ); + if (result == null || !mounted) return; + if (!result.repeats) { + setState(() => _draft = _draft.copyWith(clearRecurrence: true)); + return; + } + final encoded = EventRecurrenceCodec.encode( + provider, + result, + baseDate: baseDate, + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + original: _draft.recurrence, + ); + setState(() => _draft = _draft.copyWith(recurrence: encoded)); + } + Widget _accountRow() { - final existingAccountId = widget.initialDraft.eventId == null - ? null - : widget.initialDraft.accountId; final accountIds = - { - for (final source in widget.sources) - if (existingAccountId == null || - source.accountId == existingAccountId) - source.accountId, - }.toList()..sort((first, second) { - final labelOrder = _accountLabel( - first, - ).toLowerCase().compareTo(_accountLabel(second).toLowerCase()); - return labelOrder != 0 ? labelOrder : first.compareTo(second); - }); + {for (final source in widget.sources) source.accountId}.toList() + ..sort((first, second) { + final labelOrder = _accountLabel( + first, + ).toLowerCase().compareTo(_accountLabel(second).toLowerCase()); + return labelOrder != 0 ? labelOrder : first.compareTo(second); + }); final selected = accountIds.contains(_draft.accountId) ? _draft.accountId : accountIds.first; @@ -689,21 +857,16 @@ class _EventEditorState extends State { leading: const Icon(YaruIcons.user), values: accountIds, selected: selected, - enabled: existingAccountId == null, + enabled: accountIds.length > 1, labelFor: _accountLabel, onSelected: _selectAccount, ); } Widget _calendarRow() { - final existingSourceId = widget.initialDraft.eventId == null - ? null - : widget.initialDraft.sourceId; final sources = [ for (final source in widget.sources) - if (source.accountId == _draft.accountId && - (existingSourceId == null || source.id == existingSourceId)) - source, + if (source.accountId == _draft.accountId) source, ]..sort(_compareCalendarSources); if (sources.isEmpty) { return BusyMaxActionRow( @@ -721,7 +884,7 @@ class _EventEditorState extends State { leading: const Icon(YaruIcons.calendar), values: [for (final source in sources) source.id], selected: selected, - enabled: existingSourceId == null, + enabled: sources.length > 1, labelFor: (value) => sourcesById[value]!.summary, selectorLeadingBuilder: (context, value) { final source = sourcesById[value]!; @@ -774,27 +937,139 @@ class _EventEditorState extends State { } void _selectCalendarSource(CalendarSourceEntity source) { - final recurrenceType = _recurrenceType(_draft.recurrence); - final adjustedRecurrence = - _draft.recurrenceChanged && recurrenceType != 'none' - ? _recurrenceFor(source.provider, recurrenceType, _draft.start) - : null; + final currentProvider = _providerForDraft(_draft, widget.sources); + Object? adjustedRecurrence; + var clearRecurrence = false; + if (_draft.recurrence != null && + currentProvider != null && + currentProvider != source.provider) { + final rule = EventRecurrenceCodec.decode( + currentProvider, + _draft.recurrence, + baseDate: _draft.start, + ); + final canConvert = + rule.isSupported && + (!rule.repeats || + EventRecurrenceCodec.canEncode(source.provider, rule)); + if (!canConvert) { + if (_draft.providerRecurringEventId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.recurrenceUnsupportedByProvider( + source.provider.displayName, + ), + ), + ), + ); + return; + } + } else if (!rule.repeats) { + clearRecurrence = true; + } else { + adjustedRecurrence = EventRecurrenceCodec.encode( + source.provider, + rule, + baseDate: _draft.start ?? DateTime.now(), + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + ); + } + } + final providerChanged = + currentProvider != null && currentProvider != source.provider; + final reminderMinutes = _reminderMinutesList(_draft.reminders); + final adjustedReminders = providerChanged + ? _remindersFor(source.provider, reminderMinutes) ?? + _disabledRemindersFor(source.provider) + : _draft.reminders; + final adjustedShowAs = providerChanged + ? _showAsForProvider(_draft.showAs, source.provider) + : _draft.showAs; + final adjustedVisibility = providerChanged + ? _visibilityForProvider( + _draft.visibilityOrSensitivity, + source.provider, + ) + : _draft.visibilityOrSensitivity; + var updated = _draft.copyWith( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + reminders: providerChanged ? adjustedReminders : null, + showAs: adjustedShowAs, + visibilityOrSensitivity: adjustedVisibility, + categories: source.provider != BusyProvider.google + ? _draft.categories + : const [], + recurrence: adjustedRecurrence, + clearRecurrence: clearRecurrence, + clearImportance: source.provider != BusyProvider.microsoft, + clearColorId: _draft.accountId != source.accountId || providerChanged, + ); + final scope = updated.recurringMutationScope; + if (scope != null && !_supportsRecurringScopeFor(updated, source, scope)) { + updated = updated.copyWith(clearRecurringMutationScope: true); + } setState(() { if (source.provider == BusyProvider.google) { _addingCategory = false; } - _draft = _draft.copyWith( - accountId: source.accountId, - sourceId: source.id, - providerCalendarId: source.providerCalendarId, - categories: source.provider != BusyProvider.google - ? _draft.categories - : const [], - recurrence: adjustedRecurrence, - ); + _draft = updated; }); } + CalendarSourceEntity? _sourceForId(String sourceId) { + for (final source in widget.sources) { + if (source.id == sourceId) return source; + } + return null; + } + + bool _isMovingTo(CalendarSourceEntity? destination) { + if (destination == null || widget.initialDraft.eventId == null) { + return false; + } + return destination.id != widget.initialDraft.sourceId || + destination.accountId != widget.initialDraft.accountId || + destination.providerCalendarId != + widget.initialDraft.providerCalendarId; + } + + bool _supportsRecurringScope( + CalendarSourceEntity destination, + RecurringEventMutationScope scope, + ) => _supportsRecurringScopeFor(_draft, destination, scope); + + bool _supportsRecurringScopeFor( + EventEditorDraft draft, + CalendarSourceEntity destination, + RecurringEventMutationScope scope, + ) { + if (!_isMovingTo(destination)) { + return scope != RecurringEventMutationScope.thisAndFuture || + supportsThisAndFollowingEventMutation(destination.provider); + } + if (scope == RecurringEventMutationScope.singleOccurrence) return true; + if (scope == RecurringEventMutationScope.thisAndFuture) return false; + final move = _eventMoveContext( + initialDraft: widget.initialDraft, + draft: draft.copyWith(recurringMutationScope: scope), + sources: widget.sources, + ); + if (move == null || + move.strategy != CalendarEventMoveStrategy.copyThenDelete) { + return true; + } + final rule = EventRecurrenceCodec.decode( + destination.provider, + draft.recurrence, + baseDate: draft.start, + ); + return rule.isSupported && rule.repeats; + } + List _reminderRows(BusyProvider provider) { final l10n = context.l10n; final colorScheme = Theme.of(context).colorScheme; @@ -1186,9 +1461,17 @@ class _EventEditorState extends State { }); } - void _setAllDay(bool allDay) { + void _setAllDay(bool allDay, BusyProvider provider) { final start = _draft.start; final end = _draft.end; + final adjustedRecurrence = start == null + ? null + : _recurrenceForScheduleChange( + provider, + baseDate: start, + allDay: allDay, + timeZone: _draft.startTimeZone, + ); setState(() { _startTimeValid = true; _endTimeValid = true; @@ -1197,6 +1480,7 @@ class _EventEditorState extends State { end: start != null && !_isValidEventEnd(start, end, allDay) ? _defaultEndFor(start, allDay) : end, + recurrence: adjustedRecurrence, ); }); } @@ -1204,18 +1488,21 @@ class _EventEditorState extends State { bool get _hasUnsavedChanges { final hasInvalidVisibleTime = !_draft.allDay && (!_startTimeValid || !_endTimeValid); - return _draft != widget.initialDraft || hasInvalidVisibleTime; + final contentDraft = _draft.copyWith(clearRecurringMutationScope: true); + final initialContent = widget.initialDraft.copyWith( + clearRecurringMutationScope: true, + ); + return contentDraft != initialContent || hasInvalidVisibleTime; } void _setStart(DateTime start, BusyProvider provider) { final end = _draft.end; - final recurrenceType = _recurrenceType(_draft.recurrence); - final adjustedRecurrence = - provider == BusyProvider.microsoft && - _draft.recurrenceChanged && - recurrenceType != 'none' - ? _recurrenceFor(provider, recurrenceType, start) - : null; + final adjustedRecurrence = _recurrenceForScheduleChange( + provider, + baseDate: start, + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + ); setState(() { _draft = _draft.copyWith( start: start, @@ -1227,6 +1514,64 @@ class _EventEditorState extends State { }); } + void _setStartTimeZone(String timeZone, BusyProvider provider) { + final start = _draft.start; + final adjustedRecurrence = start == null + ? null + : _recurrenceForScheduleChange( + provider, + baseDate: start, + allDay: _draft.allDay, + timeZone: timeZone, + ); + setState(() { + _draft = _draft.copyWith( + startTimeZone: timeZone, + recurrence: adjustedRecurrence, + ); + }); + } + + Object? _recurrenceForScheduleChange( + BusyProvider provider, { + required DateTime baseDate, + required bool allDay, + required String? timeZone, + }) { + if (_draft.providerRecurringEventId != null || _draft.recurrence == null) { + return null; + } + var rule = EventRecurrenceCodec.decode( + provider, + _draft.recurrence, + baseDate: _draft.start, + ); + if (!rule.isSupported || !rule.repeats) return null; + final untilDate = rule.untilDateFor(timeZone: _draft.startTimeZone); + if (untilDate != null) { + rule = rule.withUntilDate( + untilDate, + allDay: allDay, + floating: _isFloatingDavTime( + provider, + allDay: allDay, + timeZone: timeZone, + ), + baseDate: baseDate, + timeZone: timeZone, + ); + } + if (!EventRecurrenceCodec.canEncode(provider, rule)) return null; + return EventRecurrenceCodec.encode( + provider, + rule, + baseDate: baseDate, + allDay: allDay, + timeZone: timeZone, + original: _draft.recurrence, + ); + } + void _setEnd(DateTime end) { final start = _draft.start; setState(() { @@ -1327,88 +1672,6 @@ DateTime _calendarDate(DateTime value) { return DateTime.utc(value.year, value.month, value.day); } -String _recurrenceType(Object? recurrence) { - if (recurrence is List && recurrence.isNotEmpty) { - final value = recurrence.first.toString().toUpperCase(); - if (value.contains('FREQ=DAILY')) return 'daily'; - if (value.contains('FREQ=WEEKLY')) return 'weekly'; - if (value.contains('FREQ=MONTHLY')) return 'monthly'; - if (value.contains('FREQ=YEARLY')) return 'yearly'; - } - if (recurrence is Map) { - final rules = recurrence['rules']; - if (rules is List && rules.isNotEmpty) { - final value = rules.first.toString().toUpperCase(); - if (value.contains('FREQ=DAILY')) return 'daily'; - if (value.contains('FREQ=WEEKLY')) return 'weekly'; - if (value.contains('FREQ=MONTHLY')) return 'monthly'; - if (value.contains('FREQ=YEARLY')) return 'yearly'; - } - final pattern = recurrence['pattern']; - if (pattern is Map) { - final type = pattern['type']?.toString(); - return switch (type) { - 'daily' => 'daily', - 'weekly' => 'weekly', - 'absoluteMonthly' => 'monthly', - 'absoluteYearly' => 'yearly', - _ => 'none', - }; - } - } - return 'none'; -} - -Object _recurrenceFor(BusyProvider provider, String type, DateTime? start) { - final freq = switch (type) { - 'daily' => 'DAILY', - 'weekly' => 'WEEKLY', - 'monthly' => 'MONTHLY', - 'yearly' => 'YEARLY', - _ => 'DAILY', - }; - if (provider != BusyProvider.microsoft) { - return ['RRULE:FREQ=$freq;INTERVAL=1']; - } - final recurrenceStart = start ?? DateTime.now(); - final patternType = switch (type) { - 'daily' => 'daily', - 'weekly' => 'weekly', - 'monthly' => 'absoluteMonthly', - 'yearly' => 'absoluteYearly', - _ => 'daily', - }; - return { - 'pattern': { - 'type': patternType, - 'interval': 1, - if (type == 'weekly') ...{ - 'daysOfWeek': [_microsoftDayOfWeek(recurrenceStart.weekday)], - 'firstDayOfWeek': 'sunday', - }, - if (type == 'monthly') 'dayOfMonth': recurrenceStart.day, - if (type == 'yearly') ...{ - 'dayOfMonth': recurrenceStart.day, - 'month': recurrenceStart.month, - }, - }, - 'range': {'type': 'noEnd', 'startDate': encodeDateOnly(recurrenceStart)}, - }; -} - -String _microsoftDayOfWeek(int weekday) { - return switch (weekday) { - DateTime.monday => 'monday', - DateTime.tuesday => 'tuesday', - DateTime.wednesday => 'wednesday', - DateTime.thursday => 'thursday', - DateTime.friday => 'friday', - DateTime.saturday => 'saturday', - DateTime.sunday => 'sunday', - _ => throw ArgumentError.value(weekday, 'weekday'), - }; -} - const _eventReminderMinuteOptions = [5, 10, 30, 60, 1440]; List _reminderMinutesList(Object? reminders) { @@ -1430,6 +1693,10 @@ List _reminderMinutesList(Object? reminders) { } return _normalizedReminderMinutes(values); } + final davMinutes = map['minutes']; + if (davMinutes is List) { + return _normalizedReminderMinutes(davMinutes.whereType()); + } return const []; } @@ -1457,6 +1724,35 @@ Object _disabledRemindersFor(BusyProvider provider) { return {'isReminderOn': false}; } +String _showAsForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'free' || 'tentative' || 'busy' || 'oof' || 'workingElsewhere' => value!, + 'transparent' => 'free', + _ => 'busy', + }; + } + return switch (value) { + 'opaque' || 'transparent' => value!, + 'free' => 'transparent', + _ => 'opaque', + }; +} + +String _visibilityForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'normal' || 'personal' || 'private' || 'confidential' => value!, + _ => 'normal', + }; + } + return switch (value) { + 'default' || 'public' || 'private' || 'confidential' => value!, + 'personal' => 'private', + _ => 'default', + }; +} + List _normalizedReminderMinutes(Iterable minutes) { final result = []; for (final value in minutes) { @@ -1525,6 +1821,17 @@ bool _looksLikeEmail(String value) { return RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value); } +bool _isFloatingDavTime( + BusyProvider provider, { + required bool allDay, + required String? timeZone, +}) { + return !allDay && + (timeZone?.trim().isEmpty ?? true) && + (provider == BusyProvider.appleICloud || + provider == BusyProvider.nextcloud); +} + class _CalendarSourceDot extends StatelessWidget { const _CalendarSourceDot({required this.color}); diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart index 7f0d870e..4373a54b 100644 --- a/lib/src/features/calendar/presentation/event_editor_draft.dart +++ b/lib/src/features/calendar/presentation/event_editor_draft.dart @@ -1,3 +1,4 @@ +import '../../../core/time/provider_date_time.dart'; import '../../../providers/busy_provider.dart'; import '../data/calendar_event_detail.dart'; @@ -7,6 +8,9 @@ enum RecurringEventMutationScope { thisAndFuture, } +bool supportsThisAndFollowingEventMutation(BusyProvider provider) => + provider != BusyProvider.microsoft; + class EventAttendeeDraft { const EventAttendeeDraft({ required this.email, @@ -103,7 +107,9 @@ class EventEditorDraft { required this.title, required this.allDay, this.eventId, + this.originalDetail, this.providerRecurringEventId, + this.eventType, this.recurringMutationScope, this.start, this.end, @@ -116,6 +122,7 @@ class EventEditorDraft { this.recurrence, this.recurrenceChanged = false, this.reminders, + this.remindersChanged = false, this.attendees = const [], this.attendeesChanged = false, this.importance, @@ -177,21 +184,25 @@ class EventEditorDraft { return EventEditorDraft.existing( eventId: detail.id, + originalDetail: detail, accountId: detail.accountId, sourceId: detail.sourceId, providerCalendarId: detail.providerCalendarId, providerRecurringEventId: detail.providerRecurringEventId, + eventType: detail.eventType, title: detail.title, allDay: detail.allDay, start: _eventEditorDateTime( allDay: detail.allDay, date: detail.startDate, dateTime: detail.startDateTime, + timeZone: detail.startTimeZone, ), end: _eventEditorDateTime( allDay: detail.allDay, date: detail.endDate, dateTime: detail.endDateTime, + timeZone: detail.endTimeZone, ), startTimeZone: detail.startTimeZone, endTimeZone: detail.endTimeZone, @@ -220,6 +231,7 @@ class EventEditorDraft { factory EventEditorDraft.existing({ required String eventId, + CalendarEventDetail? originalDetail, required String accountId, required String sourceId, required String providerCalendarId, @@ -229,6 +241,7 @@ class EventEditorDraft { DateTime? end, String? location, String? providerRecurringEventId, + String? eventType, RecurringEventMutationScope? recurringMutationScope, String? description, String? descriptionContentType, @@ -252,10 +265,12 @@ class EventEditorDraft { }) { return EventEditorDraft( eventId: eventId, + originalDetail: originalDetail, accountId: accountId, sourceId: sourceId, providerCalendarId: providerCalendarId, providerRecurringEventId: providerRecurringEventId, + eventType: eventType, recurringMutationScope: recurringMutationScope, title: title, allDay: allDay, @@ -285,7 +300,9 @@ class EventEditorDraft { } final String? eventId; + final CalendarEventDetail? originalDetail; final String? providerRecurringEventId; + final String? eventType; final RecurringEventMutationScope? recurringMutationScope; final String accountId; final String sourceId; @@ -305,6 +322,9 @@ class EventEditorDraft { /// True only after the editor deliberately changes the hydrated value. final bool recurrenceChanged; final Object? reminders; + + /// True only after the editor deliberately changes the hydrated value. + final bool remindersChanged; final List attendees; /// True only after the editor deliberately changes the hydrated list. @@ -355,6 +375,7 @@ class EventEditorDraft { Object? recurrence, bool? recurrenceChanged, Object? reminders, + bool? remindersChanged, List? attendees, bool? attendeesChanged, String? importance, @@ -383,7 +404,9 @@ class EventEditorDraft { }) { return EventEditorDraft( eventId: eventId, + originalDetail: originalDetail, providerRecurringEventId: providerRecurringEventId, + eventType: eventType, recurringMutationScope: clearRecurringMutationScope ? null : recurringMutationScope ?? this.recurringMutationScope, @@ -409,6 +432,9 @@ class EventEditorDraft { recurrenceChanged ?? (this.recurrenceChanged || recurrence != null || clearRecurrence), reminders: clearReminders ? null : reminders ?? this.reminders, + remindersChanged: + remindersChanged ?? + (this.remindersChanged || reminders != null || clearReminders), attendees: attendees ?? this.attendees, attendeesChanged: attendeesChanged ?? (this.attendeesChanged || attendees != null), @@ -435,7 +461,9 @@ class EventEditorDraft { bool operator ==(Object other) { return other is EventEditorDraft && other.eventId == eventId && + other.originalDetail == originalDetail && other.providerRecurringEventId == providerRecurringEventId && + other.eventType == eventType && other.recurringMutationScope == recurringMutationScope && other.accountId == accountId && other.sourceId == sourceId && @@ -453,6 +481,7 @@ class EventEditorDraft { other.recurrence == recurrence && other.recurrenceChanged == recurrenceChanged && other.reminders == reminders && + other.remindersChanged == remindersChanged && _listEquals(other.attendees, attendees) && other.attendeesChanged == attendeesChanged && other.importance == importance && @@ -472,7 +501,9 @@ class EventEditorDraft { @override int get hashCode => Object.hashAll([ eventId, + originalDetail, providerRecurringEventId, + eventType, recurringMutationScope, accountId, sourceId, @@ -490,6 +521,7 @@ class EventEditorDraft { recurrence, recurrenceChanged, reminders, + remindersChanged, Object.hashAll(attendees), attendeesChanged, importance, @@ -526,25 +558,11 @@ DateTime? _eventEditorDateTime({ required bool allDay, required String? date, required String? dateTime, + required String? timeZone, }) { final value = allDay ? date ?? dateTime : dateTime; if (value == null || value.isEmpty) return null; - if (allDay) { - return DateTime.tryParse( - value.length >= 10 ? value.substring(0, 10) : value, - ); - } - final offsetWallTime = _parseOffsetWallDateTime(value); - if (offsetWallTime != null) return offsetWallTime; - final parsed = DateTime.tryParse(value); - if (parsed == null) return null; - return parsed.isUtc ? parsed.toLocal() : parsed; -} - -DateTime? _parseOffsetWallDateTime(String value) { - if (!RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value)) return null; - final wallTime = value.replaceFirst(RegExp(r'[+-]\d{2}:?\d{2}$'), ''); - return DateTime.tryParse(wallTime); + return providerDateTimeAsWallTime(value, timeZone); } Map _jsonMap(Object? value) { diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index c2960df5..8820e683 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -11,11 +11,13 @@ import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; import '../../../calendar_providers/calendar_colors.dart'; +import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../calendar/data/calendar_repository.dart'; +import '../../calendar/presentation/calendar_color_dialog.dart'; import '../../sync/sync_auth_error.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../../tasks/domain/task_capabilities.dart'; @@ -85,6 +87,7 @@ class _SourceRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final providerWebUri = scheduleCalendarProviderWebUri(account, source); + final capabilities = source.capabilities; return _CompactSourceRow( title: source.summary, leading: _SourceDot( @@ -125,6 +128,8 @@ class _SourceRow extends ConsumerWidget { !source.remindersEnabled, ), ); + case 'color': + unawaited(_changeCalendarColor(context, ref, source)); case 'rename': unawaited(_renameCalendar(context, ref, source)); case 'delete': @@ -149,19 +154,26 @@ class _SourceRow extends ConsumerWidget { icon: Icons.notifications_outlined, selected: source.remindersEnabled, ), + BusyMaxMenuEntry( + value: 'color', + label: context.l10n.calendarColor, + icon: Icons.palette_outlined, + enabled: capabilities.canChangeCalendarColor, + tooltip: _calendarColorRestriction(context, source), + ), BusyMaxMenuEntry( value: 'rename', label: context.l10n.rename, icon: Icons.edit_outlined, - enabled: !source.readOnly, - tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, + enabled: capabilities.canRenameCalendar, + tooltip: _calendarRenameRestriction(context, source), ), BusyMaxMenuEntry( value: 'delete', label: context.l10n.delete, icon: YaruIcons.trash, - enabled: !source.readOnly, - tooltip: source.readOnly ? context.l10n.readOnlyCalendar : null, + enabled: capabilities.canDeleteCalendar, + tooltip: _calendarDeleteRestriction(context, source), destructive: true, ), ], @@ -437,14 +449,16 @@ class _AccountCalendarSources extends ConsumerWidget { ]), builder: (context, snapshot) { final sources = snapshot.data ?? const []; - if (sources.isEmpty) { - return BusyMaxActionRow( - title: context.l10n.noCalendarsSynced, - leading: const Icon(YaruIcons.calendar), - ); - } + final canCreate = calendarManagementCapabilities( + account.provider, + ).supportsCreate; return Column( children: [ + if (sources.isEmpty) + BusyMaxActionRow( + title: context.l10n.noCalendarsSynced, + leading: const Icon(YaruIcons.calendar), + ), for (final source in sources) _SourceRow( key: ValueKey(( @@ -455,6 +469,13 @@ class _AccountCalendarSources extends ConsumerWidget { account: account, source: source, ), + if (canCreate) + BusyMaxActionRow( + key: ValueKey(('new-calendar', account.id)), + title: context.l10n.newCalendar, + leading: const Icon(YaruIcons.plus), + onTap: () => unawaited(_createCalendar(context, ref, account)), + ), ], ); }, @@ -801,6 +822,108 @@ String? _taskListDeleteRestriction( return context.l10n.taskListCannotDelete; } +String? _calendarRenameRestriction( + BuildContext context, + CalendarSourceEntity source, +) { + if (source.capabilities.canRenameCalendar) return null; + if (!calendarManagementCapabilities(source.provider).supportsRename) { + return context.l10n.calendarManagementUnsupported; + } + return context.l10n.readOnlyCalendar; +} + +String? _calendarColorRestriction( + BuildContext context, + CalendarSourceEntity source, +) { + if (source.capabilities.canChangeCalendarColor) return null; + if (!calendarManagementCapabilities(source.provider).supportsColor) { + return context.l10n.calendarManagementUnsupported; + } + return context.l10n.readOnlyCalendar; +} + +String? _calendarDeleteRestriction( + BuildContext context, + CalendarSourceEntity source, +) { + if (source.capabilities.canDeleteCalendar) return null; + if (!calendarManagementCapabilities(source.provider).supportsDelete) { + return context.l10n.calendarManagementUnsupported; + } + if (source.primaryCalendar) { + return context.l10n.primaryCalendarCannotDelete; + } + return context.l10n.readOnlyCalendar; +} + +Future _createCalendar( + BuildContext context, + WidgetRef ref, + AccountEntity account, +) async { + final title = await showBusyMaxTextPrompt( + context, + title: context.l10n.newCalendar, + label: context.l10n.title, + actionLabel: context.l10n.create, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!context.mounted || title == null || title.trim().isEmpty) return; + try { + await ref + .read(calendarRepositoryProvider) + .createLocalSource(accountId: account.id, summary: title); + _requestCalendarMutationSync(ref, account.id); + } on Object catch (error) { + if (context.mounted) { + _showCalendarMutationFailure( + context, + context.l10n.calendarCreateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ); + } + } +} + +Future _changeCalendarColor( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final choice = await showCalendarColorDialog( + context, + provider: source.provider, + currentBackgroundColor: source.backgroundColor, + currentColorId: source.colorId, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!context.mounted || choice == null) return; + try { + await ref + .read(calendarRepositoryProvider) + .setSourceColor(source.id, choice); + _requestCalendarMutationSync(ref, source.accountId); + } on Object catch (error) { + if (context.mounted) { + _showCalendarMutationFailure( + context, + context.l10n.calendarUpdateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ); + } + } +} + Future _renameCalendar( BuildContext context, WidgetRef ref, @@ -820,9 +943,24 @@ Future _renameCalendar( title.trim() == source.summary) { return; } - await ref - .read(calendarRepositoryProvider) - .renameLocalSource(source.id, title.trim()); + try { + await ref + .read(calendarRepositoryProvider) + .renameLocalSource(source.id, title.trim()); + _requestCalendarMutationSync(ref, source.accountId); + } on Object catch (error) { + if (context.mounted) { + _showCalendarMutationFailure( + context, + context.l10n.calendarUpdateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ); + } + } } Future _deleteCalendar( @@ -841,7 +979,32 @@ Future _deleteCalendar( if (!confirmed) { return; } - await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id); + try { + await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id); + _requestCalendarMutationSync(ref, source.accountId); + } on Object catch (error) { + if (context.mounted) { + _showCalendarMutationFailure( + context, + context.l10n.calendarDeleteFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ); + } + } +} + +void _requestCalendarMutationSync(WidgetRef ref, String accountId) { + ref + .read(pendingCalendarMutationSyncRequesterForAccountProvider(accountId)) + .request(); +} + +void _showCalendarMutationFailure(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } Future _renameTaskList( diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 350822f0..e1d957d9 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1897,30 +1897,39 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _saveEvent( EventEditorDraft draft, { + String? originalAccountId, CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, }) async { await ref .read(calendarRepositoryProvider) .updateLocalEvent(draft, guestUpdatePolicy: guestUpdatePolicy); - _requestCalendarMutationSync(draft.accountId); + final sourceAccountId = originalAccountId; + if (sourceAccountId != null && sourceAccountId != draft.accountId) { + unawaited(_syncMovedEvent(draft.accountId, sourceAccountId)); + } else { + _requestCalendarMutationSync(draft.accountId); + } if (mounted) { setState(() {}); } } void _requestCalendarMutationSync(String accountId) { - unawaited(_syncCalendarMutation(accountId)); + unawaited(() async { + await _syncCalendarMutation(accountId); + }()); } - Future _syncCalendarMutation(String accountId) async { + Future _syncCalendarMutation(String accountId) async { try { await ref .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false); + return true; } on Object catch (error) { if (!mounted) { - return; + return false; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1934,6 +1943,17 @@ class _ScheduleWorkspaceState extends ConsumerState { ), ), ); + return false; + } + } + + Future _syncMovedEvent( + String destinationAccountId, + String sourceAccountId, + ) async { + final destinationSynced = await _syncCalendarMutation(destinationAccountId); + if (destinationSynced) { + await _syncCalendarMutation(sourceAccountId); } } @@ -1967,7 +1987,11 @@ class _ScheduleWorkspaceState extends ConsumerState { } final savedDraft = result.draft; if (savedDraft != null) { - await _saveEvent(savedDraft, guestUpdatePolicy: result.guestUpdatePolicy); + await _saveEvent( + savedDraft, + originalAccountId: draft.accountId, + guestUpdatePolicy: result.guestUpdatePolicy, + ); } } @@ -1995,11 +2019,8 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } RecurringEventMutationScope? recurringScope; - if (item is CalendarScheduleItem && - item.providerRecurringEventId != null && - (item.provider == BusyProvider.appleICloud || - item.provider == BusyProvider.nextcloud)) { - recurringScope = await _chooseRecurringEventMutationScope(); + if (item is CalendarScheduleItem && item.providerRecurringEventId != null) { + recurringScope = await _chooseRecurringEventMutationScope(item.provider); if (recurringScope == null || !mounted) return; } var guestUpdatePolicy = CalendarGuestUpdatePolicy.send; @@ -2088,7 +2109,10 @@ class _ScheduleWorkspaceState extends ConsumerState { } } - Future _chooseRecurringEventMutationScope() { + Future _chooseRecurringEventMutationScope( + BusyProvider provider, + ) { + final supportsFollowing = supportsThisAndFollowingEventMutation(provider); return showBusyMaxModalEditorDialog( context, headerBarService: ref.read(linuxHeaderBarServiceProvider), @@ -2103,25 +2127,37 @@ class _ScheduleWorkspaceState extends ConsumerState { BusyMaxGroupedList( filled: true, children: [ - BusyMaxActionRow( - title: context.l10n.entireSeries, - subtitle: context.l10n.chooseRecurringEventScope, - leading: const Icon(Icons.repeat), - onTap: () => Navigator.of( - dialogContext, - ).pop(RecurringEventMutationScope.entireSeries), - ), BusyMaxActionRow( title: context.l10n.singleOccurrence, + subtitle: context.l10n.chooseRecurringEventScope, leading: const Icon(Icons.event_outlined), onTap: () => Navigator.of( dialogContext, ).pop(RecurringEventMutationScope.singleOccurrence), ), BusyMaxActionRow( - title: context.l10n.thisAndFutureUnavailable, - leading: const Icon(Icons.update_disabled_outlined), - enabled: false, + title: context.l10n.thisAndFollowingEvents, + subtitle: supportsFollowing + ? null + : context.l10n.thisAndFutureUnavailable, + leading: Icon( + supportsFollowing + ? Icons.update_outlined + : Icons.update_disabled_outlined, + ), + enabled: supportsFollowing, + onTap: supportsFollowing + ? () => Navigator.of( + dialogContext, + ).pop(RecurringEventMutationScope.thisAndFuture) + : null, + ), + BusyMaxActionRow( + title: context.l10n.entireSeries, + leading: const Icon(Icons.repeat), + onTap: () => Navigator.of( + dialogContext, + ).pop(RecurringEventMutationScope.entireSeries), ), ], ), diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 32326feb..61cb0a12 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -7,11 +7,15 @@ import '../../calendar_providers/calendar_mutation.dart'; import '../../calendar_providers/calendar_description.dart'; import '../../calendar_providers/calendar_sync_dto.dart'; import '../../calendar_providers/cloud_calendar_client.dart'; +import '../../core/time/provider_date_time.dart'; import '../../db/app_database.dart'; import '../../google_calendar/google_calendar_errors.dart'; +import '../../google_calendar/google_calendar_mapper.dart'; import '../../microsoft_calendar/microsoft_calendar_errors.dart'; 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'; class CalendarPendingOpsReplayer { CalendarPendingOpsReplayer({ @@ -56,6 +60,14 @@ class CalendarPendingOpsReplayer { if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { continue; } + if (_copyConfirmationMissing(op)) { + await _blockOp( + op, + 'event_copy_not_confirmed', + 'The destination event was not confirmed, so the original was kept.', + ); + continue; + } try { await _replay(op); @@ -105,18 +117,14 @@ class CalendarPendingOpsReplayer { await _deleteEvent(op); case 'event.respond': await _respondToEvent(op); + case 'event.move': + await _moveEvent(op); case 'calendar.patch': await _patchCalendar(op); case 'calendar.delete': await _deleteCalendar(op); - case 'event.move': case 'calendar.create': - await _blockOp( - op, - 'unsupported_calendar_operation', - _operationType(op), - ); - throw const _PendingOpBlocked(); + await _createCalendar(op); default: await _blockOp(op, 'unknown_calendar_operation', _operationType(op)); throw const _PendingOpBlocked(); @@ -164,6 +172,17 @@ class CalendarPendingOpsReplayer { await _repository.upsertSource(accountId: _accountId, source: source); } + Future _createCalendar(PendingOp op) async { + if (!_client.capabilities.supportsCreateCalendar) { + await _blockOp(op, 'unsupported_calendar_operation', _operationType(op)); + throw const _PendingOpBlocked(); + } + final source = await _client.createCalendar( + _calendarMutation(_request(op)), + ); + await _replaceLocalCalendar(op, source); + } + Future _deleteCalendar(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); await _client.deleteCalendar(providerCalendarId); @@ -187,18 +206,54 @@ class CalendarPendingOpsReplayer { Future _patchEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); - final providerEventId = await _providerEventId(op, local); final request = _request(op); + final recurringScope = request[calendarEventRecurringScopeKey]?.toString(); + if (recurringScope == 'thisAndFuture') { + await _patchGoogleFollowingEvents(op, local: local, request: request); + return; + } + final providerEventId = await _providerEventId(op, local); await _ensureNoEventConflict(op, local, request); + var mutationRequest = request; + if (recurringScope == 'entireSeries') { + final resolved = request[_seriesResolvedRequestKey]; + if (resolved is Map) { + mutationRequest = resolved.cast(); + } else { + final master = await _client.getEvent( + calendarId: providerCalendarId, + eventId: providerEventId, + ); + mutationRequest = _seriesRequestForMaster( + request, + master, + provider: _client.provider, + ); + request[_seriesResolvedRequestKey] = mutationRequest; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(op.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(_nowUtc().toIso8601String()), + ), + ); + } + } final event = await _client.updateEvent( calendarId: providerCalendarId, eventId: providerEventId, mutation: _eventMutation( - request, + mutationRequest, fallbackTimeZone: await _fallbackTimeZone(op, local: local), ), guestUpdatePolicy: _guestUpdatePolicy(request), ); + if (recurringScope == 'entireSeries') { + await _repository.upsertEvent(accountId: _accountId, event: event); + await _markRecurringRowsSynced(local); + return; + } await _database.transaction(() async { final hasDependent = await _rebaseDependentEventEdits(op, event); if (hasDependent) { @@ -255,16 +310,53 @@ class CalendarPendingOpsReplayer { Future _deleteEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); + final request = _request(op); + if (request[calendarEventRecurringScopeKey]?.toString() == + 'thisAndFuture') { + await _deleteGoogleFollowingEvents(op, local: local, request: request); + await _applyDeleteSideEffect(op); + return; + } final providerEventId = await _providerEventId(op, local); await _ensureEventUnchanged(op, local, 'delete'); await _client.deleteEvent( calendarId: providerCalendarId, eventId: providerEventId, - guestUpdatePolicy: _guestUpdatePolicy(_request(op)), + guestUpdatePolicy: _guestUpdatePolicy(request), ); await _applyDeleteSideEffect(op); } + Future _moveEvent(PendingOp op) async { + if (_client.provider != BusyProvider.google) { + await _blockOp( + op, + 'unsupported_calendar_operation', + 'Native event movement is only available for Google Calendar.', + ); + throw const _PendingOpBlocked(); + } + final sourceCalendarId = _require( + op.providerCalendarId, + 'sourceCalendarId', + ); + final request = _request(op); + final destinationCalendarId = _require( + request[calendarEventDestinationCalendarIdKey]?.toString(), + 'destinationCalendarId', + ); + final local = await _localEvent(op); + final providerEventId = await _providerEventId(op, local); + await _ensureEventUnchanged(op, local, 'move'); + final event = await _client.moveEvent( + sourceCalendarId: sourceCalendarId, + eventId: providerEventId, + destinationCalendarId: destinationCalendarId, + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _replaceLocalEvent(op, event); + } + Future _respondToEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); @@ -302,6 +394,295 @@ class CalendarPendingOpsReplayer { } } + Future _patchGoogleFollowingEvents( + PendingOp op, { + required CalendarEvent local, + required Map request, + }) async { + if (_client.provider != BusyProvider.google) { + await _blockOp( + op, + 'unsupported_recurring_scope', + 'This-and-following edits are not supported by this provider API.', + ); + throw const _PendingOpBlocked(); + } + final calendarId = _require(op.providerCalendarId, 'calendarId'); + final masterId = _require( + request[calendarEventTargetProviderIdKey]?.toString(), + 'recurringEventId', + ); + final currentMaster = await _client.getEvent( + calendarId: calendarId, + eventId: masterId, + ); + final snapshotValue = request[_googleSplitMasterRawKey]; + late final CalendarEventDto originalMaster; + if (snapshotValue is Map) { + originalMaster = googleCalendarEventFromJson( + calendarId, + snapshotValue.cast(), + ); + } else { + originalMaster = currentMaster; + request[_googleSplitMasterRawKey] = originalMaster.rawJson; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(op.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(_nowUtc().toIso8601String()), + ), + ); + } + final targetStart = _requiredDateTime( + request[calendarEventOriginalStartKey], + calendarEventOriginalStartKey, + ); + final masterStart = _dtoStart(originalMaster); + if (masterStart == null) { + throw StateError('The recurring master start is unavailable.'); + } + if (_sameOccurrenceStart( + masterStart, + targetStart, + allDay: originalMaster.allDay, + )) { + final mutationRequest = _seriesRequestForMaster( + request, + originalMaster, + provider: _client.provider, + ); + await _client.updateEvent( + calendarId: calendarId, + eventId: masterId, + mutation: _eventMutation( + mutationRequest, + fallbackTimeZone: await _fallbackTimeZone(op, local: local), + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _markRecurringRowsSynced(local); + return; + } + + final rule = EventRecurrenceCodec.decode( + BusyProvider.google, + originalMaster.recurrenceJson, + baseDate: masterStart, + ); + if (!rule.isSupported || !rule.repeats) { + await _blockOp( + op, + 'unsupported_recurrence_rule', + 'The Google recurrence rule cannot be split without data loss.', + ); + throw const _PendingOpBlocked(); + } + final trimmedRule = _trimRuleBefore( + rule, + targetStart, + allDay: originalMaster.allDay, + ); + final trimmedRecurrence = EventRecurrenceCodec.encode( + BusyProvider.google, + trimmedRule, + baseDate: masterStart, + allDay: originalMaster.allDay, + timeZone: originalMaster.startTimeZone, + original: originalMaster.recurrenceJson, + ); + final followingRule = await _followingGoogleRule( + rule, + calendarId: calendarId, + masterId: masterId, + masterStart: masterStart, + targetStart: targetStart, + ); + + await _client.updateEvent( + calendarId: calendarId, + eventId: masterId, + mutation: CalendarEventMutation(recurrence: trimmedRecurrence), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + + final splitRequest = { + ..._semanticSnapshot(BusyProvider.google, originalMaster.rawJson), + for (final entry in request.entries) + if (!_eventRequestMetadataFields.contains(entry.key)) + entry.key: entry.value, + 'allDay': request.containsKey('allDay') + ? request['allDay'] + : local.allDay, + 'start': request.containsKey('start') + ? request['start'] + : _localStart(local), + 'end': request.containsKey('end') ? request['end'] : _localEnd(local), + 'startTimeZone': request.containsKey('startTimeZone') + ? request['startTimeZone'] + : local.startTimeZone, + 'endTimeZone': request.containsKey('endTimeZone') + ? request['endTimeZone'] + : local.endTimeZone, + }; + final splitStart = _requiredDateTime(splitRequest['start'], 'start'); + splitRequest[calendarEventRecurrenceField] = EventRecurrenceCodec.encode( + BusyProvider.google, + followingRule, + baseDate: splitStart, + allDay: splitRequest['allDay'] == true, + timeZone: splitRequest['startTimeZone']?.toString(), + original: originalMaster.recurrenceJson, + ); + if (_newGoogleMeetRequest(originalMaster.rawJson, op.id) + case final conference?) { + splitRequest['conferenceJson'] = conference; + } else { + splitRequest.remove('conferenceJson'); + } + final providerRaw = {...originalMaster.rawJson}..remove('conferenceData'); + final splitEventId = op.id.replaceAll('-', '').toLowerCase(); + try { + 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; + await _client.getEvent(calendarId: calendarId, eventId: splitEventId); + } + await _markRecurringRowsSynced(local); + } + + Future _deleteGoogleFollowingEvents( + PendingOp op, { + required CalendarEvent local, + required Map request, + }) async { + if (_client.provider != BusyProvider.google) { + await _blockOp( + op, + 'unsupported_recurring_scope', + 'This-and-following deletion is not supported by this provider API.', + ); + throw const _PendingOpBlocked(); + } + final calendarId = _require(op.providerCalendarId, 'calendarId'); + final masterId = _require( + request[calendarEventTargetProviderIdKey]?.toString(), + 'recurringEventId', + ); + final master = await _client.getEvent( + calendarId: calendarId, + eventId: masterId, + ); + final masterStart = _dtoStart(master); + if (masterStart == null) { + throw StateError('The recurring master start is unavailable.'); + } + final targetStart = _requiredDateTime( + request[calendarEventOriginalStartKey], + calendarEventOriginalStartKey, + ); + if (_sameOccurrenceStart(masterStart, targetStart, allDay: master.allDay)) { + await _client.deleteEvent( + calendarId: calendarId, + eventId: masterId, + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _markRecurringRowsSynced(local); + return; + } + final rule = EventRecurrenceCodec.decode( + BusyProvider.google, + master.recurrenceJson, + baseDate: masterStart, + ); + if (!rule.isSupported || !rule.repeats) { + await _blockOp( + op, + 'unsupported_recurrence_rule', + 'The Google recurrence rule cannot be trimmed without data loss.', + ); + throw const _PendingOpBlocked(); + } + final trimmedRule = _trimRuleBefore( + rule, + targetStart, + allDay: master.allDay, + ); + await _client.updateEvent( + calendarId: calendarId, + eventId: masterId, + mutation: CalendarEventMutation( + recurrence: EventRecurrenceCodec.encode( + BusyProvider.google, + trimmedRule, + baseDate: masterStart, + allDay: master.allDay, + timeZone: master.startTimeZone, + original: master.recurrenceJson, + ), + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + await _markRecurringRowsSynced(local); + } + + Future _followingGoogleRule( + RecurrenceRule rule, { + required String calendarId, + required String masterId, + required DateTime masterStart, + required DateTime targetStart, + }) async { + final count = rule.count; + if (count == null) return rule; + final instances = await _client.listEventInstances( + calendarId: calendarId, + recurringEventId: masterId, + rangeStart: masterStart.subtract(const Duration(days: 1)), + rangeEnd: targetStart.add(const Duration(days: 1)), + ); + final before = instances.where((instance) { + final original = DateTime.tryParse( + instance.providerOriginalStartKey ?? '', + ); + return original != null && original.isBefore(targetStart); + }).length; + final remaining = count - before; + if (remaining < 1) { + throw StateError('The target occurrence is outside the recurrence.'); + } + return rule.copyWith(count: remaining, untilRaw: null); + } + + Future _markRecurringRowsSynced(CalendarEvent local) async { + final recurringEventId = local.providerRecurringEventId; + if (recurringEventId == null) return; + await (_database.update(_database.calendarEvents)..where( + (row) => + row.accountId.equals(local.accountId) & + row.provider.equals(local.provider) & + row.providerCalendarId.equals(local.providerCalendarId) & + row.providerRecurringEventId.equals(recurringEventId) & + row.syncStatus.equals('pending'), + )) + .write( + CalendarEventsCompanion( + syncStatus: const Value('synced'), + updatedAtLocal: Value(_nowUtc().millisecondsSinceEpoch), + ), + ); + } + Future _replaceLocalEvent( PendingOp op, CalendarEventDto serverEvent, @@ -318,6 +699,8 @@ class CalendarPendingOpsReplayer { await _database.transaction(() async { await _repository.upsertEvent(accountId: _accountId, event: serverEvent); + await _removeMovedSeriesSourceRows(op); + await _confirmDependentCopyDeletes(op, serverEventId); if (tempEventId != null && tempEventId != serverEventId) { await (_database.delete( _database.calendarEvents, @@ -339,6 +722,184 @@ class CalendarPendingOpsReplayer { }); } + Future _replaceLocalCalendar( + PendingOp op, + CalendarSourceDto serverSource, + ) async { + final temporarySourceId = op.calendarSourceId; + final temporaryProviderCalendarId = op.localTempId ?? op.providerCalendarId; + final localSource = temporarySourceId == null + ? null + : await (_database.select(_database.calendarSources) + ..where((row) => row.id.equals(temporarySourceId))) + .getSingleOrNull(); + final serverSourceId = CalendarRepository.sourceId( + accountId: _accountId, + provider: serverSource.provider, + providerCalendarId: serverSource.providerCalendarId, + ); + + await _database.transaction(() async { + await _repository.upsertSource( + accountId: _accountId, + source: serverSource, + ); + if (localSource != null) { + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(serverSourceId))).write( + CalendarSourcesCompanion( + selected: Value(localSource.selected), + remindersEnabled: Value(localSource.remindersEnabled), + hidden: Value(localSource.hidden), + backgroundColor: Value(localSource.backgroundColor), + foregroundColor: Value(localSource.foregroundColor), + colorId: Value(localSource.colorId), + createdAtLocal: Value(localSource.createdAtLocal), + updatedAtLocal: Value(_nowUtc().millisecondsSinceEpoch), + ), + ); + } + if (temporarySourceId != null && temporarySourceId != serverSourceId) { + await _database.customStatement( + 'UPDATE calendar_events SET calendar_source_id = ?, ' + 'provider_calendar_id = ? WHERE account_id = ? ' + 'AND calendar_source_id = ?', + [ + serverSourceId, + serverSource.providerCalendarId, + _accountId, + temporarySourceId, + ], + ); + await _database.customStatement( + 'UPDATE pending_ops SET calendar_source_id = ?, ' + 'provider_calendar_id = ? WHERE account_id = ? ' + 'AND calendar_source_id = ?', + [ + serverSourceId, + serverSource.providerCalendarId, + _accountId, + temporarySourceId, + ], + ); + } + if (temporaryProviderCalendarId != null && + temporaryProviderCalendarId != serverSource.providerCalendarId) { + await _database.customStatement( + 'UPDATE pending_ops SET provider_calendar_id = ? ' + 'WHERE account_id = ? AND provider_calendar_id = ?', + [ + serverSource.providerCalendarId, + _accountId, + temporaryProviderCalendarId, + ], + ); + await _database.customStatement( + 'UPDATE pending_ops SET local_temp_id = ? ' + 'WHERE account_id = ? AND local_temp_id = ?', + [ + serverSource.providerCalendarId, + _accountId, + temporaryProviderCalendarId, + ], + ); + } + await _rewriteCalendarPendingReferences( + oldSourceId: temporarySourceId, + newSourceId: serverSourceId, + oldProviderCalendarId: temporaryProviderCalendarId, + newProviderCalendarId: serverSource.providerCalendarId, + ); + if (temporarySourceId != null && temporarySourceId != serverSourceId) { + await (_database.delete( + _database.calendarSources, + )..where((row) => row.id.equals(temporarySourceId))).go(); + } + }); + } + + Future _rewriteCalendarPendingReferences({ + required String? oldSourceId, + required String newSourceId, + required String? oldProviderCalendarId, + required String newProviderCalendarId, + }) async { + final ops = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + for (final pending in ops) { + Object? rewritten = _request(pending); + if (oldSourceId != null && oldSourceId != newSourceId) { + rewritten = _replaceCalendarReference( + rewritten, + oldSourceId, + newSourceId, + ); + } + if (oldProviderCalendarId != null && + oldProviderCalendarId != newProviderCalendarId) { + rewritten = _replaceCalendarReference( + rewritten, + oldProviderCalendarId, + newProviderCalendarId, + ); + } + final requestJson = jsonEncode(rewritten); + if (requestJson == pending.requestJson) continue; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(pending.id))) + .write(PendingOpsCompanion(requestJson: Value(requestJson))); + } + } + + Future _removeMovedSeriesSourceRows(PendingOp op) async { + if (_operationType(op) != 'event.move') return; + final request = _request(op); + if (request[calendarEventRecurringScopeKey]?.toString() != 'entireSeries') { + return; + } + final recurringEventId = request[calendarEventTargetProviderIdKey] + ?.toString() + .trim(); + final sourceCalendarId = op.providerCalendarId; + if (recurringEventId == null || + recurringEventId.isEmpty || + sourceCalendarId == null) { + return; + } + await (_database.delete(_database.calendarEvents)..where( + (row) => + row.accountId.equals(_accountId) & + row.provider.equals( + op.provider ?? _client.provider.storageValue, + ) & + row.providerCalendarId.equals(sourceCalendarId) & + (row.providerEventId.equals(recurringEventId) | + row.providerRecurringEventId.equals(recurringEventId)), + )) + .go(); + } + + Future _confirmDependentCopyDeletes( + PendingOp completedCreate, + String destinationEventId, + ) async { + if (_operationType(completedCreate) != 'event.create') return; + final dependents = await (_database.select( + _database.pendingOps, + )..where((row) => row.dependsOnOpId.equals(completedCreate.id))).get(); + for (final dependent in dependents) { + final request = _request(dependent); + if (request[calendarEventCopyConfirmationRequiredKey] != true) continue; + request[calendarEventCopyConfirmedKey] = true; + request[calendarEventCopyDestinationEventIdKey] = destinationEventId; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(dependent.id))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + } + } + Future _applyDeleteSideEffect(PendingOp op) async { final eventId = op.eventId; if (eventId == null) { @@ -356,6 +917,38 @@ class CalendarPendingOpsReplayer { } return; } + final request = _request(op); + final scope = request[calendarEventRecurringScopeKey]?.toString(); + if (scope == 'entireSeries' || scope == 'thisAndFuture') { + final local = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingleOrNull(); + final recurringEventId = local?.providerRecurringEventId; + if (local != null && recurringEventId != null) { + await (_database.update(_database.calendarEvents)..where((row) { + var predicate = + row.accountId.equals(local.accountId) & + row.provider.equals(local.provider) & + row.providerCalendarId.equals(local.providerCalendarId) & + (row.providerRecurringEventId.equals(recurringEventId) | + (scope == 'entireSeries' + ? row.providerEventId.equals(recurringEventId) + : const Constant(false))); + if (scope == 'thisAndFuture') { + predicate &= row.isDeleted.equals(true); + } + return predicate; + })) + .write( + CalendarEventsCompanion( + isDeleted: const Value(true), + syncStatus: const Value('synced'), + updatedAtLocal: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + return; + } + } await (_database.update( _database.calendarEvents, )..where((row) => row.id.equals(eventId))).write( @@ -377,7 +970,7 @@ class CalendarPendingOpsReplayer { return; } final current = await _client.getEvent( - calendarId: local.providerCalendarId, + calendarId: op.providerCalendarId ?? local.providerCalendarId, eventId: await _providerEventId(op, local), ); final currentUpdatedUtc = _parseUtc(current.updatedAtServer); @@ -410,7 +1003,7 @@ class CalendarPendingOpsReplayer { return; } final current = await _client.getEvent( - calendarId: local.providerCalendarId, + calendarId: op.providerCalendarId ?? local.providerCalendarId, eventId: await _providerEventId(op, local), ); final currentUpdatedUtc = _parseUtc(current.updatedAtServer); @@ -439,10 +1032,24 @@ class CalendarPendingOpsReplayer { Set _eventMutationFields(Map request) { final clearFields = _eventClearFields(request); + const metadataFields = { + calendarEventClearFieldsKey, + calendarEventGuestUpdatePolicyKey, + calendarEventRecurringScopeKey, + calendarEventTargetProviderIdKey, + calendarEventOriginalStartKey, + calendarEventOriginalEndKey, + calendarEventDestinationCalendarIdKey, + calendarEventDestinationSourceIdKey, + calendarEventCopyConfirmationRequiredKey, + calendarEventCopyConfirmedKey, + calendarEventCopyDestinationEventIdKey, + _googleSplitMasterRawKey, + _seriesResolvedRequestKey, + }; return { for (final entry in request.entries) - if (entry.key != calendarEventClearFieldsKey && - entry.key != calendarEventGuestUpdatePolicyKey && + if (!metadataFields.contains(entry.key) && (entry.value != null || clearFields.contains(entry.key))) entry.key, }; @@ -527,17 +1134,21 @@ class CalendarPendingOpsReplayer { CalendarEventMutation _eventMutation( Map request, { String? fallbackTimeZone, + String? providerEventId, + Map? providerRaw, }) { final clearFields = _eventClearFields(request); final allDay = request['allDay'] == true; final start = request['start']?.toString(); final end = request['end']?.toString(); - final startTimeZone = allDay + final hasStartMutation = request.containsKey('start'); + final hasEndMutation = request.containsKey('end'); + final startTimeZone = allDay || !hasStartMutation ? null : _nonBlank(request['startTimeZone']?.toString()) ?? _nonBlank(fallbackTimeZone) ?? 'UTC'; - final endTimeZone = allDay + final endTimeZone = allDay || !hasEndMutation ? null : _nonBlank(request['endTimeZone']?.toString()) ?? startTimeZone ?? @@ -573,6 +1184,8 @@ class CalendarPendingOpsReplayer { responseRequested: request['responseRequested'] as bool?, hideAttendees: request['hideAttendees'] as bool?, allowNewTimeProposals: request['allowNewTimeProposals'] as bool?, + providerEventId: providerEventId, + providerRaw: providerRaw, ); } @@ -648,6 +1261,10 @@ class CalendarPendingOpsReplayer { } Future _providerEventId(PendingOp op, CalendarEvent local) async { + final target = _request( + op, + )[calendarEventTargetProviderIdKey]?.toString().trim(); + if (target != null && target.isNotEmpty) return target; if (local.providerEventId.startsWith('local:')) { await _blockOp( op, @@ -731,6 +1348,12 @@ class CalendarPendingOpsReplayer { return _readOp(id).then((op) => op != null); } + bool _copyConfirmationMissing(PendingOp op) { + final request = _request(op); + return request[calendarEventCopyConfirmationRequiredKey] == true && + request[calendarEventCopyConfirmedKey] != true; + } + Future _readOp(String id) async { return (_database.select( _database.pendingOps, @@ -760,10 +1383,254 @@ class CalendarPendingOpsReplayer { } } +Object? _replaceCalendarReference( + Object? value, + String oldValue, + String newValue, +) { + if (value is String) { + return value == oldValue ? newValue : value; + } + if (value is List) { + return [ + for (final item in value) + _replaceCalendarReference(item, oldValue, newValue), + ]; + } + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _replaceCalendarReference( + entry.value, + oldValue, + newValue, + ), + }; + } + return value; +} + class _PendingOpBlocked { const _PendingOpBlocked(); } +const _googleSplitMasterRawKey = '_googleSplitMasterRaw'; +const _seriesResolvedRequestKey = '_seriesResolvedRequest'; + +const _eventRequestMetadataFields = { + calendarEventClearFieldsKey, + calendarEventGuestUpdatePolicyKey, + calendarEventRecurringScopeKey, + calendarEventTargetProviderIdKey, + calendarEventOriginalStartKey, + calendarEventOriginalEndKey, + _googleSplitMasterRawKey, + _seriesResolvedRequestKey, +}; + +Map _seriesRequestForMaster( + Map request, + CalendarEventDto master, { + required BusyProvider provider, +}) { + final result = {...request}; + if (request.containsKey('start')) { + final timeZone = request['startTimeZone']?.toString(); + final masterStart = providerDateTimeAsWallTime( + master.allDay ? master.startDate : master.startDateTime, + master.startTimeZone, + ); + final originalStart = providerDateTimeAsWallTime( + request[calendarEventOriginalStartKey]?.toString(), + timeZone, + ); + final desiredStart = DateTime.tryParse(request['start']?.toString() ?? ''); + if (masterStart == null || originalStart == null || desiredStart == null) { + throw StateError('The recurring series start could not be adjusted.'); + } + result['start'] = _naiveWallTime(masterStart) + .add( + _naiveWallTime( + desiredStart, + ).difference(_naiveWallTime(originalStart)), + ) + .toIso8601String(); + } + if (request.containsKey('end')) { + final timeZone = request['endTimeZone']?.toString(); + final masterEnd = providerDateTimeAsWallTime( + master.allDay ? master.endDate : master.endDateTime, + master.endTimeZone, + ); + final originalEnd = providerDateTimeAsWallTime( + request[calendarEventOriginalEndKey]?.toString(), + timeZone, + ); + final desiredEnd = DateTime.tryParse(request['end']?.toString() ?? ''); + if (masterEnd == null || originalEnd == null || desiredEnd == null) { + throw StateError('The recurring series end could not be adjusted.'); + } + result['end'] = _naiveWallTime(masterEnd) + .add(_naiveWallTime(desiredEnd).difference(_naiveWallTime(originalEnd))) + .toIso8601String(); + } + if (request.containsKey('start') && master.recurrenceJson != null) { + final adjustedStart = DateTime.tryParse(result['start']?.toString() ?? ''); + if (adjustedStart == null) { + throw StateError('The recurring series start could not be adjusted.'); + } + result[calendarEventRecurrenceField] = _reanchorSeriesRecurrence( + provider, + master.recurrenceJson!, + originalStart: providerDateTimeAsWallTime( + master.allDay ? master.startDate : master.startDateTime, + master.startTimeZone, + ), + adjustedStart: adjustedStart, + allDay: request['allDay'] as bool? ?? master.allDay, + originalTimeZone: master.startTimeZone, + adjustedTimeZone: + request['startTimeZone']?.toString() ?? master.startTimeZone, + ); + } + return result; +} + +Object _reanchorSeriesRecurrence( + BusyProvider provider, + Object original, { + required DateTime? originalStart, + required DateTime adjustedStart, + required bool allDay, + required String? originalTimeZone, + required String? adjustedTimeZone, +}) { + final rule = EventRecurrenceCodec.decode( + provider, + original, + baseDate: originalStart, + ); + if (rule.isSupported && rule.repeats) { + final untilDate = rule.untilDateFor(timeZone: originalTimeZone); + final adjustedRule = untilDate == null + ? rule + : rule.withUntilDate( + untilDate, + allDay: allDay, + baseDate: adjustedStart, + timeZone: adjustedTimeZone, + ); + return EventRecurrenceCodec.encode( + provider, + adjustedRule, + baseDate: adjustedStart, + allDay: allDay, + timeZone: adjustedTimeZone, + original: original, + )!; + } + if (provider == BusyProvider.microsoft && original is Map) { + final recurrence = Map.from(original); + final originalRange = recurrence['range']; + if (originalRange is Map) { + recurrence['range'] = { + ...Map.from(originalRange), + 'startDate': _isoDate(adjustedStart), + }; + } + return recurrence; + } + return original; +} + +String _isoDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + +DateTime _naiveWallTime(DateTime value) => DateTime( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, +); + +DateTime _requiredDateTime(Object? value, String field) { + final parsed = DateTime.tryParse(value?.toString() ?? ''); + if (parsed == null) throw StateError('Missing or invalid $field.'); + return parsed; +} + +DateTime? _dtoStart(CalendarEventDto event) => DateTime.tryParse( + event.allDay ? event.startDate ?? '' : event.startDateTime ?? '', +); + +bool _sameOccurrenceStart( + DateTime left, + DateTime right, { + required bool allDay, +}) { + if (!allDay) return left.toUtc() == right.toUtc(); + return left.year == right.year && + left.month == right.month && + left.day == right.day; +} + +RecurrenceRule _trimRuleBefore( + RecurrenceRule rule, + DateTime targetStart, { + required bool allDay, +}) { + if (allDay) { + final previousDate = DateTime( + targetStart.year, + targetStart.month, + targetStart.day - 1, + ); + return rule.copyWith(count: null, untilRaw: _basicIcalDate(previousDate)); + } + final until = targetStart.toUtc().subtract(const Duration(seconds: 1)); + return rule.copyWith(count: null, untilRaw: _utcIcalDateTime(until)); +} + +String _basicIcalDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}' + '${value.month.toString().padLeft(2, '0')}' + '${value.day.toString().padLeft(2, '0')}'; + +String _utcIcalDateTime(DateTime value) { + final utc = value.toUtc(); + return '${_basicIcalDate(utc)}T' + '${utc.hour.toString().padLeft(2, '0')}' + '${utc.minute.toString().padLeft(2, '0')}' + '${utc.second.toString().padLeft(2, '0')}Z'; +} + +Object? _newGoogleMeetRequest(Map raw, String requestId) { + final conference = raw['conferenceData']; + if (conference is! Map) return null; + final solution = conference['conferenceSolution']; + final key = solution is Map ? solution['key'] : null; + final type = key is Map ? key['type']?.toString() : null; + if (type != 'hangoutsMeet') return null; + return { + 'createRequest': { + 'requestId': requestId, + 'conferenceSolutionKey': {'type': 'hangoutsMeet'}, + }, + }; +} + +String? _localStart(CalendarEvent event) => + event.allDay ? event.startDate : event.startDateTime; + +String? _localEnd(CalendarEvent event) => + event.allDay ? event.endDate : event.endDateTime; + const _eventSemanticBaselineKey = '__busymaxSemanticBaseline'; Map _mapValue(Object? value) { diff --git a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart index ad3afd33..dc330189 100644 --- a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart +++ b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart @@ -2,16 +2,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../dav/ical/ical_task_alarm.dart'; -import '../../../dav/ical/ical_task_recurrence.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../recurrence/domain/recurrence_rule.dart'; +import '../../recurrence/presentation/recurrence_editor.dart'; import '../domain/task_capabilities.dart'; import 'desktop_date_time_fields.dart'; import 'task_details_draft.dart'; @@ -363,7 +363,7 @@ class _IcalTaskFieldsEditorState extends State { Widget _recurrenceGroup() { final l10n = context.l10n; - final recurrence = IcalTaskRecurrence.fromJson( + final recurrence = RecurrenceRule.fromJson( widget.draft.recurrenceJson, baseDate: _recurrenceBaseDate, ); @@ -532,28 +532,28 @@ class _IcalTaskFieldsEditorState extends State { } Future _editRecurrence() async { - final initial = IcalTaskRecurrence.fromJson( + final initial = RecurrenceRule.fromJson( widget.draft.recurrenceJson, baseDate: _recurrenceBaseDate, ); if (!initial.isSupported) return; - final result = await showBusyMaxModalEditorDialog( + final baseDate = _recurrenceBaseDate ?? DateTime.now(); + final allDay = + widget.draft.microsoftDueTime == null && + widget.draft.microsoftStartTime == null; + final result = await showRecurrenceEditorDialog( context, + initial: initial, + allDay: allDay, + floating: + !allDay && + (widget.draft.microsoftStartTimeZone?.trim().isEmpty ?? true), + baseDate: baseDate, + minimumDate: baseDate, + timeZone: widget.draft.microsoftStartTimeZone, + useNativeDatePicker: widget.useNativeDatePicker, barrierColor: widget.dialogBarrierColor, headerBarService: widget.headerBarService, - maxWidth: 620, - maxHeight: 820, - builder: (context) => _TaskRecurrenceDialog( - initial: initial, - allDay: - widget.draft.microsoftDueTime == null && - widget.draft.microsoftStartTime == null, - baseDate: DateTime.now(), - minimumDate: - DateTime.tryParse(widget.draft.microsoftStartDate ?? '') ?? - DateTime.now(), - useNativeDatePicker: widget.useNativeDatePicker, - ), ); if (result == null || !mounted) return; widget.onChanged( @@ -613,75 +613,15 @@ class _IcalTaskFieldsEditorState extends State { return alarm.action; } - String _recurrenceSummary(IcalTaskRecurrence recurrence) { - if (!recurrence.repeats) return context.l10n.repeatNone; - final l10n = context.l10n; - var summary = recurrence.interval == 1 - ? switch (recurrence.frequency) { - IcalTaskRecurrenceFrequency.daily => l10n.repeatDaily, - IcalTaskRecurrenceFrequency.weekly => l10n.repeatWeekly, - IcalTaskRecurrenceFrequency.monthly => l10n.repeatMonthly, - IcalTaskRecurrenceFrequency.yearly => l10n.repeatYearly, - IcalTaskRecurrenceFrequency.none => l10n.repeatNone, - } - : switch (recurrence.frequency) { - IcalTaskRecurrenceFrequency.daily => l10n.repeatEveryDays( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.weekly => l10n.repeatEveryWeeks( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.monthly => l10n.repeatEveryMonths( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.yearly => l10n.repeatEveryYears( - recurrence.interval, - ), - IcalTaskRecurrenceFrequency.none => l10n.repeatNone, - }; - if (recurrence.frequency == IcalTaskRecurrenceFrequency.weekly && - recurrence.byDay.isNotEmpty) { - summary = - '$summary ${l10n.repeatOnDaysSummary(recurrence.byDay.map(_weekdayLabel).join(', '))}'; - } else if ((recurrence.frequency == IcalTaskRecurrenceFrequency.monthly || - recurrence.frequency == IcalTaskRecurrenceFrequency.yearly) && - recurrence.byMonthDay.isNotEmpty) { - summary = - '$summary ${l10n.repeatOnMonthDaysSummary(recurrence.byMonthDay.join(', '))}'; - } else if (recurrence.bySetPosition case final position?) { - summary = - '$summary ${l10n.repeatOnOrdinalSummary(_ordinalLabel(position), _ordinalDayLabel(recurrence.byDay))}'; - } - if (recurrence.frequency == IcalTaskRecurrenceFrequency.yearly && - recurrence.byMonth.isNotEmpty) { - summary = - '$summary ${l10n.repeatInMonthsSummary(recurrence.byMonth.map(_monthLabel).join(', '))}'; - } - if (recurrence.count != null) { - return '$summary · ${l10n.repeatTimesSummary(recurrence.count!)}'; - } - final until = recurrence.untilDate; - 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)}'; - } + String _recurrenceSummary(RecurrenceRule recurrence) => recurrenceRuleSummary( + context, + recurrence, + timeZone: widget.draft.microsoftStartTimeZone, + ); DateTime? get _recurrenceBaseDate => DateTime.tryParse( widget.draft.microsoftStartDate ?? widget.draft.dueDate ?? '', ); - - String _weekdayLabel(String day) => - _localizedWeekday(context, day, abbreviated: false); - - String _monthLabel(int month) => _localizedMonth(context, month); - - String _ordinalLabel(int value) => _localizedOrdinal(context, value); - - String _ordinalDayLabel(List days) => - _localizedOrdinalDay(context, days); } class _TaskValueSliderRow extends StatelessWidget { @@ -1028,530 +968,6 @@ class _TaskReminderDialogState extends State<_TaskReminderDialog> { }; } -enum _RecurrenceEnd { never, until, count } - -class _TaskRecurrenceDialog extends StatefulWidget { - const _TaskRecurrenceDialog({ - required this.initial, - required this.allDay, - required this.baseDate, - required this.minimumDate, - required this.useNativeDatePicker, - }); - - final IcalTaskRecurrence initial; - final bool allDay; - final DateTime baseDate; - final DateTime minimumDate; - final bool useNativeDatePicker; - - @override - State<_TaskRecurrenceDialog> createState() => _TaskRecurrenceDialogState(); -} - -class _TaskRecurrenceDialogState extends State<_TaskRecurrenceDialog> { - late IcalTaskRecurrence _value; - late _RecurrenceEnd _end; - late final TextEditingController _intervalController; - late final TextEditingController _countController; - - @override - void initState() { - super.initState(); - _value = widget.initial.isSupported - ? widget.initial - : const IcalTaskRecurrence.none(); - _end = _value.count != null - ? _RecurrenceEnd.count - : _value.untilRaw != null - ? _RecurrenceEnd.until - : _RecurrenceEnd.never; - _intervalController = TextEditingController(text: '${_value.interval}'); - _countController = TextEditingController(text: '${_value.count ?? 10}'); - } - - @override - void dispose() { - _intervalController.dispose(); - _countController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final repeats = _value.repeats; - return BusyMaxModalEditorScaffold( - title: l10n.repeat, - cancelLabel: l10n.cancel, - saveLabel: l10n.save, - onCancel: () => Navigator.pop(context), - onSave: _isValid ? () => Navigator.pop(context, _value) : null, - contentMaxWidth: 560, - children: [ - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxComboRow( - title: l10n.repeat, - values: IcalTaskRecurrenceFrequency.values, - selected: _value.frequency, - labelFor: _frequencyLabel, - onSelected: _setFrequency, - ), - if (repeats) - YaruListTile.square( - title: TextField( - controller: _intervalController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: busyMaxGroupedTextFieldDecoration( - context, - labelText: l10n.repeatEvery, - ), - onChanged: (value) { - final parsed = int.tryParse(value); - setState(() { - if (parsed != null && parsed >= 1 && parsed <= 366) { - _value = _value.copyWith(interval: parsed); - } - }); - }, - ), - ), - ], - ), - if (_value.frequency == IcalTaskRecurrenceFrequency.weekly) - BusyMaxGroupedList( - title: l10n.repeatOn, - filled: true, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: YaruChoiceChipBar( - style: YaruChoiceChipBarStyle.wrap, - labels: [ - for (final day in _weekdays) Text(_weekdayLabel(day)), - ], - isSelected: [ - for (final day in _weekdays) _value.byDay.contains(day), - ], - selectedFirst: false, - clearOnSelect: false, - onSelected: (index) { - final day = _weekdays[index]; - _toggleWeekday(day, !_value.byDay.contains(day)); - }, - ), - ), - ], - ), - if (_value.frequency == IcalTaskRecurrenceFrequency.monthly || - _value.frequency == IcalTaskRecurrenceFrequency.yearly) - BusyMaxGroupedList( - title: l10n.repeatDayOfMonth, - filled: true, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: YaruChoiceChipBar( - style: YaruChoiceChipBarStyle.wrap, - labels: [for (var day = 1; day <= 31; day += 1) Text('$day')], - isSelected: [ - for (var day = 1; day <= 31; day += 1) - _value.byMonthDay.contains(day), - ], - selectedFirst: false, - clearOnSelect: false, - onSelected: _value.bySetPosition == null - ? (index) { - final day = index + 1; - _toggleMonthDay( - day, - !_value.byMonthDay.contains(day), - ); - } - : null, - ), - ), - BusyMaxComboRow( - title: l10n.repeatOrdinal, - values: const [0, ..._ordinalPositions], - selected: _value.bySetPosition ?? 0, - labelFor: (value) => value == 0 - ? l10n.repeatSpecificDays - : _localizedOrdinal(context, value), - onSelected: _setOrdinal, - ), - if (_value.bySetPosition != null) - BusyMaxComboRow( - title: l10n.repeatOn, - values: [for (final choice in _ordinalDayChoices) choice.key], - selected: _ordinalDayChoiceKey(_value.byDay), - labelFor: (value) => _localizedOrdinalDay( - context, - _ordinalDayChoices - .firstWhere((choice) => choice.key == value) - .days, - ), - onSelected: (value) { - final days = _ordinalDayChoices - .firstWhere((choice) => choice.key == value) - .days; - setState(() => _value = _value.copyWith(byDay: days)); - }, - ), - ], - ), - if (_value.frequency == IcalTaskRecurrenceFrequency.yearly) - BusyMaxGroupedList( - title: l10n.repeatMonths, - filled: true, - children: [ - Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.md), - child: YaruChoiceChipBar( - style: YaruChoiceChipBarStyle.wrap, - labels: [ - for (var month = 1; month <= 12; month += 1) - Text(_localizedMonth(context, month)), - ], - isSelected: [ - for (var month = 1; month <= 12; month += 1) - _value.byMonth.contains(month), - ], - selectedFirst: false, - clearOnSelect: false, - onSelected: (index) { - final month = index + 1; - _toggleMonth(month, !_value.byMonth.contains(month)); - }, - ), - ), - ], - ), - if (repeats) - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxComboRow<_RecurrenceEnd>( - title: l10n.repeatEnd, - values: _RecurrenceEnd.values, - selected: _end, - labelFor: (value) => switch (value) { - _RecurrenceEnd.never => l10n.repeatNever, - _RecurrenceEnd.until => l10n.repeatUntil, - _RecurrenceEnd.count => l10n.repeatAfter, - }, - onSelected: _setEnd, - ), - if (_end == _RecurrenceEnd.until) - DesktopDateValueRow( - label: l10n.repeatUntil, - date: - _value.untilDate ?? - _dateString(_oneMonthAfter(DateTime.now())), - useNativePicker: widget.useNativeDatePicker, - onChanged: _setUntilDate, - ), - if (_end == _RecurrenceEnd.count) - YaruListTile.square( - title: TextField( - controller: _countController, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: busyMaxGroupedTextFieldDecoration( - context, - labelText: l10n.repeatCount, - ), - onChanged: (value) { - final parsed = int.tryParse(value); - setState(() { - if (parsed != null && parsed >= 1 && parsed <= 3500) { - _value = _value.copyWith(count: parsed); - } - }); - }, - ), - ), - ], - ), - ], - ); - } - - bool get _isValid { - if (!_value.repeats) return true; - final interval = int.tryParse(_intervalController.text); - if (interval == null || interval < 1 || interval > 366) return false; - if (_value.frequency == IcalTaskRecurrenceFrequency.weekly && - _value.byDay.isEmpty) { - return false; - } - if ((_value.frequency == IcalTaskRecurrenceFrequency.monthly || - _value.frequency == IcalTaskRecurrenceFrequency.yearly) && - _value.byMonthDay.isEmpty && - (_value.bySetPosition == null || _value.byDay.isEmpty)) { - return false; - } - if (_value.frequency == IcalTaskRecurrenceFrequency.yearly && - _value.byMonth.isEmpty) { - return false; - } - if (_end == _RecurrenceEnd.until && _value.untilRaw == null) return false; - if (_end == _RecurrenceEnd.count) { - final count = int.tryParse(_countController.text); - if (count == null || count < 1 || count > 3500) return false; - } - return true; - } - - void _setFrequency(IcalTaskRecurrenceFrequency frequency) { - final now = DateTime.now(); - setState(() { - _value = switch (frequency) { - IcalTaskRecurrenceFrequency.none => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - count: null, - untilRaw: null, - ), - IcalTaskRecurrenceFrequency.weekly => _value.copyWith( - frequency: frequency, - byDay: [_weekdayCode(now.weekday)], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ), - IcalTaskRecurrenceFrequency.monthly => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: const [], - byMonthDay: [now.day], - bySetPosition: null, - ), - IcalTaskRecurrenceFrequency.yearly => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: [now.month], - byMonthDay: [now.day], - bySetPosition: null, - ), - IcalTaskRecurrenceFrequency.daily => _value.copyWith( - frequency: frequency, - byDay: const [], - byMonth: const [], - byMonthDay: const [], - bySetPosition: null, - ), - }; - }); - } - - void _toggleWeekday(String day, bool selected) { - final values = [..._value.byDay]; - if (selected) { - if (!values.contains(day)) values.add(day); - } else if (values.length > 1) { - values.remove(day); - } - values.sort( - (left, right) => - _weekdays.indexOf(left).compareTo(_weekdays.indexOf(right)), - ); - setState(() => _value = _value.copyWith(byDay: values)); - } - - void _toggleMonthDay(int day, bool selected) { - final values = [..._value.byMonthDay]; - if (selected) { - if (!values.contains(day)) values.add(day); - } else if (values.length > 1) { - values.remove(day); - } - values.sort(); - setState(() { - _value = _value.copyWith( - byMonthDay: values, - byDay: const [], - bySetPosition: null, - ); - }); - } - - void _toggleMonth(int month, bool selected) { - final values = [..._value.byMonth]; - if (selected) { - if (!values.contains(month)) values.add(month); - } else if (values.length > 1) { - values.remove(month); - } - values.sort(); - setState(() => _value = _value.copyWith(byMonth: values)); - } - - void _setOrdinal(int? value) { - setState(() { - if (value == null || value == 0) { - _value = _value.copyWith( - bySetPosition: null, - byDay: const [], - byMonthDay: _value.byMonthDay.isEmpty - ? [widget.baseDate.day] - : _value.byMonthDay, - ); - } else { - _value = _value.copyWith( - bySetPosition: value, - byDay: _value.byDay.isEmpty - ? [_weekdayCode(widget.baseDate.weekday)] - : _value.byDay, - byMonthDay: const [], - ); - } - }); - } - - void _setEnd(_RecurrenceEnd value) { - setState(() { - _end = value; - _value = switch (value) { - _RecurrenceEnd.never => _value.copyWith(count: null, untilRaw: null), - _RecurrenceEnd.count => _value.copyWith( - count: int.tryParse(_countController.text) ?? 10, - untilRaw: null, - ), - _RecurrenceEnd.until => - _value - .copyWith(count: null) - .withUntilDate( - _value.untilDate ?? _dateString(_oneMonthAfter(DateTime.now())), - allDay: widget.allDay, - ), - }; - }); - } - - void _setUntilDate(String value) { - final date = DateTime.tryParse(value); - if (date == null) return; - final firstDate = DateTime( - widget.minimumDate.year, - widget.minimumDate.month, - widget.minimumDate.day, - ); - if (date.isBefore(firstDate)) return; - setState(() { - _value = _value.withUntilDate(_dateString(date), allDay: widget.allDay); - }); - } - - String _frequencyLabel(IcalTaskRecurrenceFrequency frequency) => - switch (frequency) { - IcalTaskRecurrenceFrequency.none => context.l10n.repeatNone, - IcalTaskRecurrenceFrequency.daily => context.l10n.repeatDaily, - IcalTaskRecurrenceFrequency.weekly => context.l10n.repeatWeekly, - IcalTaskRecurrenceFrequency.monthly => context.l10n.repeatMonthly, - IcalTaskRecurrenceFrequency.yearly => context.l10n.repeatYearly, - }; - - String _weekdayLabel(String day) { - return _localizedWeekday(context, day, abbreviated: true); - } -} - -const _weekdays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; -const _ordinalPositions = [1, 2, 3, 4, 5, -2, -1]; - -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 _weekdayCode(int weekday) => _weekdays[(weekday - 1).clamp(0, 6)]; - -String _ordinalDayChoiceKey(List days) { - for (final choice in _ordinalDayChoices) { - if (_sameDaySet(choice.days, days)) return choice.key; - } - return days.firstOrNull ?? 'MO'; -} - -String _localizedWeekday( - BuildContext context, - String day, { - required bool abbreviated, -}) { - final index = _weekdays.indexOf(day); - if (index < 0) return day; - final date = DateTime(2024, 1, 1 + index); - final locale = Localizations.localeOf(context).toLanguageTag(); - return DateFormat(abbreviated ? 'EEE' : 'EEEE', locale).format(date); -} - -String _localizedMonth(BuildContext context, int month) { - if (month < 1 || month > 12) return '$month'; - return DateFormat( - 'MMM', - Localizations.localeOf(context).toLanguageTag(), - ).format(DateTime(2024, month)); -} - -String _localizedOrdinal(BuildContext context, int value) => switch (value) { - 1 => context.l10n.repeatFirst, - 2 => context.l10n.repeatSecond, - 3 => context.l10n.repeatThird, - 4 => context.l10n.repeatFourth, - 5 => context.l10n.repeatFifth, - -2 => context.l10n.repeatSecondToLast, - -1 => context.l10n.repeatLast, - _ => '$value', -}; - -String _localizedOrdinalDay(BuildContext context, List days) { - if (days.length == 1) { - return _localizedWeekday(context, days.single, abbreviated: false); - } - if (_sameDaySet(days, _ordinalDayChoices[7].days)) { - return context.l10n.repeatAnyDay; - } - if (_sameDaySet(days, _ordinalDayChoices[8].days)) { - return context.l10n.repeatWeekday; - } - if (_sameDaySet(days, _ordinalDayChoices[9].days)) { - return context.l10n.repeatWeekendDay; - } - return days - .map((day) => _localizedWeekday(context, day, abbreviated: false)) - .join(', '); -} - -bool _sameDaySet(List left, List right) { - if (left.length != right.length) return false; - final leftValues = {...left}; - return leftValues.length == left.length && leftValues.containsAll(right); -} - -DateTime _oneMonthAfter(DateTime value) { - final firstOfTarget = DateTime(value.year, value.month + 1); - final lastDay = DateTime(firstOfTarget.year, firstOfTarget.month + 1, 0).day; - final day = value.day > lastDay ? lastDay : value.day; - return DateTime(firstOfTarget.year, firstOfTarget.month, day); -} - String _dateString(DateTime value) => '${value.year.toString().padLeft(4, '0')}-' '${value.month.toString().padLeft(2, '0')}-' diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index 973e5e84..e431ec67 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -71,6 +71,12 @@ class GoogleCalendarApiClient implements CloudCalendarClient { _uri('/calendar/v3/calendars'), body: googleCalendarMutationToJson(mutation), ); + final calendarId = json['id']?.toString(); + if (calendarId != null && + calendarId.isNotEmpty && + _hasCalendarListColor(mutation)) { + return _updateCalendarListColor(calendarId, mutation); + } return googleCalendarSourceFromJson(json); } @@ -79,10 +85,37 @@ class GoogleCalendarApiClient implements CloudCalendarClient { String calendarId, CalendarMutation mutation, ) async { + Map? calendarJson; + final calendarBody = googleCalendarMutationToJson(mutation); + if (calendarBody.isNotEmpty) { + calendarJson = await _requestJson( + 'PATCH', + _uri('/calendar/v3/calendars/${_enc(calendarId)}'), + body: calendarBody, + ); + } + if (_hasCalendarListColor(mutation)) { + return _updateCalendarListColor(calendarId, mutation); + } + if (calendarJson == null) { + throw ArgumentError('Calendar update has no writable fields.'); + } + return googleCalendarSourceFromJson(calendarJson); + } + + Future _updateCalendarListColor( + String calendarId, + CalendarMutation mutation, + ) async { + final usesRgb = + mutation.backgroundColor != null || mutation.foregroundColor != null; final json = await _requestJson( 'PATCH', - _uri('/calendar/v3/calendars/${_enc(calendarId)}'), - body: googleCalendarMutationToJson(mutation), + _uri( + '/calendar/v3/users/me/calendarList/${_enc(calendarId)}', + query: usesRgb ? const {'colorRgbFormat': 'true'} : null, + ), + body: googleCalendarListColorMutationToJson(mutation), ); return googleCalendarSourceFromJson(json); } @@ -161,6 +194,7 @@ class GoogleCalendarApiClient implements CloudCalendarClient { '/calendar/v3/calendars/${_enc(calendarId)}/events', query: { 'conferenceDataVersion': '1', + 'supportsAttachments': 'true', 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), }, ), @@ -309,17 +343,23 @@ class GoogleCalendarApiClient implements CloudCalendarClient { return events; } + @override Future moveEvent({ required String sourceCalendarId, required String eventId, required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, }) async { final json = await _requestJson( 'POST', _uri( '/calendar/v3/calendars/${_enc(sourceCalendarId)}/events/' '${_enc(eventId)}/move', - query: {'destination': destinationCalendarId}, + query: { + 'destination': destinationCalendarId, + 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), + }, ), ); return googleCalendarEventFromJson(destinationCalendarId, json); @@ -535,6 +575,12 @@ class GoogleCalendarApiClient implements CloudCalendarClient { } } +bool _hasCalendarListColor(CalendarMutation mutation) { + return mutation.backgroundColor != null || + mutation.foregroundColor != null || + mutation.colorId != null; +} + String _enc(String value) => Uri.encodeComponent(value); String _rfc3339(DateTime value) => value.toUtc().toIso8601String(); diff --git a/lib/src/google_calendar/google_calendar_mapper.dart b/lib/src/google_calendar/google_calendar_mapper.dart index 2ffb5ce0..587cad8f 100644 --- a/lib/src/google_calendar/google_calendar_mapper.dart +++ b/lib/src/google_calendar/google_calendar_mapper.dart @@ -97,6 +97,16 @@ Map googleCalendarListMutationToJson( }); } +Map googleCalendarListColorMutationToJson( + CalendarMutation mutation, +) { + return _compact({ + 'backgroundColor': mutation.backgroundColor, + 'foregroundColor': mutation.foregroundColor, + 'colorId': mutation.colorId, + }); +} + Map googleEventMutationToJson(CalendarEventMutation mutation) { final allDay = mutation.allDay ?? mutation.startDate != null; final start = allDay @@ -112,26 +122,58 @@ Map googleEventMutationToJson(CalendarEventMutation mutation) { 'timeZone': mutation.endTimeZone, }); - return _compact({ - 'summary': mutation.title, - 'description': mutation.description, - 'location': mutation.location, - if (start.isNotEmpty) 'start': start, - if (end.isNotEmpty) 'end': end, - 'recurrence': mutation.clearRecurrence - ? const [] - : mutation.recurrence, - 'reminders': mutation.reminders, - 'attendees': mutation.clearAttendees - ? const [] - : mutation.attendees, - 'colorId': mutation.colorId, - 'visibility': mutation.visibility, - 'transparency': mutation.transparencyOrShowAs, - if (mutation.hideAttendees != null) - 'guestsCanSeeOtherGuests': !mutation.hideAttendees!, - 'conferenceData': mutation.conference, - }); + return { + ..._googleWritableEventFields(mutation.providerRaw), + ..._compact({ + 'id': mutation.providerEventId, + 'summary': mutation.title, + 'description': mutation.description, + 'location': mutation.location, + if (start.isNotEmpty) 'start': start, + if (end.isNotEmpty) 'end': end, + 'recurrence': mutation.clearRecurrence + ? const [] + : mutation.recurrence, + 'reminders': mutation.reminders, + 'attendees': mutation.clearAttendees + ? const [] + : mutation.attendees, + 'colorId': mutation.colorId, + 'visibility': mutation.visibility, + 'transparency': mutation.transparencyOrShowAs, + if (mutation.hideAttendees != null) + 'guestsCanSeeOtherGuests': !mutation.hideAttendees!, + 'conferenceData': mutation.conference, + }), + }; +} + +Map _googleWritableEventFields(Map? source) { + if (source == null) return const {}; + const writable = { + 'summary', + 'description', + 'location', + 'start', + 'end', + 'recurrence', + 'attendees', + 'reminders', + 'attachments', + 'colorId', + 'visibility', + 'transparency', + 'guestsCanInviteOthers', + 'guestsCanModify', + 'guestsCanSeeOtherGuests', + 'extendedProperties', + 'source', + 'conferenceData', + }; + return { + for (final entry in source.entries) + if (writable.contains(entry.key)) entry.key: entry.value, + }; } String jsonOrNull(Object? value) { diff --git a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart index a347a1c1..b8d5e342 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart @@ -181,6 +181,19 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { ); } + @override + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }) { + throw UnsupportedError( + 'Microsoft Graph does not expose an event move operation.', + ); + } + @override Future respondToEvent({ required String calendarId, diff --git a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart index 9f21b89e..729c4180 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart @@ -88,7 +88,7 @@ CalendarEventDto microsoftCalendarEventFromJson( Map microsoftCalendarMutationToJson( CalendarMutation mutation, ) { - return _compact({'name': mutation.summary}); + return _compact({'name': mutation.summary, 'color': mutation.colorId}); } Map microsoftEventMutationToJson( diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 60cff80a..d6c954ed 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -59,6 +59,8 @@ class CalendarScheduleItem implements ScheduleItem { this.organizer, this.joinMeetingUrl, this.isOrganizer, + this.guestsCanModify, + this.locked = false, this.currentUserResponse, this.colorHex, this.categories = const [], @@ -95,6 +97,8 @@ class CalendarScheduleItem implements ScheduleItem { final Map? organizer; final String? joinMeetingUrl; final bool? isOrganizer; + final bool? guestsCanModify; + final bool locked; final String? currentUserResponse; final String? colorHex; @override diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 54312796..96b5dc72 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -369,6 +369,9 @@ class ScheduleRepository { final organizer = _jsonMapFromString(event.organizerJson); final conference = _jsonValueFromString(event.conferenceJson); final raw = _jsonMapFromString(event.rawJson) ?? const {}; + final isOrganizer = _eventIsOrganizer(provider, organizer, raw); + final sourceWritable = + source != null && !source.readOnly && !source.isDeleted; if (!searching && !_intersects(range, start, end)) { continue; } @@ -391,7 +394,11 @@ class ScheduleRepository { attendees: attendees, organizer: organizer, joinMeetingUrl: _eventJoinMeetingUrl(provider, conference, raw), - isOrganizer: _eventIsOrganizer(provider, organizer, raw), + isOrganizer: isOrganizer, + guestsCanModify: provider == BusyProvider.google + ? raw['guestsCanModify'] == true + : null, + locked: provider == BusyProvider.google && raw['locked'] == true, currentUserResponse: _eventCurrentUserResponse( provider, attendees, @@ -413,9 +420,12 @@ class ScheduleRepository { sourceName: source?.summary, accountDisplayName: accountDisplayNames[event.accountId], accountEmail: accountEmails[event.accountId], - capabilities: source != null && !source.readOnly && !source.isDeleted - ? ScheduleItemCapabilities.editable - : ScheduleItemCapabilities.readOnly, + capabilities: ScheduleItemCapabilities( + canEdit: + sourceWritable && + _eventAllowsFullEditing(provider, isOrganizer, raw), + canDelete: sourceWritable, + ), ), ); } @@ -1175,6 +1185,16 @@ bool? _eventIsOrganizer( }; } +bool _eventAllowsFullEditing( + BusyProvider provider, + bool? isOrganizer, + Map raw, +) { + if (provider != BusyProvider.google) return true; + return raw['locked'] != true && + (isOrganizer == true || raw['guestsCanModify'] == true); +} + String? _eventCurrentUserResponse( BusyProvider provider, List> attendees, diff --git a/test/core/time/provider_date_time_test.dart b/test/core/time/provider_date_time_test.dart index b742bf73..b23b512a 100644 --- a/test/core/time/provider_date_time_test.dart +++ b/test/core/time/provider_date_time_test.dart @@ -39,4 +39,22 @@ void main() { expect(providerDateTimeAsLocal('2026-06-04', 'UTC'), DateTime(2026, 6, 4)); expect(providerDateTimeIsInstant('2026-06-04', 'UTC'), isFalse); }); + + test('editor wall time respects UTC, offsets, and IANA zones', () { + expect( + providerDateTimeAsWallTime('2026-06-08T09:00:00Z', 'UTC'), + DateTime.utc(2026, 6, 8, 9), + ); + expect( + providerDateTimeAsWallTime( + '2026-06-08T09:00:00-07:00', + 'America/Vancouver', + ), + DateTime(2026, 6, 8, 9), + ); + expect( + providerDateTimeAsWallTime('2026-06-08T16:00:00Z', 'America/Vancouver'), + DateTime(2026, 6, 8, 9), + ); + }); } diff --git a/test/dav/ical/ical_recurrence_test.dart b/test/dav/ical/ical_recurrence_test.dart index 751e687a..70777b4e 100644 --- a/test/dav/ical/ical_recurrence_test.dart +++ b/test/dav/ical/ical_recurrence_test.dart @@ -48,6 +48,62 @@ END:VCALENDAR\r expect(moved.summary, 'Moved'); }); + test('applies THISANDFUTURE while retaining later explicit exceptions', () { + final document = IcalSemanticDocument.parse('''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Test//EN\r +BEGIN:VEVENT\r +UID:range@example.test\r +DTSTART;TZID=America/Vancouver:20260803T090000\r +DTEND;TZID=America/Vancouver:20260803T100000\r +RRULE:FREQ=WEEKLY;COUNT=5\r +SUMMARY:Master\r +END:VEVENT\r +BEGIN:VEVENT\r +UID:range@example.test\r +RECURRENCE-ID;TZID=America/Vancouver;RANGE=THISANDFUTURE:20260817T090000\r +DTSTART;TZID=America/Vancouver:20260817T110000\r +DTEND;TZID=America/Vancouver:20260817T123000\r +SUMMARY:Following\r +END:VEVENT\r +BEGIN:VEVENT\r +UID:range@example.test\r +RECURRENCE-ID;TZID=America/Vancouver:20260824T090000\r +DTSTART;TZID=America/Vancouver:20260824T080000\r +DTEND;TZID=America/Vancouver:20260824T090000\r +SUMMARY:Explicit\r +END:VEVENT\r +END:VCALENDAR\r +'''); + + final occurrences = IcalRecurrenceExpander().expand( + document, + rangeStartUtc: DateTime.utc(2026, 8), + rangeEndUtc: DateTime.utc(2026, 9, 8), + ); + + expect(occurrences.map((occurrence) => occurrence.start.rawValue), [ + '20260803T090000', + '20260810T090000', + '20260817T110000', + '20260824T080000', + '20260831T110000', + ]); + expect(occurrences.map((occurrence) => occurrence.summary), [ + 'Master', + 'Master', + 'Following', + 'Explicit', + 'Following', + ]); + expect(occurrences.last.end?.rawValue, '20260831T123000'); + expect(occurrences.last.override, isNull); + expect( + occurrences.last.inheritedOverride?.recurrenceRange, + 'THISANDFUTURE', + ); + }); + test('preserves wall time across a TZID daylight-saving transition', () { final document = IcalSemanticDocument.parse( _eventWithRule( diff --git a/test/dav/ical/ical_task_recurrence_test.dart b/test/dav/ical/ical_task_recurrence_test.dart index 5a2e0d70..44a03c66 100644 --- a/test/dav/ical/ical_task_recurrence_test.dart +++ b/test/dav/ical/ical_task_recurrence_test.dart @@ -45,6 +45,17 @@ void main() { final timed = monthly.withUntilDate('2026-12-31', allDay: false); expect(timed.untilRaw, matches(RegExp(r'^\d{8}T\d{6}Z$'))); expect(timed.untilDate, '2026-12-31'); + expect( + monthly + .withUntilDate( + '2026-12-31', + allDay: false, + floating: true, + baseDate: DateTime(2026, 6, 15, 9, 30), + ) + .untilRaw, + '20261231T093000', + ); }); test('multiple or unknown rules remain opaque and are never rewritten', () { diff --git a/test/dav/mutation/dav_mutation_patch_test.dart b/test/dav/mutation/dav_mutation_patch_test.dart index 63d0bd19..efa26c55 100644 --- a/test/dav/mutation/dav_mutation_patch_test.dart +++ b/test/dav/mutation/dav_mutation_patch_test.dart @@ -119,6 +119,81 @@ void main() { expect(result, contains('X-ALARM-UNKNOWN:keep')); }); + test('new DAV event restores a projected alarm without losing fields', () { + final object = buildDavEventObject( + DavEventMutationInput( + title: 'Copied event', + allDay: false, + start: DateTime.utc(2026, 8, 8, 9), + end: DateTime.utc(2026, 8, 8, 10), + reminders: const { + 'minutes': [17], + 'alarms': [ + { + 'properties': [ + {'name': 'ACTION', 'value': 'DISPLAY'}, + {'name': 'DESCRIPTION', 'value': 'Imported reminder'}, + {'name': 'TRIGGER', 'value': '-PT17M'}, + {'name': 'X-ALARM-KEEP', 'value': 'opaque'}, + ], + }, + ], + }, + ), + idFactory: () => 'copied-event', + nowUtc: () => DateTime.utc(2026, 8, 8, 12), + ); + + expect(object.rawIcs, contains('DESCRIPTION:Imported reminder')); + expect(object.rawIcs, contains('TRIGGER:-PT17M')); + expect(object.rawIcs, contains('X-ALARM-KEEP:opaque')); + expect( + IcalSemanticDocument.parse(object.rawIcs).components.single.alarms, + hasLength(1), + ); + }); + + test('event reminder edit replaces editable alarms and keeps siblings', () { + final baseline = _baselineEvent.replaceFirst( + 'BEGIN:VALARM\r\nACTION:AUDIO', + 'BEGIN:VALARM\r\n' + 'ACTION:DISPLAY\r\n' + 'TRIGGER:-PT30M\r\n' + 'DESCRIPTION:Second reminder\r\n' + 'END:VALARM\r\n' + 'BEGIN:VALARM\r\n' + 'ACTION:AUDIO', + ); + final patch = buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baseline, + input: DavEventMutationInput( + title: 'Baseline', + allDay: false, + start: DateTime(2026, 8, 3, 9), + end: DateTime(2026, 8, 3, 10), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + description: 'Baseline details', + location: 'Room one', + reminders: const { + 'overrides': [ + {'minutes': 45}, + ], + }, + remindersChanged: true, + ), + ); + final result = patch!.applyTo(baseline, nowUtc: DateTime.utc(2026, 8, 8)); + + expect('ACTION:DISPLAY'.allMatches(result), hasLength(1)); + expect(result, contains('TRIGGER:-PT45M')); + expect(result, isNot(contains('TRIGGER:-PT15M'))); + expect(result, isNot(contains('TRIGGER:-PT30M'))); + expect(result, contains('ACTION:AUDIO')); + expect(result, contains('X-ALARM-UNKNOWN:keep')); + }); + test('UTC task dates preserve the entered wall-clock value', () { final object = buildDavTaskObject( const { @@ -444,6 +519,66 @@ END:VCALENDAR\r expect(candidate, contains('X-UNKNOWN;X-PARAM="a,b":keep-me')); }); + test('this-and-following edit writes an RFC 5545 range exception', () { + final patch = buildDavEventOccurrenceExceptionPatch( + uid: 'event@example.test', + occurrenceKey: 'TZID=America/Vancouver:20260817T090000', + baselineRawIcs: _baselineEvent, + input: DavEventMutationInput( + title: 'Following occurrences', + allDay: false, + start: DateTime(2026, 8, 17, 11), + end: DateTime(2026, 8, 17, 12, 30), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + ), + thisAndFuture: true, + nowUtc: () => DateTime.utc(2026, 8, 8, 12), + ); + final candidate = patch.applyTo( + _baselineEvent, + nowUtc: DateTime.utc(2026, 8, 8, 12), + ); + final range = IcalSemanticDocument.parse( + candidate, + ).components.singleWhere((component) => component.recurrenceRange != null); + + expect(range.recurrenceRange, 'THISANDFUTURE'); + expect(range.recurrenceId?.rawValue, '20260817T090000'); + expect(range.start?.rawValue, '20260817T110000'); + expect(range.end?.rawValue, '20260817T123000'); + expect(candidate, contains('RANGE=THISANDFUTURE')); + }); + + test('this-and-following edit promotes an existing exception to a range', () { + final patch = buildDavEventOccurrenceExceptionPatch( + uid: 'event@example.test', + occurrenceKey: 'TZID=America/Vancouver:20260810T090000', + baselineRawIcs: _baselineEvent, + input: DavEventMutationInput( + title: 'Promoted exception', + allDay: false, + start: DateTime(2026, 8, 10, 13), + end: DateTime(2026, 8, 10, 14), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + ), + thisAndFuture: true, + ); + final candidate = patch.applyTo( + _baselineEvent, + nowUtc: DateTime.utc(2026, 8, 8, 12), + ); + final semantic = IcalSemanticDocument.parse(candidate); + final exception = semantic.components.singleWhere( + (component) => component.recurrenceId != null, + ); + + expect(semantic.components, hasLength(2)); + expect(exception.recurrenceRange, 'THISANDFUTURE'); + expect(exception.summary, 'Promoted exception'); + }); + test('one-occurrence delete creates or updates a cancelled exception', () { final generated = buildDavEventOccurrenceCancellationPatch( uid: 'event@example.test', @@ -466,6 +601,23 @@ END:VCALENDAR\r expect('BEGIN:VEVENT'.allMatches(existing), hasLength(2)); }); + test('this-and-following delete writes a cancelled range exception', () { + final candidate = buildDavEventOccurrenceCancellationPatch( + uid: 'event@example.test', + occurrenceKey: 'TZID=America/Vancouver:20260817T090000', + baselineRawIcs: _baselineEvent, + thisAndFuture: true, + nowUtc: () => DateTime.utc(2026, 8, 8, 12), + ).applyTo(_baselineEvent, nowUtc: DateTime.utc(2026, 8, 8, 12)); + final exception = IcalSemanticDocument.parse(candidate).components + .singleWhere( + (component) => component.recurrenceRange == 'THISANDFUTURE', + ); + + expect(exception.status, 'CANCELLED'); + expect(exception.recurrenceRange, 'THISANDFUTURE'); + }); + test( 'three-way merge applies disjoint local change to current server body', () { diff --git a/test/dav/mutation/dav_repository_mutation_integration_test.dart b/test/dav/mutation/dav_repository_mutation_integration_test.dart index 3f13aba7..2fe318bd 100644 --- a/test/dav/mutation/dav_repository_mutation_integration_test.dart +++ b/test/dav/mutation/dav_repository_mutation_integration_test.dart @@ -142,6 +142,56 @@ void main() { }, ); + test('same-account Nextcloud event move uses WebDAV MOVE', () async { + await _seedDestinationCalendar(database); + const href = '${_collectionHref}move-event.ics'; + const keep = 'X-KEEP-EXACT;X-QUOTE="a,b":opaque'; + const alarm = [ + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:Imported reminder', + 'TRIGGER:-PT17M', + 'X-ALARM-KEEP:opaque', + 'END:VALARM', + ]; + final baseline = _eventResource( + uid: 'move-event@example.test', + summary: 'Server title', + extra: const [keep, ...alarm], + ); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"move-event"', body: baseline), + ]); + final event = await database.select(database.calendarEvents).getSingle(); + + await calendarRepository.updateLocalEvent( + _draftForEvent(event).copyWith( + sourceId: _destinationCalendarSourceId, + providerCalendarId: _destinationCollectionHref, + title: 'Moved title', + ), + ); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'dav.move'); + expect(operation.davCollectionId, _collectionId); + expect(operation.destinationCollectionId, _destinationCollectionId); + expect(operation.baselineRawIcs, baseline); + final patch = DavMutationPatch.fromJsonString(operation.mutationPatchJson!); + final movedRaw = patch.applyTo(operation.baselineRawIcs!, nowUtc: _now); + expect(movedRaw, contains('SUMMARY:Moved title')); + expect(movedRaw, contains(keep)); + expect(movedRaw, contains(alarm.join('\r\n'))); + + final projected = await database + .select(database.calendarEvents) + .getSingle(); + expect(projected.calendarSourceId, _destinationCalendarSourceId); + expect(projected.providerCalendarId, _destinationCollectionHref); + expect(projected.davCollectionId, _destinationCollectionId); + expect(projected.syncStatus, 'pending'); + }); + test( 'DAV event attendee mutation is rejected without local side effects', () async { @@ -262,6 +312,51 @@ void main() { expect(candidate, contains('X-SERIES-KEEP:opaque')); }); + test( + 'this-and-following DAV edit projects the RFC range immediately', + () async { + const href = '${_collectionHref}range-edit.ics'; + final baseline = _recurringEventResource(); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"series-etag"', body: baseline), + ]); + final occurrence = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.recurrenceIdKey != null); + + await calendarRepository.updateLocalEvent( + _draftForEvent(occurrence).copyWith( + title: 'Changed following events', + start: DateTime.utc(2026, 8, 9, 13), + end: DateTime.utc(2026, 8, 9, 14), + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + + final operation = await database.select(database.pendingOps).getSingle(); + final patch = DavMutationPatch.fromJsonString( + operation.mutationPatchJson!, + ); + final candidate = patch.applyTo(baseline, nowUtc: _now); + final range = IcalSemanticDocument.parse( + candidate, + ).components.singleWhere((component) => component.recurrenceId != null); + expect(range.recurrenceRange, 'THISANDFUTURE'); + + final projected = await database.select(database.calendarEvents).get(); + final target = projected.singleWhere( + (event) => event.occurrenceKey!.contains('2026-08-09T09:00:00'), + ); + final following = projected.singleWhere( + (event) => event.occurrenceKey!.contains('2026-08-10T09:00:00'), + ); + expect(target.title, 'Changed following events'); + expect(target.startDateTime, '2026-08-09T13:00:00.000Z'); + expect(following.title, 'Changed following events'); + expect(following.startDateTime, '2026-08-10T13:00:00.000Z'); + expect(following.recurrenceIdKey, isNull); + }, + ); + test( 'entire-series edit patches the master anchor and preserves exceptions', () async { @@ -346,6 +441,44 @@ void main() { }, ); + test('this-and-following DAV delete cancels the projected range', () async { + const href = '${_collectionHref}range-delete.ics'; + final baseline = _recurringEventResource(); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"series-etag"', body: baseline), + ]); + final occurrence = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.recurrenceIdKey != null); + + await calendarRepository.deleteLocalEvent( + occurrence.id, + recurringScope: RecurringEventMutationScope.thisAndFuture, + ); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'dav.update'); + final candidate = DavMutationPatch.fromJsonString( + operation.mutationPatchJson!, + ).applyTo(baseline, nowUtc: _now); + final exception = IcalSemanticDocument.parse( + candidate, + ).components.singleWhere((component) => component.recurrenceId != null); + expect(exception.status, 'CANCELLED'); + expect(exception.recurrenceRange, 'THISANDFUTURE'); + + final projected = await database.select(database.calendarEvents).get(); + expect( + projected + .where( + (event) => + event.occurrenceKey!.contains('2026-08-09T09:00:00') || + event.occurrenceKey!.contains('2026-08-10T09:00:00'), + ) + .every((event) => event.isCancelled), + isTrue, + ); + }); + test( 'event delete removes only VEVENT when an unknown sibling is present', () async { @@ -1198,6 +1331,50 @@ Future _seedDestinationTaskList(AppDatabase database) async { ); } +Future _seedDestinationCalendar(AppDatabase database) async { + final now = _now.toIso8601String(); + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: _destinationCollectionId, + accountId: _accountId, + hrefKey: _destinationCollectionHref, + requestUri: 'https://cloud.example.test$_destinationCollectionHref', + displayName: 'Home', + supportedComponentMask: const Value(1), + supportedReportsJson: Value( + jsonEncode([ + '{DAV:}sync-collection', + '{urn:ietf:params:xml:ns:caldav}calendar-multiget', + ]), + ), + currentUserPrivilegesJson: Value( + jsonEncode(['{DAV:}read', '{DAV:}write']), + ), + readOnly: const Value(false), + eventProjectionEnabled: const Value(true), + taskProjectionEnabled: const Value(false), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: _destinationCalendarSourceId, + accountId: _accountId, + provider: 'nextcloud', + providerCalendarId: _destinationCollectionHref, + davCollectionId: const Value(_destinationCollectionId), + summary: 'Home', + createdAtLocal: _now.millisecondsSinceEpoch, + updatedAtLocal: _now.millisecondsSinceEpoch, + ), + ); +} + String _eventResource({ required String uid, required String summary, @@ -1301,5 +1478,6 @@ const _taskListId = 'dav-task-list-collection'; const _destinationCollectionId = 'destination-collection'; const _destinationCollectionHref = '/remote.php/dav/calendars/alex/home/'; const _destinationTaskListId = 'dav-task-list-destination-collection'; +const _destinationCalendarSourceId = 'dav-calendar-destination-collection'; final _now = DateTime.utc(2026, 8, 8, 12); final _later = DateTime.utc(2026, 8, 9, 12); diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index ab750585..ed95a04f 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; @@ -140,6 +141,156 @@ void main() { expect(sources.single.allowedConferenceSolutions, ['hangoutsMeet']); }); + test( + 'calendar creation is optimistic and queued for offline replay', + () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Project', + ); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(source.id, sourceId); + expect(source.providerCalendarId, startsWith('local:')); + expect(source.summary, 'Project'); + expect(operation.operationType, 'calendar.create'); + expect(operation.calendarSourceId, sourceId); + expect(operation.providerCalendarId, source.providerCalendarId); + expect(jsonDecode(operation.requestJson), {'summary': 'Project'}); + }, + ); + + test('event creation waits for its locally created calendar', () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Project', + ); + final source = await database.select(database.calendarSources).getSingle(); + final calendarCreate = await database + .select(database.pendingOps) + .getSingle(); + + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'google:g', + sourceId: sourceId, + providerCalendarId: source.providerCalendarId, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + + final operations = await database.select(database.pendingOps).get(); + final eventCreate = operations.singleWhere( + (operation) => operation.operationType == 'event.create', + ); + expect(eventCreate.dependsOnOpId, calendarCreate.id); + }); + + test('calendar color is projected locally and queued by provider', () async { + await _upsertSource(repository); + const choice = CalendarColorChoice( + providerValue: '#3584e4', + backgroundColor: '#3584e4', + ); + + await repository.setSourceColor(_sourceId, choice); + + final source = await database.select(database.calendarSources).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(source.backgroundColor, '#3584e4'); + expect(source.foregroundColor, '#000000'); + expect(source.colorId, null); + expect(operation.operationType, 'calendar.patch'); + expect(jsonDecode(operation.requestJson), { + 'backgroundColor': '#3584e4', + 'foregroundColor': '#000000', + }); + }); + + test('deleting an unsynced calendar cancels its pending work', () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Temporary', + ); + + await repository.deleteLocalSource(sourceId); + + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('DAV calendar management is rejected before local mutation', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'nextcloud:n', + provider: 'nextcloud', + authority: 'https://cloud.example.test', + providerAccountId: 'n', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + await expectLater( + repository.createLocalSource( + accountId: 'nextcloud:n', + summary: 'New calendar', + ), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.createCalendar, + ), + ), + ); + await repository.upsertSource( + accountId: 'nextcloud:n', + source: const CalendarSourceDto( + provider: BusyProvider.nextcloud, + providerCalendarId: '/calendars/n/work/', + summary: 'Work', + ), + ); + final source = await database.select(database.calendarSources).getSingle(); + + await expectLater( + repository.renameLocalSource(source.id, 'Renamed'), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.renameCalendar, + ), + ), + ); + await expectLater( + repository.deleteLocalSource(source.id), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.deleteCalendar, + ), + ), + ); + + final unchanged = await database + .select(database.calendarSources) + .getSingle(); + expect(unchanged.summary, 'Work'); + expect(unchanged.isDeleted, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + test( 'event queue preserves guest delivery and Meet creation intent', () async { @@ -348,7 +499,12 @@ void main() { expect(draft.allowNewTimeProposals, isFalse); expect(draft.isOrganizer, isFalse); - await repository.updateLocalEvent(draft.copyWith(title: 'Updated')); + await repository.updateLocalEvent( + draft.copyWith( + title: 'Updated', + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + ); final after = await (database.select( database.calendarEvents, @@ -358,6 +514,310 @@ void main() { }, ); + test( + 'Google invitation edit permissions are enforced before mutation', + () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'invitation', + title: 'Invitation', + startDateTime: '2026-06-08T09:00:00-07:00', + endDateTime: '2026-06-08T10:00:00-07:00', + organizerJson: {'email': 'owner@example.com', 'self': false}, + attendeesJson: [ + { + 'email': 'me@example.com', + 'self': true, + 'responseStatus': 'needsAction', + }, + ], + rawJson: {'id': 'invitation'}, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'invitation', + ); + final detail = await repository.loadEventDetail(eventId); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(title: 'Unauthorized edit'), + ), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect((await repository.loadEventDetail(eventId))!.title, 'Invitation'); + + await repository.respondToLocalEvent( + eventId, + CalendarInvitationResponse.accept, + ); + final responseOp = await database.select(database.pendingOps).getSingle(); + expect(responseOp.operationType, 'event.respond'); + }, + ); + + test('title-only recurring Google edit preserves explicit UTC', () async { + await _upsertSource(repository); + const recurrence = ['RRULE:FREQ=WEEKLY;BYDAY=MO']; + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'utc-series', + title: 'UTC series', + startDateTime: '2026-06-08T09:00:00Z', + startTimeZone: 'UTC', + endDateTime: '2026-06-08T10:00:00Z', + endTimeZone: 'UTC', + recurrenceJson: recurrence, + organizerJson: {'self': true}, + updatedAtServer: '2026-06-01T00:00:00Z', + rawJson: { + 'id': 'utc-series', + 'summary': 'UTC series', + 'start': {'dateTime': '2026-06-08T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z', 'timeZone': 'UTC'}, + 'recurrence': recurrence, + 'organizer': {'self': true}, + 'updated': '2026-06-01T00:00:00Z', + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'utc-series', + ); + final detail = await repository.loadEventDetail(eventId); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Renamed'), + ); + + final event = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request, { + 'title': 'Renamed', + calendarEventGuestUpdatePolicyKey: 'send', + }); + expect(event.startDateTime, '2026-06-08T09:00:00Z'); + expect(event.endDateTime, '2026-06-08T10:00:00Z'); + expect(event.startTimeZone, 'UTC'); + expect(event.endTimeZone, 'UTC'); + expect(event.recurrenceJson, jsonEncode(recurrence)); + }); + + test('title-only Google edit preserves offset-only timestamps', () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'offset-event', + title: 'Offset event', + startDateTime: '2026-06-08T09:00:00+02:00', + endDateTime: '2026-06-08T10:00:00+02:00', + organizerJson: {'self': true}, + updatedAtServer: '2026-06-01T00:00:00Z', + rawJson: { + 'id': 'offset-event', + 'summary': 'Offset event', + 'start': {'dateTime': '2026-06-08T09:00:00+02:00'}, + 'end': {'dateTime': '2026-06-08T10:00:00+02:00'}, + 'organizer': {'self': true}, + 'updated': '2026-06-01T00:00:00Z', + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'offset-event', + ); + final detail = await repository.loadEventDetail(eventId); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Renamed'), + ); + + final event = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request.keys, {'title', calendarEventGuestUpdatePolicyKey}); + expect(event.startDateTime, '2026-06-08T09:00:00+02:00'); + expect(event.endDateTime, '2026-06-08T10:00:00+02:00'); + expect(event.startTimeZone, equals(null)); + expect(event.endTimeZone, equals(null)); + }); + + test('title-only Microsoft edit omits structured location', () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'microsoft-calendar', + summary: 'Outlook', + ), + ); + const raw = { + 'id': 'structured-location', + 'subject': 'Site visit', + 'location': { + 'displayName': 'Main office', + 'locationType': 'businessAddress', + 'address': { + 'street': '1 Example Street', + 'city': 'Vancouver', + 'countryOrRegion': 'Canada', + }, + 'coordinates': {'latitude': 49.2827, 'longitude': -123.1207}, + }, + 'start': { + 'dateTime': '2026-06-08T09:00:00', + 'timeZone': 'Pacific Standard Time', + }, + 'end': { + 'dateTime': '2026-06-08T10:00:00', + 'timeZone': 'Pacific Standard Time', + }, + 'updated': '2026-06-01T00:00:00Z', + }; + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'microsoft-calendar', + providerEventId: 'structured-location', + title: 'Site visit', + location: 'Main office', + startDateTime: '2026-06-08T09:00:00', + startTimeZone: 'Pacific Standard Time', + endDateTime: '2026-06-08T10:00:00', + endTimeZone: 'Pacific Standard Time', + updatedAtServer: '2026-06-01T00:00:00Z', + rawJson: raw, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.microsoft, + providerCalendarId: 'microsoft-calendar', + providerEventId: 'structured-location', + ); + final detail = await repository.loadEventDetail(eventId); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Renamed'), + ); + + final event = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request, { + 'title': 'Renamed', + calendarEventGuestUpdatePolicyKey: 'send', + }); + expect(event.location, 'Main office'); + expect(jsonDecode(event.rawJson!), raw); + }); + + test( + 'entire-series edit targets the master and projects every occurrence', + () async { + await _upsertSource(repository); + final eventIds = []; + for (final day in [1, 8, 15]) { + eventIds.add(await _upsertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(eventIds[1]); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'Renamed series', + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + + final rows = + await (database.select(database.calendarEvents)..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) + .get(); + expect(rows.map((row) => row.title), everyElement('Renamed series')); + expect(rows.map((row) => row.syncStatus), everyElement('pending')); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request, { + calendarEventGuestUpdatePolicyKey: 'send', + 'title': 'Renamed series', + calendarEventRecurringScopeKey: 'entireSeries', + calendarEventTargetProviderIdKey: 'series-master', + calendarEventOriginalStartKey: '2026-06-08T09:00:00.000Z', + calendarEventOriginalEndKey: '2026-06-08T10:00:00.000Z', + }); + expect(operation.baselineUpdatedUtc, equals(null)); + expect(operation.baselineRawJson, equals(null)); + }, + ); + + test( + 'this-and-following edit projects only the target and later rows', + () async { + await _upsertSource(repository); + final eventIds = []; + for (final day in [1, 8, 15]) { + eventIds.add(await _upsertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(eventIds[1]); + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + location: 'New room', + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + + final rows = + await (database.select(database.calendarEvents)..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) + .get(); + expect(rows.map((row) => row.location), [null, 'New room', 'New room']); + expect(rows.map((row) => row.syncStatus), [ + 'synced', + 'pending', + 'pending', + ]); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request[calendarEventRecurringScopeKey], 'thisAndFuture'); + expect(request[calendarEventTargetProviderIdKey], 'series-master'); + expect(request['location'], 'New room'); + expect(request, isNot(contains('start'))); + }, + ); + test('local attendee state retains provider response metadata', () async { await _upsertSource(repository); await repository.upsertEvent( @@ -668,6 +1128,49 @@ Future _upsertSource(CalendarRepository repository) { ); } +Future _upsertGoogleOccurrence( + CalendarRepository repository, { + required int day, +}) async { + final date = day.toString().padLeft(2, '0'); + final start = '2026-06-${date}T09:00:00.000Z'; + final end = '2026-06-${date}T10:00:00.000Z'; + final providerEventId = 'occurrence-$date'; + await repository.upsertEvent( + accountId: 'google:g', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: providerEventId, + providerRecurringEventId: 'series-master', + providerOriginalStartKey: start, + title: 'Weekly planning', + organizerJson: const {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + updatedAtServer: '2026-05-30T00:00:00.000Z', + rawJson: { + 'id': providerEventId, + 'summary': 'Weekly planning', + 'recurringEventId': 'series-master', + 'originalStartTime': {'dateTime': start}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + 'updated': '2026-05-30T00:00:00.000Z', + }, + ), + ); + return CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: providerEventId, + providerOriginalStartKey: start, + ); +} + EventEditorDraft _newEventDraft() { return EventEditorDraft.newEvent( accountId: 'google:g', diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 21a46da2..4423c699 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1,14 +1,14 @@ import 'dart:io'; -import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/features/recurrence/presentation/recurrence_editor.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; -import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -710,173 +710,194 @@ void main() { expect(attendee.optional, isTrue); }); - for (final recurrenceCase in _microsoftRecurrenceCases.entries) { - testWidgets( - 'Microsoft ${recurrenceCase.key} recurrence emits required Graph fields', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'microsoft-account', - sourceId: 'microsoft-source', - providerCalendarId: 'ms-cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _microsoftSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), - ), - ), + testWidgets('recurrence editor creates a biweekly weekday schedule', ( + tester, + ) async { + EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime(2026, 6, 10, 9), + end: DateTime(2026, 6, 10, 10), + ).copyWith( + title: 'Planning', + recurrence: const ['RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10'], ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: initial, + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); - final repeatRow = tester.widget>( - find.byWidgetPredicate( - (widget) => - widget is BusyMaxComboRow && widget.title == 'Repeat', + _actionRow(tester, 'Repeat').onTap!(); + await tester.pumpAndSettle(); + final dialog = find.byType(RecurrenceEditorDialog); + expect(dialog, findsOneWidget); + tester + .widget>( + find.descendant( + of: dialog, + matching: find.byType(BusyMaxComboRow), ), - ); - repeatRow.onSelected(recurrenceCase.key); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); - - final expectedRecurrence = { - 'pattern': recurrenceCase.value, - 'range': const {'type': 'noEnd', 'startDate': '2026-06-10'}, - }; - final body = microsoftEventMutationToJson( - CalendarEventMutation(recurrence: saved?.recurrence), - ); + ) + .onSelected(RecurrenceFrequency.weekly); + await tester.pump(); - expect(saved, isNotNull); - expect(body, {'recurrence': expectedRecurrence}); - }, + final fields = find.descendant( + of: dialog, + matching: find.byType(TextField), ); - } - - for (final recurrenceCase in _microsoftReanchoredRecurrenceCases.entries) { - testWidgets( - 'Microsoft ${recurrenceCase.key} recurrence follows a changed start date', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'microsoft-account', - sourceId: 'microsoft-source', - providerCalendarId: 'ms-cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _microsoftSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), - ), - ), - ); + await tester.enterText(fields.at(0), '2'); + await tester.enterText(fields.at(1), '9'); + var weekdays = tester.widget( + find.descendant(of: dialog, matching: find.byType(YaruChoiceChipBar)), + ); + weekdays.onSelected!(0); + await tester.pump(); + weekdays = tester.widget( + find.descendant(of: dialog, matching: find.byType(YaruChoiceChipBar)), + ); + weekdays.onSelected!(4); + await tester.pump(); - final repeatRow = tester.widget>( - find.byWidgetPredicate( - (widget) => - widget is BusyMaxComboRow && widget.title == 'Repeat', - ), + await tester.tap( + find.descendant( + of: dialog, + matching: find.widgetWithText(ElevatedButton, 'Save'), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Every 2 weeks'), findsOneWidget); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=9', + ]); + }); + + testWidgets('Microsoft recurrence range follows a changed start date', ( + tester, + ) async { + EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + start: DateTime(2026, 6, 9, 9), + end: DateTime(2026, 6, 9, 10), + ).copyWith( + title: 'Planning', + recurrence: const { + 'pattern': { + 'type': 'relativeMonthly', + 'interval': 1, + 'daysOfWeek': ['tuesday'], + 'index': 'second', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-06-09', + 'endDate': '2026-12-31', + }, + }, ); - repeatRow.onSelected(recurrenceCase.key); - await tester.pump(); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: initial, + sources: _microsoftSources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); - final startDateRow = tester.widget( + tester + .widget( find.byWidgetPredicate( (widget) => widget is DesktopDateValueRow && widget.label == 'Start date', ), - ); - startDateRow.onChanged('2026-07-11'); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); - - expect(saved?.recurrence, { - 'pattern': recurrenceCase.value, - 'range': const {'type': 'noEnd', 'startDate': '2026-07-11'}, - }); - }, - ); - } - - testWidgets( - 'new event converts Google recurrence when Microsoft account is selected', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'account', - sourceId: 'source', - providerCalendarId: 'cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _mixedProviderSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), - ), - ), - ); - - _comboRow(tester, 'Repeat').onSelected('weekly'); - await tester.pump(); - _comboRow(tester, 'Account').onSelected('microsoft-account'); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); + ) + .onChanged('2026-07-14'); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); - expect(saved?.recurrence, { - 'pattern': _microsoftRecurrenceCases['weekly'], - 'range': const {'type': 'noEnd', 'startDate': '2026-06-10'}, - }); - }, - ); + expect(saved?.recurrence, const { + 'pattern': { + 'type': 'relativeMonthly', + 'interval': 1, + 'daysOfWeek': ['tuesday'], + 'index': 'second', + }, + 'range': { + 'type': 'endDate', + 'startDate': '2026-07-14', + 'endDate': '2026-12-31', + }, + }); + }); - testWidgets( - 'new event converts Microsoft recurrence when Google account is selected', - (tester) async { - EventEditorDraft? saved; - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'microsoft-account', - sourceId: 'microsoft-source', - providerCalendarId: 'ms-cal-1', - start: DateTime.utc(2026, 6, 10, 9), - end: DateTime.utc(2026, 6, 10, 10), - ).copyWith(title: 'Planning'), - sources: _mixedProviderSources, - onCancel: () {}, - onSave: (draft) => saved = draft, - ), + testWidgets('new event converts rich recurrence when provider changes', ( + tester, + ) async { + EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime(2026, 6, 10, 9), + end: DateTime(2026, 6, 10, 10), + ).copyWith( + title: 'Planning', + recurrence: const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=9', + ], + ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: initial, + sources: _mixedProviderSources, + onCancel: () {}, + onSave: (draft) => saved = draft, ), ), - ); + ), + ); - _comboRow(tester, 'Repeat').onSelected('weekly'); - await tester.pump(); - _comboRow(tester, 'Account').onSelected('account'); - await tester.pump(); - await tester.tap(_headerButtonFinder('Save')); + _comboRow(tester, 'Account').onSelected('microsoft-account'); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); - expect(saved?.recurrence, ['RRULE:FREQ=WEEKLY;INTERVAL=1']); - }, - ); + expect(saved?.recurrence, const { + 'pattern': { + 'type': 'weekly', + 'interval': 2, + 'daysOfWeek': ['monday', 'wednesday', 'friday'], + 'firstDayOfWeek': 'monday', + }, + 'range': { + 'type': 'numbered', + 'startDate': '2026-06-10', + 'numberOfOccurrences': 9, + }, + }); + }); testWidgets('recurring occurrence hides series recurrence control', ( tester, @@ -907,7 +928,7 @@ void main() { expect(find.text('Repeat'), findsNothing); }); - testWidgets('DAV recurring event requires an explicit supported scope', ( + testWidgets('DAV recurring event exposes every RFC-supported scope', ( tester, ) async { EventEditorDraft? saved; @@ -942,28 +963,32 @@ void main() { expect(find.text('Recurring event scope'), findsOneWidget); expect(find.text('Entire series'), findsOneWidget); - expect(find.text('This occurrence'), findsOneWidget); - expect(find.text('This and future (not available)'), findsOneWidget); + expect(find.text('This event'), findsOneWidget); + expect(find.text('This and following events'), findsOneWidget); + expect(find.text('Not supported by this provider.'), findsNothing); expect(_headerButton(tester, 'Save').onPressed, isNull); expect(_actionRow(tester, 'Delete Event').onTap, isNull); - expect( - _actionRow(tester, 'This and future (not available)').enabled, - isFalse, - ); + expect(_actionRow(tester, 'This and following events').enabled, isTrue); - await tester.ensureVisible(find.text('Entire series')); - await tester.tap(find.text('Entire series')); + await tester.ensureVisible(find.text('This and following events')); + await tester.tap(find.text('This and following events')); + await tester.pump(); + expect(_headerButton(tester, 'Save').onPressed, isNull); + await tester.enterText( + find.byType(TextFormField).first, + 'Changed following events', + ); await tester.pump(); expect(_headerButton(tester, 'Save').onPressed, isNotNull); expect(_actionRow(tester, 'Delete Event').onTap, isNotNull); await tester.tap(_headerButtonFinder('Save')); expect( saved?.recurringMutationScope, - RecurringEventMutationScope.entireSeries, + RecurringEventMutationScope.thisAndFuture, ); - await tester.ensureVisible(find.text('This occurrence')); - await tester.tap(find.text('This occurrence')); + await tester.ensureVisible(find.text('This event')); + await tester.tap(find.text('This event')); await tester.pump(); await tester.ensureVisible(find.text('Delete Event')); await tester.tap(find.text('Delete Event')); @@ -971,6 +996,269 @@ void main() { expect(deletedScope, RecurringEventMutationScope.singleOccurrence); }); + testWidgets('Google recurring event exposes this-and-following scope', ( + tester, + ) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + expect(_actionRow(tester, 'This and following events').enabled, isTrue); + await tester.ensureVisible(find.text('This and following events')); + await tester.tap(find.text('This and following events')); + await tester.pump(); + expect(_headerButton(tester, 'Save').onPressed, isNull); + await tester.enterText( + find.byType(TextFormField).first, + 'Changed following events', + ); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + + expect( + saved?.recurringMutationScope, + RecurringEventMutationScope.thisAndFuture, + ); + }); + + testWidgets( + 'Microsoft recurring event disables undocumented following scope', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'microsoft-occurrence', + providerRecurringEventId: 'microsoft-series', + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _microsoftSources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + expect(_actionRow(tester, 'This and following events').enabled, isFalse); + expect(find.text('Not supported by this provider.'), findsOneWidget); + }, + ); + + testWidgets('moving a Google series natively allows the entire series', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _multipleSources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + _comboRow(tester, 'Calendar').onSelected('destination-source'); + await tester.pump(); + + expect(_actionRow(tester, 'This event').enabled, isTrue); + expect(_actionRow(tester, 'This and following events').enabled, isFalse); + expect(_actionRow(tester, 'Entire series').enabled, isTrue); + expect( + find.text( + 'This and following events cannot be moved safely. ' + 'Choose this event or the entire series.', + ), + findsOneWidget, + ); + }); + + testWidgets('native Google move preserves an unsupported recurrence rule', ( + tester, + ) async { + EventEditorDraft? saved; + const recurrence = ['RRULE:FREQ=WEEKLY;BYHOUR=9']; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + recurrence: recurrence, + ), + sources: _multipleSources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + _comboRow(tester, 'Calendar').onSelected('destination-source'); + await tester.pump(); + await tester.ensureVisible(find.text('Entire series')); + await tester.tap(find.text('Entire series')); + await tester.pump(); + await tester.enterText( + find.byType(TextFormField).first, + 'Moved weekly planning', + ); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.recurrence, recurrence); + expect(saved?.recurrenceChanged, isFalse); + expect( + saved?.recurringMutationScope, + RecurringEventMutationScope.entireSeries, + ); + }); + + testWidgets('copying a series requires a locally available recurrence rule', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'google-account', + sourceId: 'google-work', + providerCalendarId: 'google-work-calendar', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sameNamedCrossAccountSources, + accounts: _eventAccounts, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + _comboRow(tester, 'Account').onSelected('microsoft-account'); + await tester.pump(); + + expect(_actionRow(tester, 'This event').enabled, isTrue); + expect(_actionRow(tester, 'This and following events').enabled, isFalse); + expect(_actionRow(tester, 'Entire series').enabled, isFalse); + expect( + find.text( + 'The recurrence rule is not available locally. ' + 'Move this event instead.', + ), + findsOneWidget, + ); + }); + + testWidgets( + 'an occurrence with unsupported recurrence can move as one event', + (tester) async { + EventEditorDraft? saved; + const recurrence = ['RRULE:FREQ=WEEKLY;BYHOUR=9']; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-occurrence', + providerRecurringEventId: 'google-series', + accountId: 'google-account', + sourceId: 'google-work', + providerCalendarId: 'google-work-calendar', + title: 'Weekly planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + recurrence: recurrence, + ), + sources: _sameNamedCrossAccountSources, + accounts: _eventAccounts, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + _comboRow(tester, 'Account').onSelected('microsoft-account'); + await tester.pump(); + + expect(_comboRow(tester, 'Account').selected, 'microsoft-account'); + expect(_actionRow(tester, 'This event').enabled, isTrue); + expect(_actionRow(tester, 'Entire series').enabled, isFalse); + await tester.ensureVisible(find.text('This event')); + await tester.tap(find.text('This event')); + await tester.pump(); + await tester.enterText( + find.byType(TextFormField).first, + 'Moved occurrence', + ); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.sourceId, 'microsoft-work'); + expect(saved?.recurrence, recurrence); + expect( + saved?.recurringMutationScope, + RecurringEventMutationScope.singleOccurrence, + ); + }, + ); + testWidgets( 'DAV event keeps guests display-only and exposes categories and alarms', (tester) async { @@ -1026,19 +1314,30 @@ void main() { }, ); - testWidgets('new Nextcloud recurrence uses RFC RRULE values', (tester) async { + testWidgets('Nextcloud recurrence editor preserves a rich RFC rule', ( + tester, + ) async { EventEditorDraft? saved; + final initial = + EventEditorDraft.newEvent( + accountId: 'nextcloud-account', + sourceId: 'nextcloud-source', + providerCalendarId: '/calendars/work/', + start: DateTime(2026, 6, 9, 9), + end: DateTime(2026, 6, 9, 10), + ).copyWith( + title: 'Planning', + recurrence: const { + 'rules': ['FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;COUNT=6'], + 'dates': [], + 'excludedDates': [], + }, + ); await tester.pumpWidget( localizedTestApp( child: Scaffold( body: EventEditor( - initialDraft: EventEditorDraft.newEvent( - accountId: 'nextcloud-account', - sourceId: 'nextcloud-source', - providerCalendarId: '/calendars/work/', - start: DateTime.utc(2026, 6, 8, 9), - end: DateTime.utc(2026, 6, 8, 10), - ).copyWith(title: 'Planning'), + initialDraft: initial, sources: _nextcloudSources, onCancel: () {}, onSave: (draft) => saved = draft, @@ -1047,11 +1346,25 @@ void main() { ), ); - _comboRow(tester, 'Repeat').onSelected('weekly'); - await tester.pump(); + _actionRow(tester, 'Repeat').onTap!(); + await tester.pumpAndSettle(); + final dialog = find.byType(RecurrenceEditorDialog); + expect(find.text('Second'), findsWidgets); + expect(find.text('Tuesday'), findsWidgets); + await tester.tap( + find.descendant( + of: dialog, + matching: find.widgetWithText(ElevatedButton, 'Save'), + ), + ); + await tester.pumpAndSettle(); await tester.tap(_headerButtonFinder('Save')); - expect(saved?.recurrence, ['RRULE:FREQ=WEEKLY;INTERVAL=1']); + expect(saved?.recurrence, const { + 'rules': ['FREQ=MONTHLY;INTERVAL=1;BYDAY=TU;BYSETPOS=2;COUNT=6'], + 'dates': [], + 'excludedDates': [], + }); }); testWidgets('event editor does not show metadata fields', (tester) async { @@ -1118,7 +1431,7 @@ void main() { ); }); - testWidgets('existing event only exposes its original calendar', ( + testWidgets('existing event exposes writable destination calendars', ( tester, ) async { await tester.pumpWidget( @@ -1152,8 +1465,8 @@ void main() { ); expect(calendarRow.selected, 'source'); - expect(calendarRow.values, ['source']); - expect(calendarRow.enabled, isFalse); + expect(calendarRow.values, ['destination-source', 'source']); + expect(calendarRow.enabled, isTrue); final accountRow = _comboRow(tester, 'Account'); expect(accountRow.selected, 'account'); expect(accountRow.values, ['account']); @@ -2361,37 +2674,3 @@ const _microsoftSources = [ ]; const _mixedProviderSources = [..._sources, ..._microsoftSources]; - -const _microsoftRecurrenceCases = >{ - 'daily': {'type': 'daily', 'interval': 1}, - 'weekly': { - 'type': 'weekly', - 'interval': 1, - 'daysOfWeek': ['wednesday'], - 'firstDayOfWeek': 'sunday', - }, - 'monthly': {'type': 'absoluteMonthly', 'interval': 1, 'dayOfMonth': 10}, - 'yearly': { - 'type': 'absoluteYearly', - 'interval': 1, - 'dayOfMonth': 10, - 'month': 6, - }, -}; - -const _microsoftReanchoredRecurrenceCases = >{ - 'daily': {'type': 'daily', 'interval': 1}, - 'weekly': { - 'type': 'weekly', - 'interval': 1, - 'daysOfWeek': ['saturday'], - 'firstDayOfWeek': 'sunday', - }, - 'monthly': {'type': 'absoluteMonthly', 'interval': 1, 'dayOfMonth': 11}, - 'yearly': { - 'type': 'absoluteYearly', - 'interval': 1, - 'dayOfMonth': 11, - 'month': 7, - }, -}; diff --git a/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart b/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart index 56249621..0c2424e5 100644 --- a/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart +++ b/test/features/calendar/presentation/event_guest_delivery_dialog_test.dart @@ -1,3 +1,4 @@ +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; @@ -116,6 +117,86 @@ void main() { expect(await result, CalendarGuestUpdatePolicy.doNotSend); }); + testWidgets('copy-delete move explains the provider boundary before saving', ( + tester, + ) async { + Future? result; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + result = showBusyMaxEventEditorDialog( + context, + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'microsoft-account', + sourceId: 'calendar-1', + providerCalendarId: 'provider-calendar-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: const [ + CalendarSourceEntity( + id: 'calendar-1', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'provider-calendar-1', + summary: 'Work', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ), + CalendarSourceEntity( + id: 'calendar-2', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'provider-calendar-2', + summary: 'Personal', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ), + ], + accounts: const [], + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final calendarRow = tester.widget>( + find.byWidgetPredicate( + (widget) => + widget is BusyMaxComboRow && widget.title == 'Calendar', + ), + ); + calendarRow.onSelected('calendar-2'); + await tester.pump(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Copy event and delete original?'), findsOneWidget); + expect(find.textContaining('Microsoft · Work'), findsOneWidget); + expect(find.textContaining('Microsoft · Personal'), findsOneWidget); + expect(find.textContaining('only after the copy succeeds'), findsOneWidget); + await tester.tap(find.text('Copy and delete')); + await tester.pumpAndSettle(); + + final editorResult = await result; + expect(editorResult?.draft?.sourceId, 'calendar-2'); + }); + testWidgets('Microsoft delete explains that a cancellation will be sent', ( tester, ) async { diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 919c406c..5de8919b 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -3848,7 +3848,9 @@ void main() { ), ), ); - expect(sidebar, isNot(contains('context.l10n.create'))); + expect(sidebar, isNot(contains('title: context.l10n.create,'))); + expect(sidebar, contains('title: context.l10n.newCalendar')); + expect(sidebar, contains('actionLabel: context.l10n.create')); expect(sidebar, isNot(contains('PushButton.filled'))); expect(toolbar, isNot(contains('BusyMaxShortcutLabels.create'))); expect(toolbar, contains('tooltip: context.l10n.create')); diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index f9479547..38ff3c69 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -388,11 +388,86 @@ void main() { expect(event.organizer?['email'], 'organizer@example.com'); expect(event.isOrganizer, isFalse); + expect(event.guestsCanModify, isFalse); + expect(event.locked, isFalse); + expect(event.capabilities.canEdit, isFalse); expect(event.currentUserResponse, 'tentative'); expect(event.canRespondToInvitation, isTrue); expect(event.joinMeetingUrl, 'https://meet.google.com/abc-defg-hij'); }); + test( + 'Google event editability honors organizer, guest, and lock state', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: BusyProvider.google); + final calendarRepository = CalendarRepository(database: database); + await calendarRepository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Work', + ), + ); + for (final event in const [ + CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'owned', + title: 'Owned event', + startDateTime: '2026-06-11T09:00:00-07:00', + endDateTime: '2026-06-11T10:00:00-07:00', + organizerJson: {'self': true}, + rawJson: {'id': 'owned'}, + ), + CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'guest-editable', + title: 'Guest-editable event', + startDateTime: '2026-06-11T11:00:00-07:00', + endDateTime: '2026-06-11T12:00:00-07:00', + organizerJson: {'self': false}, + rawJson: {'id': 'guest-editable', 'guestsCanModify': true}, + ), + CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'locked', + title: 'Locked event', + startDateTime: '2026-06-11T13:00:00-07:00', + endDateTime: '2026-06-11T14:00:00-07:00', + organizerJson: {'self': true}, + rawJson: {'id': 'locked', 'locked': true}, + ), + ]) { + await calendarRepository.upsertEvent( + accountId: 'account', + event: event, + ); + } + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 11)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeTasks: false, + ), + ); + final byTitle = { + for (final item in items.whereType()) + item.title: item, + }; + + expect(byTitle['Owned event']!.capabilities.canEdit, isTrue); + expect(byTitle['Guest-editable event']!.capabilities.canEdit, isTrue); + expect(byTitle['Locked event']!.capabilities.canEdit, isFalse); + expect(byTitle['Locked event']!.capabilities.canDelete, isTrue); + }, + ); + test('Microsoft invitation and Teams data reach the schedule item', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); diff --git a/test/features/sync/calendar_event_clear_patch_test.dart b/test/features/sync/calendar_event_clear_patch_test.dart index 34aaf247..388887ca 100644 --- a/test/features/sync/calendar_event_clear_patch_test.dart +++ b/test/features/sync/calendar_event_clear_patch_test.dart @@ -154,6 +154,9 @@ _editAndReplay({ endTimeZone: 'UTC', recurrenceJson: recurrence, attendeesJson: attendees, + organizerJson: provider == BusyProvider.google + ? const {'self': true} + : null, updatedAtServer: '2026-06-08T00:00:00.000Z', rawJson: _eventJson( provider, @@ -275,6 +278,7 @@ Map _googleEventJson({ 'summary': edited ? 'Edited planning' : 'Planning', 'start': {'dateTime': '2026-06-08T09:00:00.000Z', 'timeZone': 'UTC'}, 'end': {'dateTime': '2026-06-08T10:00:00.000Z', 'timeZone': 'UTC'}, + 'organizer': {'self': true}, if (includeRecurrence) 'recurrence': ['RRULE:FREQ=WEEKLY'], if (includeAttendees) 'attendees': [ diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index bf49f0ba..53379afc 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -173,7 +173,7 @@ void main() { ); test( - 'local Google event edit uses app local timezone over UTC event', + 'title-only Google edit preserves the explicit UTC representation', () async { await CalendarRepository(database: database).upsertSource( accountId: 'account', @@ -191,22 +191,13 @@ void main() { endTimeZone: 'UTC', ); - await CalendarRepository( + final repository = CalendarRepository( database: database, localTimeZone: 'America/Vancouver', - ).updateLocalEvent( - EventEditorDraft.existing( - eventId: eventId, - accountId: 'account', - sourceId: 'account|google|cal-1', - providerCalendarId: 'cal-1', - title: 'Patched', - allDay: false, - start: DateTime(2026, 6, 8, 9), - end: DateTime(2026, 6, 8, 10), - startTimeZone: 'UTC', - endTimeZone: 'UTC', - ), + ); + final detail = await repository.loadEventDetail(eventId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith(title: 'Patched'), ); final event = await (database.select( @@ -218,113 +209,392 @@ void main() { .getSingle(); final request = jsonDecode(op.requestJson) as Map; - expect(event.startTimeZone, 'America/Vancouver'); - expect(event.endTimeZone, 'America/Vancouver'); - expect(request['startTimeZone'], 'America/Vancouver'); - expect(request['endTimeZone'], 'America/Vancouver'); + expect(event.startDateTime, '2026-06-08T09:00:00.000Z'); + expect(event.endDateTime, '2026-06-08T10:00:00.000Z'); + expect(event.startTimeZone, 'UTC'); + expect(event.endTimeZone, 'UTC'); + expect(request, { + 'title': 'Patched', + calendarEventGuestUpdatePolicyKey: 'send', + }); }, ); + test('same-account Google move uses the native provider operation', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-2', + summary: 'Personal', + timeZone: 'America/Vancouver', + ), + ); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ); + + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'account', + sourceId: 'account|google|cal-2', + providerCalendarId: 'cal-2', + title: 'Moved', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ); + + final queued = await (database.select( + database.pendingOps, + )..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)])).get(); + expect(queued.map((op) => op.operationType), ['event.move', 'event.patch']); + expect(queued.last.dependsOnOpId, queued.first.id); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 2); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'moveEvent:cal-1:provider-event:cal-2', + 'updateEvent:cal-2:provider-event:Moved', + ]); + expect( + client.guestUpdatePolicies, + everyElement(CalendarGuestUpdatePolicy.doNotSend), + ); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.providerCalendarId, 'cal-2'); + expect(event.calendarSourceId, 'account|google|cal-2'); + expect(event.title, 'Moved'); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('calendar-only Google move does not rewrite event fields', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-2', + summary: 'Personal', + timeZone: 'America/Vancouver', + ), + ); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ); + + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'account', + sourceId: 'account|google|cal-2', + providerCalendarId: 'cal-2', + title: 'Base', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + ); + + final queued = await database.select(database.pendingOps).get(); + expect(queued, hasLength(1)); + expect(queued.single.operationType, 'event.move'); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'moveEvent:cal-1:provider-event:cal-2', + ]); + }); + + test('native Google series move removes stale source occurrences', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-2', + summary: 'Personal', + timeZone: 'UTC', + ), + ); + await repository.upsertEvent( + accountId: 'account', + event: _googleSeriesMaster(), + ); + final occurrenceId = await _insertGoogleOccurrence(repository, day: 8); + await _insertGoogleOccurrence(repository, day: 15); + client.remoteEvent = _googleSeriesMaster(); + + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: occurrenceId, + providerRecurringEventId: 'series-master', + recurringMutationScope: RecurringEventMutationScope.entireSeries, + accountId: 'account', + sourceId: 'account|google|cal-2', + providerCalendarId: 'cal-2', + title: 'Base', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + ); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'event.move'); + expect( + (jsonDecode(operation.requestJson) + as Map)[calendarEventRecurringScopeKey], + RecurringEventMutationScope.entireSeries.name, + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, ['moveEvent:cal-1:series-master:cal-2']); + final events = await database.select(database.calendarEvents).get(); + expect( + events.where((event) => event.providerCalendarId == 'cal-1'), + isEmpty, + ); + expect(events, hasLength(1)); + expect(events.single.providerCalendarId, 'cal-2'); + expect(events.single.providerEventId, 'series-master'); + }); + test( - 'existing event identity cannot move between accounts or calendars', + 'cross-provider move queues copy before deleting the original', () async { - await database - .into(database.accounts) - .insert( - AccountsCompanion.insert( - id: 'microsoft-account', - provider: 'microsoft', - authority: 'https://login.microsoftonline.com/common', - providerAccountId: 'microsoft-account', - credentialKind: 'oauth', - authState: const Value('signed_in'), - grantedScopes: const Value(''), - createdAtUtc: '2026-06-08T00:00:00.000Z', - updatedAtUtc: '2026-06-08T00:00:00.000Z', - ), - ); - await CalendarRepository(database: database).upsertSource( - accountId: 'account', - source: const CalendarSourceDto( - provider: BusyProvider.google, - providerCalendarId: 'cal-2', - summary: 'Personal', - timeZone: 'America/Vancouver', - ), + await _insertMicrosoftAccountAndSource(database); + final repository = CalendarRepository(database: database); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', ); - await CalendarRepository(database: database).upsertSource( - accountId: 'microsoft-account', - source: const CalendarSourceDto( - provider: BusyProvider.microsoft, + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'microsoft-account', + sourceId: 'microsoft-account|microsoft|ms-cal-1', providerCalendarId: 'ms-cal-1', - summary: 'Outlook', - timeZone: 'America/Vancouver', + title: 'Copied meeting', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + attendees: const [ + EventAttendeeDraft(email: 'me@example.com', self: true), + EventAttendeeDraft( + email: 'guest@example.com', + responseStatus: 'accepted', + ), + ], + conference: const {'conferenceId': 'old-meeting'}, ), ); + + final operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (op) => op.operationType == 'event.create', + ); + final delete = operations.singleWhere( + (op) => op.operationType == 'event.delete', + ); + expect(create.accountId, 'microsoft-account'); + expect(delete.accountId, 'account'); + expect(delete.dependsOnOpId, create.id); + final createRequest = + jsonDecode(create.requestJson) as Map; + final attendees = createRequest[calendarEventAttendeesField] as List; + expect(attendees, hasLength(1)); + expect((attendees.single as Map)['emailAddress'], { + 'address': 'guest@example.com', + }); + expect(createRequest, isNot(contains('conferenceJson'))); + + final original = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(original.isDeleted, isFalse); + final copy = await (database.select( + database.calendarEvents, + )..where((row) => row.accountId.equals('microsoft-account'))).getSingle(); + expect(copy.title, 'Copied meeting'); + expect(copy.conferenceJson, equals(null)); + }, + ); + + test( + 'cross-provider single occurrence is copied without recurrence', + () async { + await _insertMicrosoftAccountAndSource(database); + final repository = CalendarRepository(database: database); final eventId = await _insertEvent( database, - providerEventId: 'provider-event', + providerEventId: 'provider-occurrence', + providerRecurringEventId: 'provider-series', ); - final repository = CalendarRepository(database: database); - final changedIdentities = - <({String accountId, String sourceId, String providerCalendarId})>[ - ( - accountId: 'other-account', - sourceId: 'account|google|cal-1', - providerCalendarId: 'cal-1', - ), - ( - accountId: 'account', - sourceId: 'account|google|cal-2', - providerCalendarId: 'cal-1', - ), - ( - accountId: 'account', - sourceId: 'account|google|cal-1', - providerCalendarId: 'cal-2', - ), - ( - accountId: 'account', - sourceId: 'account|google|cal-2', - providerCalendarId: 'cal-2', - ), - ( - accountId: 'microsoft-account', - sourceId: 'microsoft-account|microsoft|ms-cal-1', - providerCalendarId: 'ms-cal-1', - ), - ]; - - for (final identity in changedIdentities) { - await expectLater( - repository.updateLocalEvent( - EventEditorDraft.existing( - eventId: eventId, - accountId: identity.accountId, - sourceId: identity.sourceId, - providerCalendarId: identity.providerCalendarId, - title: 'Moved', - allDay: false, - start: DateTime.utc(2026, 6, 8, 9), - end: DateTime.utc(2026, 6, 8, 10), - ), - ), - throwsA(isA()), - ); - final event = await (database.select( - database.calendarEvents, - )..where((table) => table.id.equals(eventId))).getSingle(); - expect(event.accountId, 'account'); - expect(event.calendarSourceId, 'account|google|cal-1'); - expect(event.providerCalendarId, 'cal-1'); - expect(event.title, 'Base'); - expect(event.syncStatus, 'synced'); - expect(await database.select(database.pendingOps).get(), isEmpty); - } + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + providerRecurringEventId: 'provider-series', + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + accountId: 'microsoft-account', + sourceId: 'microsoft-account|microsoft|ms-cal-1', + providerCalendarId: 'ms-cal-1', + title: 'Copied occurrence', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + recurrence: const ['RRULE:FREQ=WEEKLY;BYHOUR=9'], + ), + ); + + final operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (op) => op.operationType == 'event.create', + ); + final delete = operations.singleWhere( + (op) => op.operationType == 'event.delete', + ); + final createRequest = + jsonDecode(create.requestJson) as Map; + final deleteRequest = + jsonDecode(delete.requestJson) as Map; + expect(createRequest[calendarEventRecurrenceField], equals(null)); + expect( + deleteRequest[calendarEventRecurringScopeKey], + RecurringEventMutationScope.singleOccurrence.name, + ); }, ); + test('cross-account move deletes only after the copy is confirmed', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'destination-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'destination-google-account', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'destination-account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'destination-cal', + summary: 'Destination', + timeZone: 'America/Vancouver', + ), + ); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + await repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'destination-account', + sourceId: 'destination-account|google|destination-cal', + providerCalendarId: 'destination-cal', + title: 'Cross-account copy', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ); + + final sourceReplayBeforeCopy = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + expect(sourceReplayBeforeCopy, 0); + expect(client.calls, isEmpty); + + final copied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'destination-account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + expect(copied, 1); + var deleteOperation = await (database.select( + database.pendingOps, + )..where((row) => row.operationType.equals('event.delete'))).getSingle(); + var deleteRequest = + jsonDecode(deleteOperation.requestJson) as Map; + expect(deleteRequest[calendarEventCopyConfirmedKey], isTrue); + expect( + deleteRequest[calendarEventCopyDestinationEventIdKey], + contains('server-event-1'), + ); + + client.calls.clear(); + final deleted = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + expect(deleted, 1); + expect(client.calls, [ + 'getEvent:cal-1:provider-event', + 'deleteEvent:cal-1:provider-event', + ]); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).isDeleted, + isTrue, + ); + }); + test('recurrence edits cannot target an individual occurrence', () async { final eventId = await _insertEvent( database, @@ -460,8 +730,8 @@ void main() { 'updateEvent:cal-1:provider-event:Patched', ]); expect(client.updatedMutations.single.title, 'Patched'); - expect(client.updatedMutations.single.startTimeZone, 'America/Vancouver'); - expect(client.updatedMutations.single.endTimeZone, 'America/Vancouver'); + expect(client.updatedMutations.single.startTimeZone, equals(null)); + expect(client.updatedMutations.single.endTimeZone, equals(null)); expect( await database.pendingOpsDao.pendingOpsForReplay('account', _later), isEmpty, @@ -469,6 +739,192 @@ void main() { }, ); + test('entire-series edit patches the recurring master', () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(ids[1]); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'Renamed series', + start: DateTime(2026, 6, 8, 11), + end: DateTime(2026, 6, 8, 12), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + client.remoteEvent = _googleSeriesMaster(); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:series-master', + 'updateEvent:cal-1:series-master:Renamed series', + ]); + expect(client.updatedMutations.single.title, 'Renamed series'); + expect( + client.updatedMutations.single.startDateTime, + '2026-06-01T11:00:00.000', + ); + expect( + client.updatedMutations.single.endDateTime, + '2026-06-01T12:00:00.000', + ); + expect(client.updatedMutations.single.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;COUNT=5', + ]); + final rows = await database.select(database.calendarEvents).get(); + expect(rows.map((row) => row.syncStatus), everyElement('synced')); + }); + + test( + 'entire-series retry does not apply the occurrence delta twice', + () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(ids[1]); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + start: DateTime(2026, 6, 8, 11), + end: DateTime(2026, 6, 8, 12), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + client + ..remoteEvent = _googleSeriesMaster() + ..transientUpdateFailures = 1; + + final firstApplied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + final secondApplied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + + expect(firstApplied, 0); + expect(secondApplied, 1); + expect(client.updatedMutations, hasLength(2)); + expect( + client.updatedMutations.map((mutation) => mutation.startDateTime), + everyElement('2026-06-01T11:00:00.000'), + ); + expect( + client.updatedMutations.map((mutation) => mutation.endDateTime), + everyElement('2026-06-01T12:00:00.000'), + ); + expect( + client.calls.where((call) => call.startsWith('getEvent:')), + hasLength(1), + ); + }, + ); + + test('Google this-and-following edit trims and splits the series', () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + final detail = await repository.loadEventDetail(ids[2]); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'New series title', + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + client + ..remoteEvent = _googleSeriesMaster() + ..eventInstances = [ + _googleSeriesInstance(day: 1), + _googleSeriesInstance(day: 8), + _googleSeriesInstance(day: 15), + ]; + final operation = await database.select(database.pendingOps).getSingle(); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:series-master', + 'listEventInstances:cal-1:series-master', + 'updateEvent:cal-1:series-master:null', + 'createEvent:cal-1:New series title', + ]); + expect(client.updatedMutations.single.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;' + 'UNTIL=20260615T085959Z', + ]); + final split = client.createdMutations.single; + expect(split.title, 'New series title'); + expect(split.startDateTime, '2026-06-15T09:00:00.000Z'); + expect(split.endDateTime, '2026-06-15T10:00:00.000Z'); + expect(split.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;COUNT=3', + ]); + expect(split.providerEventId, operation.id.replaceAll('-', '')); + expect(split.providerRaw?['id'], 'series-master'); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('Google this-and-following delete trims the old series', () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add(await _insertGoogleOccurrence(repository, day: day)); + } + await repository.deleteLocalEvent( + ids[1], + recurringScope: RecurringEventMutationScope.thisAndFuture, + ); + client.remoteEvent = _googleSeriesMaster(); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, [ + 'getEvent:cal-1:series-master', + 'updateEvent:cal-1:series-master:null', + ]); + expect(client.updatedMutations.single.recurrence, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=MO;' + 'UNTIL=20260608T085959Z', + ]); + final rows = + await (database.select(database.calendarEvents)..orderBy([ + (row) => OrderingTerm.asc(row.providerOriginalStartKey), + ])) + .get(); + expect(rows.map((row) => row.isDeleted), [false, true, true]); + expect(rows.map((row) => row.syncStatus), everyElement('synced')); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + test( 'back-to-back local event patches replay in order without self-conflict', () async { @@ -632,6 +1088,33 @@ void main() { expect(row.isDeleted, isTrue); }); + test('entire-series delete marks the master and every occurrence', () async { + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: _googleSeriesMaster(), + ); + final occurrenceId = await _insertGoogleOccurrence(repository, day: 8); + await _insertGoogleOccurrence(repository, day: 15); + + await repository.deleteLocalEvent( + occurrenceId, + recurringScope: RecurringEventMutationScope.entireSeries, + ); + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(client.calls, ['deleteEvent:cal-1:series-master']); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(3)); + expect(rows.map((row) => row.isDeleted), everyElement(isTrue)); + expect(rows.map((row) => row.syncStatus), everyElement('synced')); + }); + test('provider missing delete is treated as success', () async { client.deleteError = const GoogleCalendarApiError( statusCode: 404, @@ -716,6 +1199,58 @@ void main() { ); }); + test( + 'calendar create remaps dependent event work to the server id', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final temporarySourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Project', + ); + final temporarySource = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(temporarySourceId))).getSingle(); + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: temporarySourceId, + providerCalendarId: temporarySource.providerCalendarId, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 2); + expect(client.calls, [ + 'createCalendar:Project', + 'createEvent:cal-created:Planning', + ]); + final sources = await database.select(database.calendarSources).get(); + expect( + sources.map((source) => source.providerCalendarId), + containsAll(['cal-1', 'cal-created']), + ); + expect(sources.any((source) => source.id == temporarySourceId), isFalse); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.providerCalendarId, 'cal-created'); + expect(event.calendarSourceId, 'account|google|cal-created'); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + }, + ); + test('calendar delete pending op calls provider deleteCalendar', () async { await database.pendingOpsDao.enqueue( PendingOpsCompanion.insert( @@ -948,6 +1483,33 @@ Future _insertAccount(AppDatabase database) { ); } +Future _insertMicrosoftAccountAndSource(AppDatabase database) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft-account', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'microsoft-account', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + await CalendarRepository(database: database).upsertSource( + accountId: 'microsoft-account', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'ms-cal-1', + summary: 'Outlook', + timeZone: 'America/Vancouver', + ), + ); +} + Future _insertEvent( AppDatabase database, { required String providerEventId, @@ -961,6 +1523,7 @@ Future _insertEvent( providerEventId: providerEventId, providerRecurringEventId: providerRecurringEventId, title: 'Base', + organizerJson: const {'self': true}, startDateTime: '2026-06-08T09:00:00.000Z', startTimeZone: startTimeZone, endDateTime: '2026-06-08T10:00:00.000Z', @@ -983,6 +1546,102 @@ Future _insertEvent( ); } +Future _insertGoogleOccurrence( + CalendarRepository repository, { + required int day, +}) async { + final date = day.toString().padLeft(2, '0'); + final start = '2026-06-${date}T09:00:00.000Z'; + final end = '2026-06-${date}T10:00:00.000Z'; + final providerEventId = 'occurrence-$date'; + await repository.upsertEvent( + accountId: 'account', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: providerEventId, + providerRecurringEventId: 'series-master', + providerOriginalStartKey: start, + title: 'Base', + organizerJson: const {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + updatedAtServer: '2026-05-30T00:00:00.000Z', + rawJson: { + 'id': providerEventId, + 'summary': 'Base', + 'recurringEventId': 'series-master', + 'originalStartTime': {'dateTime': start}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + 'updated': '2026-05-30T00:00:00.000Z', + }, + ), + ); + return CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: providerEventId, + providerOriginalStartKey: start, + ); +} + +CalendarEventDto _googleSeriesMaster() { + const start = '2026-06-01T09:00:00.000Z'; + const end = '2026-06-01T10:00:00.000Z'; + const recurrence = ['RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;COUNT=5']; + return const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'series-master', + title: 'Base', + organizerJson: {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + recurrenceJson: recurrence, + updatedAtServer: '2026-05-30T00:00:00.000Z', + rawJson: { + 'id': 'series-master', + 'summary': 'Base', + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + 'recurrence': recurrence, + 'updated': '2026-05-30T00:00:00.000Z', + }, + ); +} + +CalendarEventDto _googleSeriesInstance({required int day}) { + final date = day.toString().padLeft(2, '0'); + final start = '2026-06-${date}T09:00:00.000Z'; + final end = '2026-06-${date}T10:00:00.000Z'; + return CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'instance-$date', + providerRecurringEventId: 'series-master', + providerOriginalStartKey: start, + title: 'Base', + organizerJson: const {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + rawJson: { + 'id': 'instance-$date', + 'recurringEventId': 'series-master', + 'originalStartTime': {'dateTime': start}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + }, + ); +} + Future _enqueueEventOp( AppDatabase database, { required String id, @@ -1029,6 +1688,7 @@ class _FakeCalendarClient implements CloudCalendarClient { int transientUpdateFailures = 0; CalendarEventDto? syncEvent; CalendarEventDto? remoteEvent; + List eventInstances = const []; bool persistEventUpdates = false; int _eventUpdateRevision = 0; GoogleCalendarApiError? deleteError; @@ -1066,6 +1726,7 @@ class _FakeCalendarClient implements CloudCalendarClient { return _event( 'server-event-$_createdCount', title: mutation.title ?? '', + providerCalendarId: calendarId, startTimeZone: mutation.startTimeZone, endTimeZone: mutation.endTimeZone, ); @@ -1110,6 +1771,7 @@ class _FakeCalendarClient implements CloudCalendarClient { : '2026-06-08T00:00:00.000Z', startTimeZone: mutation.startTimeZone, endTimeZone: mutation.endTimeZone, + providerCalendarId: calendarId, ); if (persistEventUpdates) { remoteEvent = event; @@ -1141,6 +1803,23 @@ class _FakeCalendarClient implements CloudCalendarClient { } } + @override + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }) async { + calls.add('moveEvent:$sourceCalendarId:$eventId:$destinationCalendarId'); + guestUpdatePolicies.add(guestUpdatePolicy); + return _event( + eventId, + title: remoteEvent?.title ?? 'Base', + providerCalendarId: destinationCalendarId, + ); + } + @override Future respondToEvent({ required String calendarId, @@ -1175,6 +1854,7 @@ class _FakeCalendarClient implements CloudCalendarClient { CalendarEventDto _event( String id, { required String title, + String providerCalendarId = 'cal-1', String? location, String? etagOrChangeKey, String updatedAtServer = '2026-06-08T00:00:00.000Z', @@ -1183,7 +1863,7 @@ class _FakeCalendarClient implements CloudCalendarClient { }) { return CalendarEventDto( provider: BusyProvider.google, - providerCalendarId: 'cal-1', + providerCalendarId: providerCalendarId, providerEventId: id, etagOrChangeKey: etagOrChangeKey, title: title, @@ -1233,8 +1913,9 @@ class _FakeCalendarClient implements CloudCalendarClient { required String recurringEventId, required DateTime rangeStart, required DateTime rangeEnd, - }) { - throw UnimplementedError(); + }) async { + calls.add('listEventInstances:$calendarId:$recurringEventId'); + return eventInstances; } @override diff --git a/test/features/sync/calendar_sync_engine_test.dart b/test/features/sync/calendar_sync_engine_test.dart index d36295e7..d7ed8c9b 100644 --- a/test/features/sync/calendar_sync_engine_test.dart +++ b/test/features/sync/calendar_sync_engine_test.dart @@ -697,6 +697,17 @@ class _FakeCalendarClient implements CloudCalendarClient { throw UnimplementedError(); } + @override + Future moveEvent({ + required String sourceCalendarId, + required String eventId, + required String destinationCalendarId, + CalendarGuestUpdatePolicy guestUpdatePolicy = + CalendarGuestUpdatePolicy.send, + }) { + throw UnimplementedError(); + } + @override Future getEvent({ required String calendarId, diff --git a/test/google_calendar/google_calendar_api_client_test.dart b/test/google_calendar/google_calendar_api_client_test.dart index eef4a8cb..4ded417b 100644 --- a/test/google_calendar/google_calendar_api_client_test.dart +++ b/test/google_calendar/google_calendar_api_client_test.dart @@ -13,6 +13,58 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + test('calendar RGB color updates the Google CalendarList entry', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'id': 'calendar@example.com', + 'summary': 'Work', + 'backgroundColor': '#3584e4', + 'foregroundColor': '#ffffff', + 'accessRole': 'owner', + }); + }); + + final source = await client.updateCalendar( + 'calendar@example.com', + const CalendarMutation( + backgroundColor: '#3584e4', + foregroundColor: '#ffffff', + ), + ); + + expect(captured.method, 'PATCH'); + expect( + captured.url.path, + '/calendar/v3/users/me/calendarList/calendar%40example.com', + ); + expect(captured.url.queryParameters, {'colorRgbFormat': 'true'}); + expect(jsonDecode(captured.body), { + 'backgroundColor': '#3584e4', + 'foregroundColor': '#ffffff', + }); + expect(source.backgroundColor, '#3584e4'); + }); + + test('calendar rename updates the calendar resource', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({'id': 'calendar@example.com', 'summary': 'Renamed'}); + }); + + await client.updateCalendar( + 'calendar@example.com', + const CalendarMutation(summary: 'Renamed'), + ); + + expect(captured.method, 'PATCH'); + expect(captured.url.path, '/calendar/v3/calendars/calendar%40example.com'); + expect(captured.url.queryParameters, isEmpty); + expect(jsonDecode(captured.body), {'summary': 'Renamed'}); + }); + test('event mutations always send an explicit guest update policy', () async { final requests = []; final client = _client((request) { @@ -42,6 +94,32 @@ void main() { expect(requests[2].url.queryParameters['sendUpdates'], 'none'); expect(requests[0].url.queryParameters['conferenceDataVersion'], '1'); expect(requests[1].url.queryParameters['conferenceDataVersion'], '1'); + expect(jsonDecode(requests[1].body), {'summary': 'Updated'}); + }); + + test('native event move sends destination and guest update policy', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json(_googleEventJson()); + }); + + await client.moveEvent( + sourceCalendarId: 'source@example.com', + eventId: 'event-1', + destinationCalendarId: 'destination@example.com', + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ); + + expect(captured.method, 'POST'); + expect( + captured.url.path, + '/calendar/v3/calendars/source%40example.com/events/event-1/move', + ); + expect(captured.url.queryParameters, { + 'destination': 'destination@example.com', + 'sendUpdates': 'none', + }); }); test('Google RSVP updates only the signed-in attendee response', () async { diff --git a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart index 7095f232..21028306 100644 --- a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart @@ -7,6 +7,31 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + test('calendar update sends the documented Microsoft color enum', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'id': 'cal-1', + 'name': 'Work', + 'color': 'lightBlue', + 'hexColor': '#0078D4', + 'canEdit': true, + }); + }); + + final source = await client.updateCalendar( + 'cal-1', + const CalendarMutation(colorId: 'lightBlue'), + ); + + expect(captured.method, 'PATCH'); + expect(captured.url.path, '/v1.0/me/calendars/cal-1'); + expect(jsonDecode(captured.body), {'color': 'lightBlue'}); + expect(source.colorId, 'lightBlue'); + expect(source.backgroundColor, '#0078D4'); + }); + test( 'event create, update, and delete use Microsoft Graph endpoints', () async { From d31d2bf6d4b48319441b946f8085e85800fbd102 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 10:34:41 -0700 Subject: [PATCH 03/69] feat: enhance calendar management with attendee controls, metadata support, and removal modes --- .../calendar_providers/calendar_mutation.dart | 4 + .../calendar_providers/calendar_sync_dto.dart | 4 + .../cloud_calendar_client.dart | 11 + lib/src/db/migrations.dart | 19 +- lib/src/db/tables.dart | 2 + .../calendar/data/calendar_event_detail.dart | 3 + .../calendar/data/calendar_repository.dart | 230 +++++++++++++++--- .../calendar/presentation/event_editor.dart | 5 +- .../presentation/event_editor_draft.dart | 14 +- .../google_calendar_api_client.dart | 29 ++- .../google_calendar_mapper.dart | 9 +- .../microsoft_calendar_mapper.dart | 1 + 12 files changed, 282 insertions(+), 49 deletions(-) diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index e1cbfeb6..5d4ee79f 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -16,6 +16,10 @@ class CalendarMutation { final String? colorId; } +const calendarMutationScopeKey = '_calendarMutationScope'; +const calendarMutationScopeGlobal = 'global'; +const calendarMutationScopePersonal = 'personal'; + const calendarEventClearFieldsKey = '_clearFields'; const calendarEventRecurrenceField = 'recurrenceJson'; const calendarEventAttendeesField = 'attendeesJson'; diff --git a/lib/src/calendar_providers/calendar_sync_dto.dart b/lib/src/calendar_providers/calendar_sync_dto.dart index 911ed3cf..f442c855 100644 --- a/lib/src/calendar_providers/calendar_sync_dto.dart +++ b/lib/src/calendar_providers/calendar_sync_dto.dart @@ -15,6 +15,8 @@ class CalendarSourceDto { this.colorId, this.timeZone, this.accessRole, + this.dataOwner, + this.isRemovable, this.isDeleted = false, this.rawJson = const {}, }); @@ -32,6 +34,8 @@ class CalendarSourceDto { final String? colorId; final String? timeZone; final String? accessRole; + final String? dataOwner; + final bool? isRemovable; final bool isDeleted; final Map rawJson; } diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index 98c5d1bc..d6974ea6 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -86,3 +86,14 @@ abstract interface class CloudCalendarClient { required DateTime rangeEnd, }); } + +/// User-specific calendar-list operations supported by providers that expose +/// calendar metadata separately from the signed-in user's personalization. +abstract interface class CalendarListManagementClient { + Future updateCalendarListEntry( + String calendarId, + CalendarMutation mutation, + ); + + Future deleteCalendarListEntry(String calendarId); +} diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index d55627df..a7119f82 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; -const latestSchemaVersion = 9; +const latestSchemaVersion = 10; /// A recoverable, non-secret diagnostic raised when an on-disk schema cannot /// be migrated without guessing remote identity or losing synchronized data. @@ -51,6 +51,9 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { if (from < 9) { await _migrateToV9(migrator, database); } + if (from < 10) { + await _migrateToV10(migrator, database); + } await _createIndexes(database); await _verifyForeignKeys(database); }, @@ -60,6 +63,20 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { ); } +Future _migrateToV10(Migrator migrator, AppDatabase database) async { + for (final column in [ + database.calendarSources.dataOwner, + database.calendarSources.isRemovable, + ]) { + await _addColumnIfMissing( + migrator, + database, + database.calendarSources, + column, + ); + } +} + Future _migrateToV9(Migrator migrator, AppDatabase database) async { await _addColumnIfMissing( migrator, diff --git a/lib/src/db/tables.dart b/lib/src/db/tables.dart index fab1abeb..0e62c7da 100644 --- a/lib/src/db/tables.dart +++ b/lib/src/db/tables.dart @@ -403,6 +403,8 @@ class CalendarSources extends Table { TextColumn get colorId => text().nullable()(); TextColumn get timeZone => text().nullable()(); TextColumn get accessRole => text().nullable()(); + TextColumn get dataOwner => text().nullable()(); + BoolColumn get isRemovable => boolean().nullable()(); BoolColumn get isDeleted => boolean().withDefault(const Constant(false))(); TextColumn get rawJson => text().nullable()(); IntColumn get createdAtLocal => integer()(); diff --git a/lib/src/features/calendar/data/calendar_event_detail.dart b/lib/src/features/calendar/data/calendar_event_detail.dart index 867dae7c..53e0e90b 100644 --- a/lib/src/features/calendar/data/calendar_event_detail.dart +++ b/lib/src/features/calendar/data/calendar_event_detail.dart @@ -44,6 +44,7 @@ final class CalendarEventDetail { required this.organizer, required this.creator, required this.guestsCanModify, + required this.guestsCanInviteOthers, required this.locked, required this.colorId, required this.colorHex, @@ -102,6 +103,7 @@ final class CalendarEventDetail { organizer: _decodeJson(row.organizerJson), creator: _decodeJson(row.creatorJson), guestsCanModify: rawMap['guestsCanModify'] == true, + guestsCanInviteOthers: rawMap['guestsCanInviteOthers'] != false, locked: rawMap['locked'] == true, colorId: row.colorId, colorHex: row.colorHex, @@ -157,6 +159,7 @@ final class CalendarEventDetail { final Object? organizer; final Object? creator; final bool guestsCanModify; + final bool guestsCanInviteOthers; final bool locked; final String? colorId; final String? colorHex; diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index f4ebebb3..f1ed44f9 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -41,11 +41,17 @@ class CalendarSourceEntity { this.colorId, this.timeZone, this.accessRole, + this.dataOwner, + this.isRemovable, + this.authenticatedAccountEmail, this.davCollectionId, this.allowedConferenceSolutions = const [], }); - factory CalendarSourceEntity.fromRow(CalendarSource row) { + factory CalendarSourceEntity.fromRow( + CalendarSource row, { + String? authenticatedAccountEmail, + }) { return CalendarSourceEntity( id: row.id, accountId: row.accountId, @@ -64,6 +70,9 @@ class CalendarSourceEntity { colorId: row.colorId, timeZone: row.timeZone, accessRole: row.accessRole, + dataOwner: row.dataOwner, + isRemovable: row.isRemovable, + authenticatedAccountEmail: authenticatedAccountEmail, davCollectionId: row.davCollectionId, allowedConferenceSolutions: _conferenceSolutions(row.rawJson), ); @@ -86,13 +95,31 @@ class CalendarSourceEntity { final String? colorId; final String? timeZone; final String? accessRole; + final String? dataOwner; + final bool? isRemovable; + final String? authenticatedAccountEmail; final String? davCollectionId; final List allowedConferenceSolutions; CalendarSourceCapabilities get capabilities => CalendarSourceCapabilities.fromSource(this); + + bool get isCurrentGoogleDataOwner { + if (provider != BusyProvider.google) return false; + final owner = dataOwner?.trim().toLowerCase(); + final identity = authenticatedAccountEmail?.trim().toLowerCase(); + return owner != null && + owner.isNotEmpty && + identity != null && + identity.isNotEmpty && + owner == identity; + } } +enum CalendarRenameMode { unavailable, global, personal } + +enum CalendarRemovalMode { unavailable, delete, removeFromList } + /// The event operations currently permitted by a calendar source. /// /// Keeping this policy with the source model gives every presentation surface @@ -107,19 +134,55 @@ class CalendarSourceCapabilities { required this.canRenameCalendar, required this.canDeleteCalendar, required this.canChangeCalendarColor, + required this.renameMode, + required this.removalMode, }); factory CalendarSourceCapabilities.fromSource(CalendarSourceEntity source) { - final writable = !source.readOnly && !source.isDeleted; + final available = !source.isDeleted; + final writable = !source.readOnly && available; final management = calendarManagementCapabilities(source.provider); + final renameMode = switch (source.provider) { + BusyProvider.google when available => + source.primaryCalendar || source.isCurrentGoogleDataOwner + ? CalendarRenameMode.global + : CalendarRenameMode.personal, + BusyProvider.microsoft when writable => CalendarRenameMode.global, + _ => CalendarRenameMode.unavailable, + }; + final removalMode = switch (source.provider) { + BusyProvider.google + when available && + !source.primaryCalendar && + source.isCurrentGoogleDataOwner => + CalendarRemovalMode.delete, + BusyProvider.google + when available && + !source.primaryCalendar && + source.dataOwner?.trim().isNotEmpty == true => + CalendarRemovalMode.removeFromList, + BusyProvider.microsoft + when available && + !source.primaryCalendar && + source.isRemovable == true => + CalendarRemovalMode.delete, + _ => CalendarRemovalMode.unavailable, + }; return CalendarSourceCapabilities( canCreateEvents: writable, canEditEvents: writable, canDeleteEvents: writable, - canRenameCalendar: writable && management.supportsRename, + canRenameCalendar: + management.supportsRename && + renameMode != CalendarRenameMode.unavailable, canDeleteCalendar: - writable && management.supportsDelete && !source.primaryCalendar, - canChangeCalendarColor: writable && management.supportsColor, + management.supportsDelete && + removalMode == CalendarRemovalMode.delete, + canChangeCalendarColor: + management.supportsColor && + (source.provider == BusyProvider.google ? available : writable), + renameMode: renameMode, + removalMode: removalMode, ); } @@ -130,6 +193,8 @@ class CalendarSourceCapabilities { canRenameCalendar: false, canDeleteCalendar: false, canChangeCalendarColor: false, + renameMode: CalendarRenameMode.unavailable, + removalMode: CalendarRemovalMode.unavailable, ); final bool canCreateEvents; @@ -138,6 +203,11 @@ class CalendarSourceCapabilities { final bool canRenameCalendar; final bool canDeleteCalendar; final bool canChangeCalendarColor; + final CalendarRenameMode renameMode; + final CalendarRemovalMode removalMode; + + bool get canRemoveCalendar => + removalMode != CalendarRemovalMode.unavailable; } enum CalendarMutationOperation { @@ -148,6 +218,7 @@ enum CalendarMutationOperation { renameCalendar, changeCalendarColor, deleteCalendar, + removeCalendar, } class CalendarMutationNotAllowed implements Exception { @@ -196,16 +267,30 @@ class CalendarRepository { if (accountIds.isEmpty) { return Stream.value(const []); } - final query = _database.select(_database.calendarSources) - ..where( - (row) => row.accountId.isIn(accountIds) & row.isDeleted.equals(false), - ) - ..orderBy([ - (row) => OrderingTerm.asc(row.accountId), - (row) => OrderingTerm.asc(row.summary), - ]); + final query = _database.select(_database.calendarSources).join([ + innerJoin( + _database.accounts, + _database.accounts.id.equalsExp(_database.calendarSources.accountId), + ), + ]); + query.where( + _database.calendarSources.accountId.isIn(accountIds) & + _database.calendarSources.isDeleted.equals(false), + ); + query.orderBy([ + OrderingTerm.asc(_database.calendarSources.accountId), + OrderingTerm.asc(_database.calendarSources.summary), + ]); return query.watch().map( - (rows) => rows.map(CalendarSourceEntity.fromRow).toList(), + (rows) => [ + for (final result in rows) + CalendarSourceEntity.fromRow( + result.readTable(_database.calendarSources), + authenticatedAccountEmail: result + .readTable(_database.accounts) + .email, + ), + ], ); } @@ -215,16 +300,26 @@ class CalendarRepository { if (accountIds.isEmpty) { return const []; } - final query = _database.select(_database.calendarSources) - ..where( - (row) => - row.accountId.isIn(accountIds) & - row.selected.equals(true) & - row.hidden.equals(false) & - row.isDeleted.equals(false), - ); + final query = _database.select(_database.calendarSources).join([ + innerJoin( + _database.accounts, + _database.accounts.id.equalsExp(_database.calendarSources.accountId), + ), + ]); + query.where( + _database.calendarSources.accountId.isIn(accountIds) & + _database.calendarSources.selected.equals(true) & + _database.calendarSources.hidden.equals(false) & + _database.calendarSources.isDeleted.equals(false), + ); final rows = await query.get(); - return rows.map(CalendarSourceEntity.fromRow).toList(); + return [ + for (final result in rows) + CalendarSourceEntity.fromRow( + result.readTable(_database.calendarSources), + authenticatedAccountEmail: result.readTable(_database.accounts).email, + ), + ]; } Future loadEventDetail(String eventId) async { @@ -234,6 +329,16 @@ class CalendarRepository { return row == null ? null : CalendarEventDetail.fromRow(row); } + Future _sourceEntity(CalendarSource source) async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(source.accountId))).getSingle(); + return CalendarSourceEntity.fromRow( + source, + authenticatedAccountEmail: account.email, + ); + } + Future setSourceSelected(String sourceId, bool selected) async { await (_database.update( _database.calendarSources, @@ -337,12 +442,11 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); + final entity = await _sourceEntity(source); _requireCalendarSourceCapability( source, operation: CalendarMutationOperation.renameCalendar, - allowed: CalendarSourceEntity.fromRow( - source, - ).capabilities.canRenameCalendar, + allowed: entity.capabilities.canRenameCalendar, ); final title = summary.trim(); if (title.isEmpty) { @@ -373,7 +477,13 @@ class CalendarRepository { } else { await _enqueueOrMergeCalendarPatch( source, - request: {'summary': title}, + request: { + 'summary': title, + calendarMutationScopeKey: + entity.capabilities.renameMode == CalendarRenameMode.personal + ? calendarMutationScopePersonal + : calendarMutationScopeGlobal, + }, nowUtc: nowUtc, ); } @@ -387,7 +497,7 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); - final entity = CalendarSourceEntity.fromRow(source); + final entity = await _sourceEntity(source); _requireCalendarSourceCapability( source, operation: CalendarMutationOperation.changeCalendarColor, @@ -415,6 +525,7 @@ class CalendarRepository { BusyProvider.google => { 'backgroundColor': choice.backgroundColor, 'foregroundColor': foregroundColor, + calendarMutationScopeKey: calendarMutationScopePersonal, }, BusyProvider.microsoft => { 'colorId': choice.providerValue, @@ -453,12 +564,14 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).getSingle(); + final entity = await _sourceEntity(source); + final removalMode = entity.capabilities.removalMode; _requireCalendarSourceCapability( source, - operation: CalendarMutationOperation.deleteCalendar, - allowed: CalendarSourceEntity.fromRow( - source, - ).capabilities.canDeleteCalendar, + operation: removalMode == CalendarRemovalMode.removeFromList + ? CalendarMutationOperation.removeCalendar + : CalendarMutationOperation.deleteCalendar, + allowed: entity.capabilities.canRemoveCalendar, ); final now = _now(); final nowUtc = now.toUtc().toIso8601String(); @@ -491,8 +604,14 @@ class CalendarRepository { accountId: source.accountId, provider: Value(source.provider), entityType: 'calendar', - operation: 'delete', - operationType: const Value('calendar.delete'), + operation: removalMode == CalendarRemovalMode.removeFromList + ? 'remove' + : 'delete', + operationType: Value( + removalMode == CalendarRemovalMode.removeFromList + ? 'calendar.remove' + : 'calendar.delete', + ), calendarSourceId: Value(source.id), providerCalendarId: Value(source.providerCalendarId), requestJson: '{}', @@ -543,6 +662,8 @@ class CalendarRepository { colorId: Value(source.colorId), timeZone: Value(source.timeZone), accessRole: Value(source.accessRole), + dataOwner: Value(source.dataOwner), + isRemovable: Value(source.isRemovable), isDeleted: Value(isDeleted), rawJson: Value(jsonEncode(source.rawJson)), createdAtLocal: existing?.createdAtLocal ?? now, @@ -862,6 +983,7 @@ class CalendarRepository { _database.calendarSources, )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); _requireFullEventEditingAllowed(existing); + _requireAttendeeManagementAllowed(existing, draft); final editBaseline = _eventEditBaseline(draft, existing); _requireWritableSource( source, @@ -2640,7 +2762,10 @@ class CalendarRepository { required String nowUtc, String? dependsOnOpId, }) async { - final existing = + final requestedScope = + request[calendarMutationScopeKey]?.toString() ?? + calendarMutationScopeGlobal; + final candidates = await (_database.select(_database.pendingOps) ..where( (row) => @@ -2649,9 +2774,20 @@ class CalendarRepository { row.state.equals('pending') & row.attemptCount.equals(0), ) - ..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)]) - ..limit(1)) - .getSingleOrNull(); + ..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)])) + .get(); + PendingOp? existing; + for (final candidate in candidates) { + final candidateScope = + _calendarPendingRequest( + candidate, + )[calendarMutationScopeKey]?.toString() ?? + calendarMutationScopeGlobal; + if (candidateScope == requestedScope) { + existing = candidate; + break; + } + } if (existing != null) { final merged = _calendarPendingRequest(existing)..addAll(request); await (_database.update( @@ -2852,6 +2988,24 @@ void _requireFullEventEditingAllowed(CalendarEvent event) { ); } +void _requireAttendeeManagementAllowed( + CalendarEvent event, + EventEditorDraft draft, +) { + if (!draft.attendeesChanged) return; + final provider = BusyProviderCodec.requireStorageValue(event.provider); + if (provider != BusyProvider.google) return; + final raw = _jsonMap(event.rawJson); + final organizer = _jsonMap(event.organizerJson); + final allowed = + organizer['self'] == true || raw['guestsCanInviteOthers'] != false; + if (allowed) return; + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: event.calendarSourceId, + ); +} + void _requireCalendarSourceCapability( CalendarSource source, { required CalendarMutationOperation operation, diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 72ec1d9d..869cbe68 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -542,7 +542,10 @@ class _EventEditorState extends State { BusyMaxGroupedList( title: l10n.guests, filled: true, - children: _guestRows(readOnly: schedulingReadOnly), + children: _guestRows( + readOnly: + schedulingReadOnly || !_draft.canManageAttendees, + ), ), if ((provider == BusyProvider.google || provider == BusyProvider.microsoft) && diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart index 4373a54b..cf1651ed 100644 --- a/lib/src/features/calendar/presentation/event_editor_draft.dart +++ b/lib/src/features/calendar/presentation/event_editor_draft.dart @@ -137,6 +137,7 @@ class EventEditorDraft { this.hideAttendees, this.allowNewTimeProposals, this.isOrganizer, + this.canManageAttendees = true, }); factory EventEditorDraft.newEvent({ @@ -226,6 +227,10 @@ class EventEditorDraft { hideAttendees: hideAttendees, allowNewTimeProposals: _jsonBool(raw['allowNewTimeProposals']), isOrganizer: isOrganizer, + canManageAttendees: + detail.provider != BusyProvider.google || + isOrganizer == true || + detail.guestsCanInviteOthers, ); } @@ -262,6 +267,7 @@ class EventEditorDraft { bool? hideAttendees, bool? allowNewTimeProposals, bool? isOrganizer, + bool canManageAttendees = true, }) { return EventEditorDraft( eventId: eventId, @@ -296,6 +302,7 @@ class EventEditorDraft { hideAttendees: hideAttendees, allowNewTimeProposals: allowNewTimeProposals, isOrganizer: isOrganizer, + canManageAttendees: canManageAttendees, ); } @@ -343,6 +350,7 @@ class EventEditorDraft { final bool? hideAttendees; final bool? allowNewTimeProposals; final bool? isOrganizer; + final bool canManageAttendees; bool get canSave { final start = this.start; @@ -390,6 +398,7 @@ class EventEditorDraft { bool? hideAttendees, bool? allowNewTimeProposals, bool? isOrganizer, + bool? canManageAttendees, RecurringEventMutationScope? recurringMutationScope, bool clearLocation = false, bool clearDescription = false, @@ -454,6 +463,7 @@ class EventEditorDraft { allowNewTimeProposals: allowNewTimeProposals ?? this.allowNewTimeProposals, isOrganizer: isOrganizer ?? this.isOrganizer, + canManageAttendees: canManageAttendees ?? this.canManageAttendees, ); } @@ -495,7 +505,8 @@ class EventEditorDraft { other.responseRequested == responseRequested && other.hideAttendees == hideAttendees && other.allowNewTimeProposals == allowNewTimeProposals && - other.isOrganizer == isOrganizer; + other.isOrganizer == isOrganizer && + other.canManageAttendees == canManageAttendees; } @override @@ -536,6 +547,7 @@ class EventEditorDraft { hideAttendees, allowNewTimeProposals, isOrganizer, + canManageAttendees, ]); } diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index e431ec67..f71b36a0 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -11,7 +11,8 @@ import 'google_calendar_errors.dart'; import 'google_calendar_mapper.dart'; import 'google_calendar_models.dart'; -class GoogleCalendarApiClient implements CloudCalendarClient { +class GoogleCalendarApiClient + implements CloudCalendarClient, CalendarListManagementClient { GoogleCalendarApiClient({ required http.Client httpClient, required Uri baseUri, @@ -85,22 +86,31 @@ class GoogleCalendarApiClient implements CloudCalendarClient { String calendarId, CalendarMutation mutation, ) async { - Map? calendarJson; + var updatedGlobalMetadata = false; final calendarBody = googleCalendarMutationToJson(mutation); if (calendarBody.isNotEmpty) { - calendarJson = await _requestJson( + await _requestJson( 'PATCH', _uri('/calendar/v3/calendars/${_enc(calendarId)}'), body: calendarBody, ); + updatedGlobalMetadata = true; } if (_hasCalendarListColor(mutation)) { return _updateCalendarListColor(calendarId, mutation); } - if (calendarJson == null) { + if (!updatedGlobalMetadata) { throw ArgumentError('Calendar update has no writable fields.'); } - return googleCalendarSourceFromJson(calendarJson); + return _getCalendarListEntry(calendarId); + } + + Future _getCalendarListEntry(String calendarId) async { + final json = await _requestJson( + 'GET', + _uri('/calendar/v3/users/me/calendarList/${_enc(calendarId)}'), + ); + return googleCalendarSourceFromJson(json); } Future _updateCalendarListColor( @@ -120,13 +130,19 @@ class GoogleCalendarApiClient implements CloudCalendarClient { return googleCalendarSourceFromJson(json); } + @override Future updateCalendarListEntry( String calendarId, CalendarMutation mutation, ) async { + final usesRgb = + mutation.backgroundColor != null || mutation.foregroundColor != null; final json = await _requestJson( 'PATCH', - _uri('/calendar/v3/users/me/calendarList/${_enc(calendarId)}'), + _uri( + '/calendar/v3/users/me/calendarList/${_enc(calendarId)}', + query: usesRgb ? const {'colorRgbFormat': 'true'} : null, + ), body: googleCalendarListMutationToJson(mutation), ); return googleCalendarSourceFromJson(json); @@ -141,6 +157,7 @@ class GoogleCalendarApiClient implements CloudCalendarClient { return googleCalendarSourceFromJson(json); } + @override Future deleteCalendarListEntry(String calendarId) { return _requestEmpty( 'DELETE', diff --git a/lib/src/google_calendar/google_calendar_mapper.dart b/lib/src/google_calendar/google_calendar_mapper.dart index 587cad8f..023cb920 100644 --- a/lib/src/google_calendar/google_calendar_mapper.dart +++ b/lib/src/google_calendar/google_calendar_mapper.dart @@ -6,11 +6,15 @@ import 'package:busymax/src/providers/busy_provider.dart'; CalendarSourceDto googleCalendarSourceFromJson(Map json) { final accessRole = json['accessRole']?.toString(); + final summaryOverride = json['summaryOverride']?.toString().trim(); + final providerSummary = json['summary']?.toString().trim(); return CalendarSourceDto( provider: BusyProvider.google, providerCalendarId: json['id']?.toString() ?? '', - summary: json['summary']?.toString().trim().isNotEmpty == true - ? json['summary']!.toString() + summary: summaryOverride?.isNotEmpty == true + ? summaryOverride! + : providerSummary?.isNotEmpty == true + ? providerSummary! : 'Calendar', description: json['description']?.toString(), primaryCalendar: json['primary'] == true, @@ -22,6 +26,7 @@ CalendarSourceDto googleCalendarSourceFromJson(Map json) { colorId: json['colorId']?.toString(), timeZone: json['timeZone']?.toString(), accessRole: accessRole, + dataOwner: json['dataOwner']?.toString(), isDeleted: json['deleted'] == true, rawJson: json, ); diff --git a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart index 729c4180..563985de 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart @@ -23,6 +23,7 @@ CalendarSourceDto microsoftCalendarSourceFromJson(Map json) { ), colorId: json['color']?.toString(), accessRole: canEdit is bool && canEdit ? 'writer' : 'reader', + isRemovable: json['isRemovable'] as bool?, isDeleted: json['@removed'] != null, rawJson: json, ); From c75c0c17b71e0adc368459e9ab69dffc28584523 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 11:04:13 -0700 Subject: [PATCH 04/69] fix: complete calendar management permissions --- lib/l10n/app_ar.arb | 6 + lib/l10n/app_de.arb | 6 + lib/l10n/app_en.arb | 7 + lib/l10n/app_es.arb | 6 + lib/l10n/app_et.arb | 6 + lib/l10n/app_fa.arb | 6 + lib/l10n/app_fi.arb | 6 + lib/l10n/app_fr.arb | 6 + lib/l10n/app_hi.arb | 6 + lib/l10n/app_it.arb | 6 + lib/l10n/app_ja.arb | 6 + lib/l10n/app_ko.arb | 6 + lib/l10n/app_pt.arb | 6 + lib/l10n/app_ru.arb | 6 + lib/l10n/app_vi.arb | 6 + lib/l10n/app_zh.arb | 6 + lib/l10n/app_zh_Hans.arb | 6 + lib/l10n/app_zh_Hant.arb | 6 + lib/l10n/generated/app_localizations.dart | 36 ++++ lib/l10n/generated/app_localizations_ar.dart | 21 +++ lib/l10n/generated/app_localizations_de.dart | 21 +++ lib/l10n/generated/app_localizations_en.dart | 21 +++ lib/l10n/generated/app_localizations_es.dart | 21 +++ lib/l10n/generated/app_localizations_et.dart | 21 +++ lib/l10n/generated/app_localizations_fa.dart | 21 +++ lib/l10n/generated/app_localizations_fi.dart | 21 +++ lib/l10n/generated/app_localizations_fr.dart | 21 +++ lib/l10n/generated/app_localizations_hi.dart | 21 +++ lib/l10n/generated/app_localizations_it.dart | 21 +++ lib/l10n/generated/app_localizations_ja.dart | 20 +++ lib/l10n/generated/app_localizations_ko.dart | 20 +++ lib/l10n/generated/app_localizations_pt.dart | 21 +++ lib/l10n/generated/app_localizations_ru.dart | 21 +++ lib/l10n/generated/app_localizations_vi.dart | 20 +++ lib/l10n/generated/app_localizations_zh.dart | 60 +++++++ lib/src/db/app_database.g.dart | 146 ++++++++++++++++ .../calendar/data/calendar_repository.dart | 19 ++- .../calendar/presentation/event_editor.dart | 3 +- .../presentation/schedule_sidebar.dart | 37 +++-- .../sync/calendar_pending_ops_replayer.dart | 157 +++++++++++++++++- test/db/app_database_test.dart | 72 +++++++- .../data/calendar_repository_test.dart | 134 +++++++++++++++ ...calendar_management_capabilities_test.dart | 82 +++++++++ .../presentation/event_editor_test.dart | 44 +++++ .../calendar_pending_ops_replayer_test.dart | 94 ++++++++++- .../google_calendar_api_client_test.dart | 68 +++++++- .../google_calendar_mapper_test.dart | 14 ++ .../microsoft_calendar_mapper_test.dart | 2 + 48 files changed, 1353 insertions(+), 36 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 3ab41fcd..31891ab2 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "تعيين اسم مخصص", + "setAction": "تعيين", + "removeFromMyCalendars": "إزالة من تقاويمي", + "removeAction": "إزالة", + "removeCalendarConfirmation": "هل تريد إزالة \"{title}\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", + "calendarCannotRemove": "لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.", "networkOffline": "غير متصل", "networkOfflineDescription": "ستتم مزامنة التغييرات عند استعادة الاتصال.", "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى." diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1401d24c..b9239712 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -694,6 +694,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Benutzerdefinierten Namen festlegen", + "setAction": "Festlegen", + "removeFromMyCalendars": "Aus meinen Kalendern entfernen", + "removeAction": "Entfernen", + "removeCalendarConfirmation": "„{title}“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.", + "calendarCannotRemove": "Dieser Kalender kann in diesem Konto weder gelöscht noch entfernt werden.", "networkOffline": "Offline", "networkOfflineDescription": "Änderungen werden synchronisiert, sobald die Verbindung wiederhergestellt ist.", "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut." diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 664a7669..9450ddea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -651,6 +651,13 @@ "taskStartDueTimeModeMismatch": "Set times for both start and due, or make the task all day.", "deleteCalendarConfirmation": "Delete \"{title}\"?", "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "setCustomCalendarName": "Set custom name", + "setAction": "Set", + "removeFromMyCalendars": "Remove from my calendars", + "removeAction": "Remove", + "removeCalendarConfirmation": "Remove \"{title}\" from your Google Calendar list? The shared calendar and its events will not be deleted.", + "@removeCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "calendarCannotRemove": "This calendar cannot be deleted or removed from this account.", "networkOffline": "Offline", "networkOfflineDescription": "Changes will sync when the connection is restored.", "networkOfflineTryAgain": "You’re offline. Connect to the internet and try again." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1433f929..6dbe5ff7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -694,6 +694,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Establecer nombre personalizado", + "setAction": "Establecer", + "removeFromMyCalendars": "Quitar de mis calendarios", + "removeAction": "Quitar", + "removeCalendarConfirmation": "¿Quitar \"{title}\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.", + "calendarCannotRemove": "Este calendario no se puede eliminar ni quitar de esta cuenta.", "networkOffline": "Sin conexión", "networkOfflineDescription": "Los cambios se sincronizarán cuando se restablezca la conexión.", "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo." diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index f16f9118..da959455 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -809,6 +809,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Määra kohandatud nimi", + "setAction": "Määra", + "removeFromMyCalendars": "Eemalda minu kalendritest", + "removeAction": "Eemalda", + "removeCalendarConfirmation": "Kas eemaldada „{title}” sinu Google'i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", + "calendarCannotRemove": "Seda kalendrit ei saa sellelt kontolt kustutada ega eemaldada.", "networkOffline": "Võrguühenduseta", "networkOfflineDescription": "Muudatused sünkroonitakse ühenduse taastumisel.", "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti." diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index cfa7cb46..ac070afe 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -695,6 +695,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "تنظیم نام سفارشی", + "setAction": "تنظیم", + "removeFromMyCalendars": "حذف از تقویم‌های من", + "removeAction": "حذف", + "removeCalendarConfirmation": "«{title}» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", + "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف کرد.", "networkOffline": "آفلاین", "networkOfflineDescription": "تغییرات پس از برقراری دوبارهٔ اتصال همگام‌سازی می‌شوند.", "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید." diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index d6f1e5d3..899e401f 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Aseta mukautettu nimi", + "setAction": "Aseta", + "removeFromMyCalendars": "Poista omista kalentereistani", + "removeAction": "Poista", + "removeCalendarConfirmation": "Poistetaanko ”{title}” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.", + "calendarCannotRemove": "Tätä kalenteria ei voi poistaa tältä tililtä.", "networkOffline": "Ei verkkoyhteyttä", "networkOfflineDescription": "Muutokset synkronoidaan, kun yhteys palautuu.", "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index de3b2713..7ce0b282 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -694,6 +694,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Définir un nom personnalisé", + "setAction": "Définir", + "removeFromMyCalendars": "Retirer de mes agendas", + "removeAction": "Retirer", + "removeCalendarConfirmation": "Retirer « {title} » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.", + "calendarCannotRemove": "Cet agenda ne peut pas être supprimé ni retiré de ce compte.", "networkOffline": "Hors ligne", "networkOfflineDescription": "Les modifications seront synchronisées au rétablissement de la connexion.", "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 84b676fb..6af29a48 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "कस्टम नाम सेट करें", + "setAction": "सेट करें", + "removeFromMyCalendars": "मेरे कैलेंडर से हटाएँ", + "removeAction": "हटाएँ", + "removeCalendarConfirmation": "क्या \"{title}\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।", + "calendarCannotRemove": "इस कैलेंडर को इस खाते से मिटाया या हटाया नहीं जा सकता।", "networkOffline": "ऑफ़लाइन", "networkOfflineDescription": "कनेक्शन वापस आने पर बदलाव सिंक हो जाएंगे।", "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।" diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a4b38f8b..e75eb6a6 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Imposta nome personalizzato", + "setAction": "Imposta", + "removeFromMyCalendars": "Rimuovi dai miei calendari", + "removeAction": "Rimuovi", + "removeCalendarConfirmation": "Rimuovere \"{title}\" dall'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.", + "calendarCannotRemove": "Questo calendario non può essere eliminato o rimosso da questo account.", "networkOffline": "Offline", "networkOfflineDescription": "Le modifiche verranno sincronizzate al ripristino della connessione.", "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova." diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 38bf41d2..91c8f573 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "カスタム名を設定", + "setAction": "設定", + "removeFromMyCalendars": "マイカレンダーから削除", + "removeAction": "削除", + "removeCalendarConfirmation": "「{title}」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。", + "calendarCannotRemove": "このカレンダーは、このアカウントから削除できません。", "networkOffline": "オフライン", "networkOfflineDescription": "接続が復旧すると変更が同期されます。", "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 98faa292..2f5d5ee9 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "맞춤 이름 설정", + "setAction": "설정", + "removeFromMyCalendars": "내 캘린더에서 삭제", + "removeAction": "삭제", + "removeCalendarConfirmation": "Google Calendar 목록에서 \"{title}\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.", + "calendarCannotRemove": "이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.", "networkOffline": "오프라인", "networkOfflineDescription": "연결이 복구되면 변경 사항이 동기화됩니다.", "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b709e425..8db86411 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Definir nome personalizado", + "setAction": "Definir", + "removeFromMyCalendars": "Remover dos meus calendários", + "removeAction": "Remover", + "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Agenda? A agenda compartilhada e os eventos dela não serão excluídos.", + "calendarCannotRemove": "Não é possível excluir nem remover esta agenda desta conta.", "networkOffline": "Offline", "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente." diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 253154cd..02e79d17 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Задать своё название", + "setAction": "Задать", + "removeFromMyCalendars": "Удалить из моих календарей", + "removeAction": "Удалить", + "removeCalendarConfirmation": "Удалить «{title}» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.", + "calendarCannotRemove": "Этот календарь нельзя удалить из этой учётной записи.", "networkOffline": "Нет подключения", "networkOfflineDescription": "Изменения будут синхронизированы после восстановления подключения.", "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку." diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 62acb283..2a8bfc42 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "Đặt tên tùy chỉnh", + "setAction": "Đặt", + "removeFromMyCalendars": "Xóa khỏi lịch của tôi", + "removeAction": "Xóa", + "removeCalendarConfirmation": "Xóa “{title}” khỏi danh sách Google Calendar của bạn? Lịch dùng chung và các sự kiện trong đó sẽ không bị xóa.", + "calendarCannotRemove": "Không thể xóa lịch này khỏi tài khoản.", "networkOffline": "Ngoại tuyến", "networkOfflineDescription": "Các thay đổi sẽ được đồng bộ khi kết nối được khôi phục.", "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a25a4845..3101aafa 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "设置自定义名称", + "setAction": "设置", + "removeFromMyCalendars": "从“我的日历”中移除", + "removeAction": "移除", + "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", + "calendarCannotRemove": "无法从此账号中删除或移除此日历。", "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index b4e50ac3..680c89a1 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "设置自定义名称", + "setAction": "设置", + "removeFromMyCalendars": "从“我的日历”中移除", + "removeAction": "移除", + "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", + "calendarCannotRemove": "无法从此账号中删除或移除此日历。", "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 3f62626d..075d9b66 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -631,6 +631,12 @@ "invitationResponseFailed": "Could not send your response: {error}", "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, "joinMeetingFailed": "Could not open the meeting link.", + "setCustomCalendarName": "設定自訂名稱", + "setAction": "設定", + "removeFromMyCalendars": "從「我的日曆」移除", + "removeAction": "移除", + "removeCalendarConfirmation": "要從您的 Google 日曆清單移除「{title}」嗎?共用日曆及其活動不會被刪除。", + "calendarCannotRemove": "無法從此帳戶刪除或移除此日曆。", "networkOffline": "離線", "networkOfflineDescription": "連線恢復後將同步變更。", "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。" diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 19ec96c3..e81b31d7 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3636,6 +3636,42 @@ abstract class AppLocalizations { /// **'Delete \"{title}\"?'** String deleteCalendarConfirmation(String title); + /// No description provided for @setCustomCalendarName. + /// + /// In en, this message translates to: + /// **'Set custom name'** + String get setCustomCalendarName; + + /// No description provided for @setAction. + /// + /// In en, this message translates to: + /// **'Set'** + String get setAction; + + /// No description provided for @removeFromMyCalendars. + /// + /// In en, this message translates to: + /// **'Remove from my calendars'** + String get removeFromMyCalendars; + + /// No description provided for @removeAction. + /// + /// In en, this message translates to: + /// **'Remove'** + String get removeAction; + + /// No description provided for @removeCalendarConfirmation. + /// + /// In en, this message translates to: + /// **'Remove \"{title}\" from your Google Calendar list? The shared calendar and its events will not be deleted.'** + String removeCalendarConfirmation(String title); + + /// No description provided for @calendarCannotRemove. + /// + /// In en, this message translates to: + /// **'This calendar cannot be deleted or removed from this account.'** + String get calendarCannotRemove; + /// No description provided for @networkOffline. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 3159ace6..38623d9c 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1997,6 +1997,27 @@ class AppLocalizationsAr extends AppLocalizations { return 'حذف «⁨$title⁩»؟'; } + @override + String get setCustomCalendarName => 'تعيين اسم مخصص'; + + @override + String get setAction => 'تعيين'; + + @override + String get removeFromMyCalendars => 'إزالة من تقاويمي'; + + @override + String get removeAction => 'إزالة'; + + @override + String removeCalendarConfirmation(String title) { + return 'هل تريد إزالة \"$title\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.'; + } + + @override + String get calendarCannotRemove => + 'لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.'; + @override String get networkOffline => 'غير متصل'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index e523da3c..16949597 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1998,6 +1998,27 @@ class AppLocalizationsDe extends AppLocalizations { return '\"$title\" löschen?'; } + @override + String get setCustomCalendarName => 'Benutzerdefinierten Namen festlegen'; + + @override + String get setAction => 'Festlegen'; + + @override + String get removeFromMyCalendars => 'Aus meinen Kalendern entfernen'; + + @override + String get removeAction => 'Entfernen'; + + @override + String removeCalendarConfirmation(String title) { + return '„$title“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.'; + } + + @override + String get calendarCannotRemove => + 'Dieser Kalender kann in diesem Konto weder gelöscht noch entfernt werden.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 34bbd588..525f8e60 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1979,6 +1979,27 @@ class AppLocalizationsEn extends AppLocalizations { return 'Delete \"$title\"?'; } + @override + String get setCustomCalendarName => 'Set custom name'; + + @override + String get setAction => 'Set'; + + @override + String get removeFromMyCalendars => 'Remove from my calendars'; + + @override + String get removeAction => 'Remove'; + + @override + String removeCalendarConfirmation(String title) { + return 'Remove \"$title\" from your Google Calendar list? The shared calendar and its events will not be deleted.'; + } + + @override + String get calendarCannotRemove => + 'This calendar cannot be deleted or removed from this account.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index ff235abe..c8f85a84 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1998,6 +1998,27 @@ class AppLocalizationsEs extends AppLocalizations { return '¿Eliminar \"$title\"?'; } + @override + String get setCustomCalendarName => 'Establecer nombre personalizado'; + + @override + String get setAction => 'Establecer'; + + @override + String get removeFromMyCalendars => 'Quitar de mis calendarios'; + + @override + String get removeAction => 'Quitar'; + + @override + String removeCalendarConfirmation(String title) { + return '¿Quitar \"$title\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.'; + } + + @override + String get calendarCannotRemove => + 'Este calendario no se puede eliminar ni quitar de esta cuenta.'; + @override String get networkOffline => 'Sin conexión'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 235e4471..d2844bd4 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1986,6 +1986,27 @@ class AppLocalizationsEt extends AppLocalizations { return 'Kas kustutada „$title”?'; } + @override + String get setCustomCalendarName => 'Määra kohandatud nimi'; + + @override + String get setAction => 'Määra'; + + @override + String get removeFromMyCalendars => 'Eemalda minu kalendritest'; + + @override + String get removeAction => 'Eemalda'; + + @override + String removeCalendarConfirmation(String title) { + return 'Kas eemaldada „$title” sinu Google\'i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.'; + } + + @override + String get calendarCannotRemove => + 'Seda kalendrit ei saa sellelt kontolt kustutada ega eemaldada.'; + @override String get networkOffline => 'Võrguühenduseta'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 678b5e4a..89b2c8ae 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2020,6 +2020,27 @@ class AppLocalizationsFa extends AppLocalizations { return '«⁨$title⁩» حذف شود؟'; } + @override + String get setCustomCalendarName => 'تنظیم نام سفارشی'; + + @override + String get setAction => 'تنظیم'; + + @override + String get removeFromMyCalendars => 'حذف از تقویم‌های من'; + + @override + String get removeAction => 'حذف'; + + @override + String removeCalendarConfirmation(String title) { + return '«$title» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.'; + } + + @override + String get calendarCannotRemove => + 'این تقویم را نمی‌توان از این حساب حذف کرد.'; + @override String get networkOffline => 'آفلاین'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 5df4214e..dfc70f0f 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1992,6 +1992,27 @@ class AppLocalizationsFi extends AppLocalizations { return 'Poistetaanko \"$title\"?'; } + @override + String get setCustomCalendarName => 'Aseta mukautettu nimi'; + + @override + String get setAction => 'Aseta'; + + @override + String get removeFromMyCalendars => 'Poista omista kalentereistani'; + + @override + String get removeAction => 'Poista'; + + @override + String removeCalendarConfirmation(String title) { + return 'Poistetaanko ”$title” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.'; + } + + @override + String get calendarCannotRemove => + 'Tätä kalenteria ei voi poistaa tältä tililtä.'; + @override String get networkOffline => 'Ei verkkoyhteyttä'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 4c73efaf..5fa21a48 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1996,6 +1996,27 @@ class AppLocalizationsFr extends AppLocalizations { return 'Supprimer « $title » ?'; } + @override + String get setCustomCalendarName => 'Définir un nom personnalisé'; + + @override + String get setAction => 'Définir'; + + @override + String get removeFromMyCalendars => 'Retirer de mes agendas'; + + @override + String get removeAction => 'Retirer'; + + @override + String removeCalendarConfirmation(String title) { + return 'Retirer « $title » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.'; + } + + @override + String get calendarCannotRemove => + 'Cet agenda ne peut pas être supprimé ni retiré de ce compte.'; + @override String get networkOffline => 'Hors ligne'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 0c349852..8a7537dc 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1986,6 +1986,27 @@ class AppLocalizationsHi extends AppLocalizations { return '“$title” मिटाएँ?'; } + @override + String get setCustomCalendarName => 'कस्टम नाम सेट करें'; + + @override + String get setAction => 'सेट करें'; + + @override + String get removeFromMyCalendars => 'मेरे कैलेंडर से हटाएँ'; + + @override + String get removeAction => 'हटाएँ'; + + @override + String removeCalendarConfirmation(String title) { + return 'क्या \"$title\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।'; + } + + @override + String get calendarCannotRemove => + 'इस कैलेंडर को इस खाते से मिटाया या हटाया नहीं जा सकता।'; + @override String get networkOffline => 'ऑफ़लाइन'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index bb8697fe..cb0e2d82 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1998,6 +1998,27 @@ class AppLocalizationsIt extends AppLocalizations { return 'Eliminare «$title»?'; } + @override + String get setCustomCalendarName => 'Imposta nome personalizzato'; + + @override + String get setAction => 'Imposta'; + + @override + String get removeFromMyCalendars => 'Rimuovi dai miei calendari'; + + @override + String get removeAction => 'Rimuovi'; + + @override + String removeCalendarConfirmation(String title) { + return 'Rimuovere \"$title\" dall\'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.'; + } + + @override + String get calendarCannotRemove => + 'Questo calendario non può essere eliminato o rimosso da questo account.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index a23746dc..f3d753e9 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1958,6 +1958,26 @@ class AppLocalizationsJa extends AppLocalizations { return '「$title」を削除しますか?'; } + @override + String get setCustomCalendarName => 'カスタム名を設定'; + + @override + String get setAction => '設定'; + + @override + String get removeFromMyCalendars => 'マイカレンダーから削除'; + + @override + String get removeAction => '削除'; + + @override + String removeCalendarConfirmation(String title) { + return '「$title」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。'; + } + + @override + String get calendarCannotRemove => 'このカレンダーは、このアカウントから削除できません。'; + @override String get networkOffline => 'オフライン'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 1985eb23..e9e03241 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1959,6 +1959,26 @@ class AppLocalizationsKo extends AppLocalizations { return '“$title” 캘린더를 삭제할까요?'; } + @override + String get setCustomCalendarName => '맞춤 이름 설정'; + + @override + String get setAction => '설정'; + + @override + String get removeFromMyCalendars => '내 캘린더에서 삭제'; + + @override + String get removeAction => '삭제'; + + @override + String removeCalendarConfirmation(String title) { + return 'Google Calendar 목록에서 \"$title\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.'; + } + + @override + String get calendarCannotRemove => '이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.'; + @override String get networkOffline => '오프라인'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index d59ac4a8..b493aa2c 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1996,6 +1996,27 @@ class AppLocalizationsPt extends AppLocalizations { return 'Eliminar «$title»?'; } + @override + String get setCustomCalendarName => 'Definir nome personalizado'; + + @override + String get setAction => 'Definir'; + + @override + String get removeFromMyCalendars => 'Remover dos meus calendários'; + + @override + String get removeAction => 'Remover'; + + @override + String removeCalendarConfirmation(String title) { + return 'Remover \"$title\" da sua lista do Google Agenda? A agenda compartilhada e os eventos dela não serão excluídos.'; + } + + @override + String get calendarCannotRemove => + 'Não é possível excluir nem remover esta agenda desta conta.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 47129b6b..b6c6265d 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1999,6 +1999,27 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить «$title»?'; } + @override + String get setCustomCalendarName => 'Задать своё название'; + + @override + String get setAction => 'Задать'; + + @override + String get removeFromMyCalendars => 'Удалить из моих календарей'; + + @override + String get removeAction => 'Удалить'; + + @override + String removeCalendarConfirmation(String title) { + return 'Удалить «$title» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.'; + } + + @override + String get calendarCannotRemove => + 'Этот календарь нельзя удалить из этой учётной записи.'; + @override String get networkOffline => 'Нет подключения'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 5933b9de..2198f829 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1986,6 +1986,26 @@ class AppLocalizationsVi extends AppLocalizations { return 'Xóa “$title”?'; } + @override + String get setCustomCalendarName => 'Đặt tên tùy chỉnh'; + + @override + String get setAction => 'Đặt'; + + @override + String get removeFromMyCalendars => 'Xóa khỏi lịch của tôi'; + + @override + String get removeAction => 'Xóa'; + + @override + String removeCalendarConfirmation(String title) { + return 'Xóa “$title” khỏi danh sách Google Calendar của bạn? Lịch dùng chung và các sự kiện trong đó sẽ không bị xóa.'; + } + + @override + String get calendarCannotRemove => 'Không thể xóa lịch này khỏi tài khoản.'; + @override String get networkOffline => 'Ngoại tuyến'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index f1d60d67..b981e4d1 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1946,6 +1946,26 @@ class AppLocalizationsZh extends AppLocalizations { return '删除“$title”?'; } + @override + String get setCustomCalendarName => '设置自定义名称'; + + @override + String get setAction => '设置'; + + @override + String get removeFromMyCalendars => '从“我的日历”中移除'; + + @override + String get removeAction => '移除'; + + @override + String removeCalendarConfirmation(String title) { + return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其活动不会被删除。'; + } + + @override + String get calendarCannotRemove => '无法从此账号中删除或移除此日历。'; + @override String get networkOffline => '离线'; @@ -3894,6 +3914,26 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '删除“$title”?'; } + @override + String get setCustomCalendarName => '设置自定义名称'; + + @override + String get setAction => '设置'; + + @override + String get removeFromMyCalendars => '从“我的日历”中移除'; + + @override + String get removeAction => '移除'; + + @override + String removeCalendarConfirmation(String title) { + return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其活动不会被删除。'; + } + + @override + String get calendarCannotRemove => '无法从此账号中删除或移除此日历。'; + @override String get networkOffline => '离线'; @@ -5843,6 +5883,26 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { return '要刪除「$title」嗎?'; } + @override + String get setCustomCalendarName => '設定自訂名稱'; + + @override + String get setAction => '設定'; + + @override + String get removeFromMyCalendars => '從「我的日曆」移除'; + + @override + String get removeAction => '移除'; + + @override + String removeCalendarConfirmation(String title) { + return '要從您的 Google 日曆清單移除「$title」嗎?共用日曆及其活動不會被刪除。'; + } + + @override + String get calendarCannotRemove => '無法從此帳戶刪除或移除此日曆。'; + @override String get networkOffline => '離線'; diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart index 21dce602..da3ecf88 100644 --- a/lib/src/db/app_database.g.dart +++ b/lib/src/db/app_database.g.dart @@ -14984,6 +14984,31 @@ class $CalendarSourcesTable extends CalendarSources type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _dataOwnerMeta = const VerificationMeta( + 'dataOwner', + ); + @override + late final GeneratedColumn dataOwner = GeneratedColumn( + 'data_owner', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _isRemovableMeta = const VerificationMeta( + 'isRemovable', + ); + @override + late final GeneratedColumn isRemovable = GeneratedColumn( + 'is_removable', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_removable" IN (0, 1))', + ), + ); static const VerificationMeta _isDeletedMeta = const VerificationMeta( 'isDeleted', ); @@ -15051,6 +15076,8 @@ class $CalendarSourcesTable extends CalendarSources colorId, timeZone, accessRole, + dataOwner, + isRemovable, isDeleted, rawJson, createdAtLocal, @@ -15198,6 +15225,21 @@ class $CalendarSourcesTable extends CalendarSources accessRole.isAcceptableOrUnknown(data['access_role']!, _accessRoleMeta), ); } + if (data.containsKey('data_owner')) { + context.handle( + _dataOwnerMeta, + dataOwner.isAcceptableOrUnknown(data['data_owner']!, _dataOwnerMeta), + ); + } + if (data.containsKey('is_removable')) { + context.handle( + _isRemovableMeta, + isRemovable.isAcceptableOrUnknown( + data['is_removable']!, + _isRemovableMeta, + ), + ); + } if (data.containsKey('is_deleted')) { context.handle( _isDeletedMeta, @@ -15309,6 +15351,14 @@ class $CalendarSourcesTable extends CalendarSources DriftSqlType.string, data['${effectivePrefix}access_role'], ), + dataOwner: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}data_owner'], + ), + isRemovable: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_removable'], + ), isDeleted: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}is_deleted'], @@ -15352,6 +15402,8 @@ class CalendarSource extends DataClass implements Insertable { final String? colorId; final String? timeZone; final String? accessRole; + final String? dataOwner; + final bool? isRemovable; final bool isDeleted; final String? rawJson; final int createdAtLocal; @@ -15374,6 +15426,8 @@ class CalendarSource extends DataClass implements Insertable { this.colorId, this.timeZone, this.accessRole, + this.dataOwner, + this.isRemovable, required this.isDeleted, this.rawJson, required this.createdAtLocal, @@ -15413,6 +15467,12 @@ class CalendarSource extends DataClass implements Insertable { if (!nullToAbsent || accessRole != null) { map['access_role'] = Variable(accessRole); } + if (!nullToAbsent || dataOwner != null) { + map['data_owner'] = Variable(dataOwner); + } + if (!nullToAbsent || isRemovable != null) { + map['is_removable'] = Variable(isRemovable); + } map['is_deleted'] = Variable(isDeleted); if (!nullToAbsent || rawJson != null) { map['raw_json'] = Variable(rawJson); @@ -15455,6 +15515,12 @@ class CalendarSource extends DataClass implements Insertable { accessRole: accessRole == null && nullToAbsent ? const Value.absent() : Value(accessRole), + dataOwner: dataOwner == null && nullToAbsent + ? const Value.absent() + : Value(dataOwner), + isRemovable: isRemovable == null && nullToAbsent + ? const Value.absent() + : Value(isRemovable), isDeleted: Value(isDeleted), rawJson: rawJson == null && nullToAbsent ? const Value.absent() @@ -15489,6 +15555,8 @@ class CalendarSource extends DataClass implements Insertable { colorId: serializer.fromJson(json['colorId']), timeZone: serializer.fromJson(json['timeZone']), accessRole: serializer.fromJson(json['accessRole']), + dataOwner: serializer.fromJson(json['dataOwner']), + isRemovable: serializer.fromJson(json['isRemovable']), isDeleted: serializer.fromJson(json['isDeleted']), rawJson: serializer.fromJson(json['rawJson']), createdAtLocal: serializer.fromJson(json['createdAtLocal']), @@ -15516,6 +15584,8 @@ class CalendarSource extends DataClass implements Insertable { 'colorId': serializer.toJson(colorId), 'timeZone': serializer.toJson(timeZone), 'accessRole': serializer.toJson(accessRole), + 'dataOwner': serializer.toJson(dataOwner), + 'isRemovable': serializer.toJson(isRemovable), 'isDeleted': serializer.toJson(isDeleted), 'rawJson': serializer.toJson(rawJson), 'createdAtLocal': serializer.toJson(createdAtLocal), @@ -15541,6 +15611,8 @@ class CalendarSource extends DataClass implements Insertable { Value colorId = const Value.absent(), Value timeZone = const Value.absent(), Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), bool? isDeleted, Value rawJson = const Value.absent(), int? createdAtLocal, @@ -15569,6 +15641,8 @@ class CalendarSource extends DataClass implements Insertable { colorId: colorId.present ? colorId.value : this.colorId, timeZone: timeZone.present ? timeZone.value : this.timeZone, accessRole: accessRole.present ? accessRole.value : this.accessRole, + dataOwner: dataOwner.present ? dataOwner.value : this.dataOwner, + isRemovable: isRemovable.present ? isRemovable.value : this.isRemovable, isDeleted: isDeleted ?? this.isDeleted, rawJson: rawJson.present ? rawJson.value : this.rawJson, createdAtLocal: createdAtLocal ?? this.createdAtLocal, @@ -15609,6 +15683,10 @@ class CalendarSource extends DataClass implements Insertable { accessRole: data.accessRole.present ? data.accessRole.value : this.accessRole, + dataOwner: data.dataOwner.present ? data.dataOwner.value : this.dataOwner, + isRemovable: data.isRemovable.present + ? data.isRemovable.value + : this.isRemovable, isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted, rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, createdAtLocal: data.createdAtLocal.present @@ -15640,6 +15718,8 @@ class CalendarSource extends DataClass implements Insertable { ..write('colorId: $colorId, ') ..write('timeZone: $timeZone, ') ..write('accessRole: $accessRole, ') + ..write('dataOwner: $dataOwner, ') + ..write('isRemovable: $isRemovable, ') ..write('isDeleted: $isDeleted, ') ..write('rawJson: $rawJson, ') ..write('createdAtLocal: $createdAtLocal, ') @@ -15667,6 +15747,8 @@ class CalendarSource extends DataClass implements Insertable { colorId, timeZone, accessRole, + dataOwner, + isRemovable, isDeleted, rawJson, createdAtLocal, @@ -15693,6 +15775,8 @@ class CalendarSource extends DataClass implements Insertable { other.colorId == this.colorId && other.timeZone == this.timeZone && other.accessRole == this.accessRole && + other.dataOwner == this.dataOwner && + other.isRemovable == this.isRemovable && other.isDeleted == this.isDeleted && other.rawJson == this.rawJson && other.createdAtLocal == this.createdAtLocal && @@ -15717,6 +15801,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { final Value colorId; final Value timeZone; final Value accessRole; + final Value dataOwner; + final Value isRemovable; final Value isDeleted; final Value rawJson; final Value createdAtLocal; @@ -15740,6 +15826,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { this.colorId = const Value.absent(), this.timeZone = const Value.absent(), this.accessRole = const Value.absent(), + this.dataOwner = const Value.absent(), + this.isRemovable = const Value.absent(), this.isDeleted = const Value.absent(), this.rawJson = const Value.absent(), this.createdAtLocal = const Value.absent(), @@ -15764,6 +15852,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { this.colorId = const Value.absent(), this.timeZone = const Value.absent(), this.accessRole = const Value.absent(), + this.dataOwner = const Value.absent(), + this.isRemovable = const Value.absent(), this.isDeleted = const Value.absent(), this.rawJson = const Value.absent(), required int createdAtLocal, @@ -15794,6 +15884,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { Expression? colorId, Expression? timeZone, Expression? accessRole, + Expression? dataOwner, + Expression? isRemovable, Expression? isDeleted, Expression? rawJson, Expression? createdAtLocal, @@ -15819,6 +15911,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { if (colorId != null) 'color_id': colorId, if (timeZone != null) 'time_zone': timeZone, if (accessRole != null) 'access_role': accessRole, + if (dataOwner != null) 'data_owner': dataOwner, + if (isRemovable != null) 'is_removable': isRemovable, if (isDeleted != null) 'is_deleted': isDeleted, if (rawJson != null) 'raw_json': rawJson, if (createdAtLocal != null) 'created_at_local': createdAtLocal, @@ -15845,6 +15939,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { Value? colorId, Value? timeZone, Value? accessRole, + Value? dataOwner, + Value? isRemovable, Value? isDeleted, Value? rawJson, Value? createdAtLocal, @@ -15869,6 +15965,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { colorId: colorId ?? this.colorId, timeZone: timeZone ?? this.timeZone, accessRole: accessRole ?? this.accessRole, + dataOwner: dataOwner ?? this.dataOwner, + isRemovable: isRemovable ?? this.isRemovable, isDeleted: isDeleted ?? this.isDeleted, rawJson: rawJson ?? this.rawJson, createdAtLocal: createdAtLocal ?? this.createdAtLocal, @@ -15931,6 +16029,12 @@ class CalendarSourcesCompanion extends UpdateCompanion { if (accessRole.present) { map['access_role'] = Variable(accessRole.value); } + if (dataOwner.present) { + map['data_owner'] = Variable(dataOwner.value); + } + if (isRemovable.present) { + map['is_removable'] = Variable(isRemovable.value); + } if (isDeleted.present) { map['is_deleted'] = Variable(isDeleted.value); } @@ -15969,6 +16073,8 @@ class CalendarSourcesCompanion extends UpdateCompanion { ..write('colorId: $colorId, ') ..write('timeZone: $timeZone, ') ..write('accessRole: $accessRole, ') + ..write('dataOwner: $dataOwner, ') + ..write('isRemovable: $isRemovable, ') ..write('isDeleted: $isDeleted, ') ..write('rawJson: $rawJson, ') ..write('createdAtLocal: $createdAtLocal, ') @@ -34055,6 +34161,8 @@ typedef $$CalendarSourcesTableCreateCompanionBuilder = Value colorId, Value timeZone, Value accessRole, + Value dataOwner, + Value isRemovable, Value isDeleted, Value rawJson, required int createdAtLocal, @@ -34080,6 +34188,8 @@ typedef $$CalendarSourcesTableUpdateCompanionBuilder = Value colorId, Value timeZone, Value accessRole, + Value dataOwner, + Value isRemovable, Value isDeleted, Value rawJson, Value createdAtLocal, @@ -34264,6 +34374,16 @@ class $$CalendarSourcesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get dataOwner => $composableBuilder( + column: $table.dataOwner, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get isDeleted => $composableBuilder( column: $table.isDeleted, builder: (column) => ColumnFilters(column), @@ -34465,6 +34585,16 @@ class $$CalendarSourcesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get dataOwner => $composableBuilder( + column: $table.dataOwner, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get isDeleted => $composableBuilder( column: $table.isDeleted, builder: (column) => ColumnOrderings(column), @@ -34600,6 +34730,14 @@ class $$CalendarSourcesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get dataOwner => + $composableBuilder(column: $table.dataOwner, builder: (column) => column); + + GeneratedColumn get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => column, + ); + GeneratedColumn get isDeleted => $composableBuilder(column: $table.isDeleted, builder: (column) => column); @@ -34765,6 +34903,8 @@ class $$CalendarSourcesTableTableManager Value colorId = const Value.absent(), Value timeZone = const Value.absent(), Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), Value isDeleted = const Value.absent(), Value rawJson = const Value.absent(), Value createdAtLocal = const Value.absent(), @@ -34788,6 +34928,8 @@ class $$CalendarSourcesTableTableManager colorId: colorId, timeZone: timeZone, accessRole: accessRole, + dataOwner: dataOwner, + isRemovable: isRemovable, isDeleted: isDeleted, rawJson: rawJson, createdAtLocal: createdAtLocal, @@ -34813,6 +34955,8 @@ class $$CalendarSourcesTableTableManager Value colorId = const Value.absent(), Value timeZone = const Value.absent(), Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), Value isDeleted = const Value.absent(), Value rawJson = const Value.absent(), required int createdAtLocal, @@ -34836,6 +34980,8 @@ class $$CalendarSourcesTableTableManager colorId: colorId, timeZone: timeZone, accessRole: accessRole, + dataOwner: dataOwner, + isRemovable: isRemovable, isDeleted: isDeleted, rawJson: rawJson, createdAtLocal: createdAtLocal, diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index f1ed44f9..e989b75c 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -114,6 +114,10 @@ class CalendarSourceEntity { identity.isNotEmpty && owner == identity; } + + bool get hasAuthenticatedGoogleIdentity => + provider == BusyProvider.google && + authenticatedAccountEmail?.trim().isNotEmpty == true; } enum CalendarRenameMode { unavailable, global, personal } @@ -159,6 +163,7 @@ class CalendarSourceCapabilities { BusyProvider.google when available && !source.primaryCalendar && + source.hasAuthenticatedGoogleIdentity && source.dataOwner?.trim().isNotEmpty == true => CalendarRemovalMode.removeFromList, BusyProvider.microsoft @@ -206,8 +211,7 @@ class CalendarSourceCapabilities { final CalendarRenameMode renameMode; final CalendarRemovalMode removalMode; - bool get canRemoveCalendar => - removalMode != CalendarRemovalMode.unavailable; + bool get canRemoveCalendar => removalMode != CalendarRemovalMode.unavailable; } enum CalendarMutationOperation { @@ -566,16 +570,16 @@ class CalendarRepository { )..where((row) => row.id.equals(sourceId))).getSingle(); final entity = await _sourceEntity(source); final removalMode = entity.capabilities.removalMode; + final createOp = await _pendingCalendarCreate(source.id); _requireCalendarSourceCapability( source, operation: removalMode == CalendarRemovalMode.removeFromList ? CalendarMutationOperation.removeCalendar : CalendarMutationOperation.deleteCalendar, - allowed: entity.capabilities.canRemoveCalendar, + allowed: createOp != null || entity.capabilities.canRemoveCalendar, ); final now = _now(); final nowUtc = now.toUtc().toIso8601String(); - final createOp = await _pendingCalendarCreate(source.id); await _database.transaction(() async { await (_database.delete(_database.pendingOps)..where( (row) => @@ -2789,13 +2793,14 @@ class CalendarRepository { } } if (existing != null) { - final merged = _calendarPendingRequest(existing)..addAll(request); + final existingOp = existing; + final merged = _calendarPendingRequest(existingOp)..addAll(request); await (_database.update( _database.pendingOps, - )..where((row) => row.id.equals(existing.id))).write( + )..where((row) => row.id.equals(existingOp.id))).write( PendingOpsCompanion( requestJson: Value(jsonEncode(merged)), - dependsOnOpId: Value(existing.dependsOnOpId ?? dependsOnOpId), + dependsOnOpId: Value(existingOp.dependsOnOpId ?? dependsOnOpId), updatedAtUtc: Value(nowUtc), ), ); diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 869cbe68..0f37658c 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -543,8 +543,7 @@ class _EventEditorState extends State { title: l10n.guests, filled: true, children: _guestRows( - readOnly: - schedulingReadOnly || !_draft.canManageAttendees, + readOnly: schedulingReadOnly || !_draft.canManageAttendees, ), ), if ((provider == BusyProvider.google || diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 8820e683..aa18ddfb 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -163,16 +163,21 @@ class _SourceRow extends ConsumerWidget { ), BusyMaxMenuEntry( value: 'rename', - label: context.l10n.rename, + label: capabilities.renameMode == CalendarRenameMode.personal + ? context.l10n.setCustomCalendarName + : context.l10n.rename, icon: Icons.edit_outlined, enabled: capabilities.canRenameCalendar, tooltip: _calendarRenameRestriction(context, source), ), BusyMaxMenuEntry( value: 'delete', - label: context.l10n.delete, + label: + capabilities.removalMode == CalendarRemovalMode.removeFromList + ? context.l10n.removeFromMyCalendars + : context.l10n.delete, icon: YaruIcons.trash, - enabled: capabilities.canDeleteCalendar, + enabled: capabilities.canRemoveCalendar, tooltip: _calendarDeleteRestriction(context, source), destructive: true, ), @@ -848,14 +853,14 @@ String? _calendarDeleteRestriction( BuildContext context, CalendarSourceEntity source, ) { - if (source.capabilities.canDeleteCalendar) return null; + if (source.capabilities.canRemoveCalendar) return null; if (!calendarManagementCapabilities(source.provider).supportsDelete) { return context.l10n.calendarManagementUnsupported; } if (source.primaryCalendar) { return context.l10n.primaryCalendarCannotDelete; } - return context.l10n.readOnlyCalendar; + return context.l10n.calendarCannotRemove; } Future _createCalendar( @@ -931,9 +936,13 @@ Future _renameCalendar( ) async { final title = await showBusyMaxTextPrompt( context, - title: context.l10n.rename, + title: source.capabilities.renameMode == CalendarRenameMode.personal + ? context.l10n.setCustomCalendarName + : context.l10n.rename, label: context.l10n.title, - actionLabel: context.l10n.rename, + actionLabel: source.capabilities.renameMode == CalendarRenameMode.personal + ? context.l10n.setAction + : context.l10n.rename, initialValue: source.summary, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); @@ -968,11 +977,19 @@ Future _deleteCalendar( WidgetRef ref, CalendarSourceEntity source, ) async { + final removeFromList = + source.capabilities.removalMode == CalendarRemovalMode.removeFromList; final confirmed = await showBusyMaxConfirm( context, - title: context.l10n.delete, - message: context.l10n.deleteCalendarConfirmation(source.summary), - confirmLabel: context.l10n.delete, + title: removeFromList + ? context.l10n.removeFromMyCalendars + : context.l10n.delete, + message: removeFromList + ? context.l10n.removeCalendarConfirmation(source.summary) + : context.l10n.deleteCalendarConfirmation(source.summary), + confirmLabel: removeFromList + ? context.l10n.removeAction + : context.l10n.delete, destructive: true, headerBarService: ref.read(linuxHeaderBarServiceProvider), ); diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 61cb0a12..50916c04 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -123,6 +123,8 @@ class CalendarPendingOpsReplayer { await _patchCalendar(op); case 'calendar.delete': await _deleteCalendar(op); + case 'calendar.remove': + await _removeCalendar(op); case 'calendar.create': await _createCalendar(op); default: @@ -165,13 +167,43 @@ class CalendarPendingOpsReplayer { Future _patchCalendar(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); - final source = await _client.updateCalendar( - providerCalendarId, - _calendarMutation(_request(op)), - ); + final request = _request(op); + final personal = + request[calendarMutationScopeKey] == calendarMutationScopePersonal || + (_client.provider == BusyProvider.google && + request[calendarMutationScopeKey] == null && + request['summary'] == null && + (request['backgroundColor'] != null || + request['foregroundColor'] != null || + request['colorId'] != null)); + await _requireCalendarPatchAllowed(op, personal: personal); + final mutation = _calendarMutation(request); + final source = personal + ? await _updateCalendarListEntry(op, providerCalendarId, mutation) + : await _client.updateCalendar(providerCalendarId, mutation); await _repository.upsertSource(accountId: _accountId, source: source); } + Future _updateCalendarListEntry( + PendingOp op, + String providerCalendarId, + CalendarMutation mutation, + ) async { + final client = _client; + if (client is CalendarListManagementClient) { + return (client as CalendarListManagementClient).updateCalendarListEntry( + providerCalendarId, + mutation, + ); + } + await _blockOp( + op, + 'unsupported_calendar_operation', + 'The provider does not support personal calendar-list updates.', + ); + throw const _PendingOpBlocked(); + } + Future _createCalendar(PendingOp op) async { if (!_client.capabilities.supportsCreateCalendar) { await _blockOp(op, 'unsupported_calendar_operation', _operationType(op)); @@ -185,10 +217,120 @@ class CalendarPendingOpsReplayer { Future _deleteCalendar(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); + await _requireCalendarRemovalAllowed(op, removeFromList: false); await _client.deleteCalendar(providerCalendarId); await _applyDeleteSideEffect(op); } + Future _removeCalendar(PendingOp op) async { + final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); + await _requireCalendarRemovalAllowed(op, removeFromList: true); + final client = _client; + if (client is! CalendarListManagementClient) { + await _blockOp( + op, + 'unsupported_calendar_operation', + 'The provider does not support removing calendar-list entries.', + ); + throw const _PendingOpBlocked(); + } + await (client as CalendarListManagementClient).deleteCalendarListEntry( + providerCalendarId, + ); + await _applyDeleteSideEffect(op); + } + + Future _requireCalendarPatchAllowed( + PendingOp op, { + required bool personal, + }) async { + final source = await _calendarSourceForOperation(op); + final provider = BusyProviderCodec.requireStorageValue(source.provider); + var allowed = false; + if (personal) { + allowed = provider == BusyProvider.google && !source.isDeleted; + } else if (provider == BusyProvider.google) { + allowed = + !source.isDeleted && + (source.primaryCalendar || + await _googleDataOwnerMatchesAccount(source)); + } else if (provider == BusyProvider.microsoft) { + allowed = !source.readOnly && !source.isDeleted; + } + if (allowed) return; + await _blockOp( + op, + 'calendar_permission_changed', + 'Calendar update permission is no longer available.', + ); + throw const _PendingOpBlocked(); + } + + Future _requireCalendarRemovalAllowed( + PendingOp op, { + required bool removeFromList, + }) async { + final source = await _calendarSourceForOperation(op); + final provider = BusyProviderCodec.requireStorageValue(source.provider); + final googleIdentity = provider == BusyProvider.google + ? await _googleAccountIdentity(source) + : null; + final googleOwner = source.dataOwner?.trim().toLowerCase(); + final ownsGoogleCalendar = + googleIdentity != null && + googleOwner != null && + googleOwner.isNotEmpty && + googleIdentity == googleOwner; + final allowed = removeFromList + ? provider == BusyProvider.google && + !source.primaryCalendar && + googleIdentity != null && + source.dataOwner?.trim().isNotEmpty == true && + !ownsGoogleCalendar + : !source.primaryCalendar && + ((provider == BusyProvider.google && ownsGoogleCalendar) || + (provider == BusyProvider.microsoft && + source.isRemovable == true)); + if (allowed) return; + await _blockOp( + op, + 'calendar_permission_changed', + removeFromList + ? 'This calendar can no longer be removed from the account.' + : 'This calendar can no longer be deleted.', + ); + throw const _PendingOpBlocked(); + } + + Future _calendarSourceForOperation(PendingOp op) async { + final sourceId = _require(op.calendarSourceId, 'calendarSourceId'); + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingleOrNull(); + if (source != null) return source; + await _blockOp( + op, + 'calendar_not_found', + 'The local calendar record is unavailable.', + ); + throw const _PendingOpBlocked(); + } + + Future _googleDataOwnerMatchesAccount(CalendarSource source) async { + final owner = source.dataOwner?.trim().toLowerCase(); + if (owner == null || owner.isEmpty) return false; + final identity = await _googleAccountIdentity(source); + return identity != null && identity == owner; + } + + Future _googleAccountIdentity(CalendarSource source) async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(source.accountId))).getSingleOrNull(); + final identity = account?.email?.trim().toLowerCase(); + return identity == null || identity.isEmpty ? null : identity; + } + Future _createEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final request = _request(op); @@ -904,7 +1046,9 @@ class CalendarPendingOpsReplayer { final eventId = op.eventId; if (eventId == null) { final sourceId = op.calendarSourceId; - if (_operationType(op) == 'calendar.delete' && sourceId != null) { + if ((_operationType(op) == 'calendar.delete' || + _operationType(op) == 'calendar.remove') && + sourceId != null) { await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( @@ -1298,7 +1442,8 @@ class CalendarPendingOpsReplayer { bool _isSuccessfulMissingDelete(PendingOp op, int statusCode) { return statusCode == 404 && (_operationType(op) == 'event.delete' || - _operationType(op) == 'calendar.delete'); + _operationType(op) == 'calendar.delete' || + _operationType(op) == 'calendar.remove'); } bool _isRetryableStatus(int statusCode) { diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index 42dc9f1f..35e92e47 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -21,7 +21,7 @@ void main() { await database.close(); }); - test('opens schema version 9 and creates required indexes', () async { + test('opens schema version 10 and creates required indexes', () async { final version = await database .customSelect('PRAGMA user_version') .getSingle(); @@ -39,7 +39,7 @@ void main() { .customSelect('PRAGMA table_info(calendar_sources)') .get(); - expect(version.data['user_version'], 9); + expect(version.data['user_version'], 10); expect( taskColumns.map((row) => row.read('name')), contains('microsoft_checklist_items_json'), @@ -78,6 +78,70 @@ void main() { }); }); + test( + 'schema 9 migration adds calendar ownership columns without data loss', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v9-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaNineDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaNineDatabase); + await schemaNineDatabase + .into(schemaNineDatabase.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'calendar', + accountId: 'account', + provider: 'google', + providerCalendarId: 'shared@example.com', + summary: 'Shared calendar', + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await schemaNineDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('ALTER TABLE calendar_sources DROP COLUMN data_owner'); + raw.execute('ALTER TABLE calendar_sources DROP COLUMN is_removable'); + raw.execute('PRAGMA user_version = 9'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final version = await database + .customSelect('PRAGMA user_version') + .getSingle(); + final columns = await database + .customSelect('PRAGMA table_info(calendar_sources)') + .get(); + final source = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals('calendar'))).getSingle(); + + expect(version.read('user_version'), 10); + expect( + columns.map((row) => row.read('name')), + containsAll(['data_owner', 'is_removable']), + ); + expect(source.summary, 'Shared calendar'); + expect(source.dataOwner, equals(null)); + expect(source.isRemovable, equals(null)); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + test('upserts task lists and preserves raw JSON', () async { await _insertAccount(database); @@ -259,7 +323,7 @@ void main() { final version = await database .customSelect('PRAGMA user_version') .getSingle(); - expect(version.read('user_version'), 9); + expect(version.read('user_version'), 10); final accounts = await database.select(database.accounts).get(); expect(accounts, hasLength(2)); @@ -460,7 +524,7 @@ void main() { .getSingle(); final op = await database.pendingOpsDao.getOp('op-1'); - expect(version.data['user_version'], 9); + expect(version.data['user_version'], 10); expect(op, isNot(equals(null))); expect(op!.baselineRawJson, equals(null)); diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index ed95a04f..e4eaff40 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -33,6 +33,7 @@ void main() { authority: 'https://accounts.google.com', providerAccountId: 'g', credentialKind: 'oauth', + email: const Value('me@example.com'), authState: const Value('signed_in'), grantedScopes: const Value(''), createdAtUtc: '2026-06-08T00:00:00.000Z', @@ -208,6 +209,7 @@ void main() { expect(jsonDecode(operation.requestJson), { 'backgroundColor': '#3584e4', 'foregroundColor': '#000000', + calendarMutationScopeKey: calendarMutationScopePersonal, }); }); @@ -566,6 +568,64 @@ void main() { }, ); + test( + 'Google guest event editing does not grant attendee management', + () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'guest-editable', + title: 'Shared planning', + startDateTime: '2026-06-08T09:00:00-07:00', + endDateTime: '2026-06-08T10:00:00-07:00', + organizerJson: {'email': 'owner@example.com', 'self': false}, + attendeesJson: [ + {'email': 'me@example.com', 'self': true}, + ], + rawJson: { + 'id': 'guest-editable', + 'guestsCanModify': true, + 'guestsCanInviteOthers': false, + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'guest-editable', + ); + final detail = await repository.loadEventDetail(eventId); + final draft = EventEditorDraft.fromEventDetail(detail!); + + expect(detail.guestsCanInviteOthers, isFalse); + expect(draft.canManageAttendees, isFalse); + await expectLater( + repository.updateLocalEvent( + draft.copyWith( + attendees: const [ + EventAttendeeDraft(email: 'me@example.com', self: true), + EventAttendeeDraft(email: 'new@example.com'), + ], + ), + ), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + + await repository.updateLocalEvent( + draft.copyWith(title: 'Allowed title edit'), + ); + expect( + (await repository.loadEventDetail(eventId))!.title, + 'Allowed title edit', + ); + }, + ); + test('title-only recurring Google edit preserves explicit UTC', () async { await _upsertSource(repository); const recurrence = ['RRULE:FREQ=WEEKLY;BYDAY=MO']; @@ -975,6 +1035,79 @@ void main() { ); }); + test('shared Google calendar removal queues CalendarList deletion', () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'shared@example.com', + summary: 'Shared', + readOnly: true, + dataOwner: 'owner@example.com', + ), + ); + const sourceId = 'google:g|google|shared@example.com'; + + await repository.deleteLocalSource(sourceId); + + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operation, 'remove'); + expect(operation.operationType, 'calendar.remove'); + expect(operation.providerCalendarId, 'shared@example.com'); + }); + + test( + 'shared read-only Google calendar uses personal name and color updates', + () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'shared@example.com', + summary: 'Shared', + readOnly: true, + dataOwner: 'owner@example.com', + ), + ); + const sourceId = 'google:g|google|shared@example.com'; + + await repository.renameLocalSource(sourceId, 'My shared calendar'); + await repository.setSourceColor( + sourceId, + calendarColorChoices(BusyProvider.google).first, + ); + + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request['summary'], 'My shared calendar'); + expect(request[calendarMutationScopeKey], calendarMutationScopePersonal); + expect(request['backgroundColor'], isA()); + }, + ); + + test('Microsoft calendar deletion requires isRemovable', () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'not-removable', + summary: 'Editable shared calendar', + readOnly: false, + isRemovable: false, + ), + ); + + await expectLater( + repository.deleteLocalSource('google:g|microsoft|not-removable'), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isFalse, + ); + }); + test('provider hidden state can return to visible', () async { await repository.upsertSource( accountId: 'google:g', @@ -1124,6 +1257,7 @@ Future _upsertSource(CalendarRepository repository) { provider: BusyProvider.google, providerCalendarId: 'calendar-1', summary: 'Calendar', + dataOwner: 'me@example.com', ), ); } diff --git a/test/features/calendar/domain/calendar_management_capabilities_test.dart b/test/features/calendar/domain/calendar_management_capabilities_test.dart index 68a27359..1deba5b4 100644 --- a/test/features/calendar/domain/calendar_management_capabilities_test.dart +++ b/test/features/calendar/domain/calendar_management_capabilities_test.dart @@ -64,4 +64,86 @@ void main() { expect(source.capabilities.canDeleteCalendar, isFalse); expect(source.capabilities.canChangeCalendarColor, isTrue); }); + + test('owned Google secondary calendar permits global rename and delete', () { + const source = CalendarSourceEntity( + id: 'owned-calendar', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'owned@example.com', + summary: 'Owned', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + dataOwner: 'owner@example.com', + authenticatedAccountEmail: 'OWNER@example.com', + ); + + expect(source.capabilities.renameMode, CalendarRenameMode.global); + expect(source.capabilities.removalMode, CalendarRemovalMode.delete); + expect(source.capabilities.canDeleteCalendar, isTrue); + }); + + test( + 'shared read-only Google calendar permits personal name, color, and removal', + () { + const source = CalendarSourceEntity( + id: 'shared-calendar', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'shared@example.com', + summary: 'Shared', + selected: true, + hidden: false, + readOnly: true, + isDeleted: false, + dataOwner: 'other@example.com', + authenticatedAccountEmail: 'me@example.com', + ); + + expect(source.capabilities.canCreateEvents, isFalse); + expect(source.capabilities.renameMode, CalendarRenameMode.personal); + expect( + source.capabilities.removalMode, + CalendarRemovalMode.removeFromList, + ); + expect(source.capabilities.canDeleteCalendar, isFalse); + expect(source.capabilities.canRemoveCalendar, isTrue); + expect(source.capabilities.canChangeCalendarColor, isTrue); + }, + ); + + test('Microsoft deletion follows isRemovable rather than canEdit', () { + const removableReadOnly = CalendarSourceEntity( + id: 'shared-outlook', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'shared', + summary: 'Shared', + selected: true, + hidden: false, + readOnly: true, + isDeleted: false, + isRemovable: true, + ); + const editableNotRemovable = CalendarSourceEntity( + id: 'owned-outlook', + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'owned', + summary: 'Owned', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + isRemovable: false, + ); + + expect( + removableReadOnly.capabilities.removalMode, + CalendarRemovalMode.delete, + ); + expect(editableNotRemovable.capabilities.canRemoveCalendar, isFalse); + }); } diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 4423c699..d2e24ce8 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1314,6 +1314,50 @@ void main() { }, ); + testWidgets( + 'Google event guest cannot manage attendees when invitations are disabled', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'google-event', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Shared planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + attendees: const [ + EventAttendeeDraft(email: 'guest@example.com'), + ], + isOrganizer: false, + canManageAttendees: false, + ), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + expect(find.text('guest@example.com'), findsOneWidget); + expect(find.text('Add Guest'), findsNothing); + expect(find.text('Add guest email'), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is BusyMaxComboRow && + widget.title == 'guest@example.com', + ), + findsNothing, + ); + }, + ); + testWidgets('Nextcloud recurrence editor preserves a rich RFC rule', ( tester, ) async { diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 53379afc..8307d2dd 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -30,6 +30,7 @@ void main() { providerCalendarId: 'cal-1', summary: 'Work', timeZone: 'America/Vancouver', + dataOwner: 'me@example.com', ), ); }); @@ -1199,6 +1200,38 @@ void main() { ); }); + test( + 'personal Google calendar patch updates the CalendarList entry', + () async { + await database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: 'op-calendar-personal-patch', + accountId: 'account', + provider: const Value('google'), + entityType: 'calendar', + operation: 'patch', + operationType: const Value('calendar.patch'), + calendarSourceId: const Value('account|google|cal-1'), + providerCalendarId: const Value('cal-1'), + requestJson: + '{"summary":"My work","_calendarMutationScope":"personal"}', + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, ['updateCalendarListEntry:cal-1:My work']); + }, + ); + test( 'calendar create remaps dependent event work to the server id', () async { @@ -1283,6 +1316,43 @@ void main() { expect(source.isDeleted, isTrue); }); + test('calendar remove pending op deletes the CalendarList entry', () async { + await (database.update( + database.calendarSources, + )..where((row) => row.id.equals('account|google|cal-1'))).write( + const CalendarSourcesCompanion(dataOwner: Value('other@example.com')), + ); + await database.pendingOpsDao.enqueue( + PendingOpsCompanion.insert( + id: 'op-calendar-remove', + accountId: 'account', + provider: const Value('google'), + entityType: 'calendar', + operation: 'remove', + operationType: const Value('calendar.remove'), + calendarSourceId: const Value('account|google|cal-1'), + providerCalendarId: const Value('cal-1'), + requestJson: '{}', + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + expect(applied, 1); + expect(client.calls, ['deleteCalendarListEntry:cal-1']); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isTrue, + ); + }); + test('sync engine replays pending event ops before pull sync', () async { await CalendarRepository(database: database).createLocalEvent( EventEditorDraft.newEvent( @@ -1475,6 +1545,7 @@ Future _insertAccount(AppDatabase database) { authority: 'https://accounts.google.com', providerAccountId: 'google-account', credentialKind: 'oauth', + email: const Value('me@example.com'), authState: const Value('signed_in'), grantedScopes: const Value(''), createdAtUtc: '2026-06-08T00:00:00.000Z', @@ -1678,7 +1749,8 @@ Future _enqueueEventOp( final _later = DateTime.utc(2026, 6, 9); -class _FakeCalendarClient implements CloudCalendarClient { +class _FakeCalendarClient + implements CloudCalendarClient, CalendarListManagementClient { final calls = []; final createdMutations = []; final updatedMutations = []; @@ -1898,6 +1970,11 @@ class _FakeCalendarClient implements CloudCalendarClient { calls.add('deleteCalendar:$calendarId'); } + @override + Future deleteCalendarListEntry(String calendarId) async { + calls.add('deleteCalendarListEntry:$calendarId'); + } + @override Future> freeBusy({ required List calendarIds, @@ -1938,6 +2015,21 @@ class _FakeCalendarClient implements CloudCalendarClient { provider: BusyProvider.google, providerCalendarId: calendarId, summary: mutation.summary ?? 'Calendar', + dataOwner: 'me@example.com', + ); + } + + @override + Future updateCalendarListEntry( + String calendarId, + CalendarMutation mutation, + ) async { + calls.add('updateCalendarListEntry:$calendarId:${mutation.summary}'); + return CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: calendarId, + summary: mutation.summary ?? 'Calendar', + dataOwner: 'me@example.com', ); } } diff --git a/test/google_calendar/google_calendar_api_client_test.dart b/test/google_calendar/google_calendar_api_client_test.dart index 4ded417b..605ab354 100644 --- a/test/google_calendar/google_calendar_api_client_test.dart +++ b/test/google_calendar/google_calendar_api_client_test.dart @@ -48,10 +48,15 @@ void main() { }); test('calendar rename updates the calendar resource', () async { - late http.Request captured; + final requests = []; final client = _client((request) { - captured = request; - return _json({'id': 'calendar@example.com', 'summary': 'Renamed'}); + requests.add(request); + return _json({ + 'id': 'calendar@example.com', + 'summary': 'Renamed', + 'dataOwner': 'owner@example.com', + 'accessRole': 'owner', + }); }); await client.updateCalendar( @@ -59,10 +64,61 @@ void main() { const CalendarMutation(summary: 'Renamed'), ); + expect(requests, hasLength(2)); + expect(requests[0].method, 'PATCH'); + expect( + requests[0].url.path, + '/calendar/v3/calendars/calendar%40example.com', + ); + expect(requests[0].url.queryParameters, isEmpty); + expect(jsonDecode(requests[0].body), {'summary': 'Renamed'}); + expect(requests[1].method, 'GET'); + expect( + requests[1].url.path, + '/calendar/v3/users/me/calendarList/calendar%40example.com', + ); + }); + + test('personal calendar name updates summaryOverride', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'id': 'shared@example.com', + 'summary': 'Team', + 'summaryOverride': 'My team', + 'dataOwner': 'owner@example.com', + }); + }); + + final source = await client.updateCalendarListEntry( + 'shared@example.com', + const CalendarMutation(summary: 'My team'), + ); + expect(captured.method, 'PATCH'); - expect(captured.url.path, '/calendar/v3/calendars/calendar%40example.com'); - expect(captured.url.queryParameters, isEmpty); - expect(jsonDecode(captured.body), {'summary': 'Renamed'}); + expect( + captured.url.path, + '/calendar/v3/users/me/calendarList/shared%40example.com', + ); + expect(jsonDecode(captured.body), {'summaryOverride': 'My team'}); + expect(source.summary, 'My team'); + }); + + test('shared calendar removal deletes only the CalendarList entry', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return http.Response('', 204); + }); + + await client.deleteCalendarListEntry('shared@example.com'); + + expect(captured.method, 'DELETE'); + expect( + captured.url.path, + '/calendar/v3/users/me/calendarList/shared%40example.com', + ); }); test('event mutations always send an explicit guest update policy', () async { diff --git a/test/google_calendar/google_calendar_mapper_test.dart b/test/google_calendar/google_calendar_mapper_test.dart index f7d8ba6b..50fb610b 100644 --- a/test/google_calendar/google_calendar_mapper_test.dart +++ b/test/google_calendar/google_calendar_mapper_test.dart @@ -3,6 +3,20 @@ import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('Google calendar source maps data owner and personal name', () { + final source = googleCalendarSourceFromJson({ + 'id': 'shared@example.com', + 'summary': 'Team calendar', + 'summaryOverride': 'My team', + 'dataOwner': 'owner@example.com', + 'accessRole': 'reader', + }); + + expect(source.summary, 'My team'); + expect(source.dataOwner, 'owner@example.com'); + expect(source.readOnly, isTrue); + }); + test('Google guest visibility maps from the shared hide-attendees field', () { expect( googleEventMutationToJson( diff --git a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart index 840bab74..3905b9fc 100644 --- a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart @@ -11,10 +11,12 @@ void main() { 'canEdit': true, 'hexColor': '#123456', 'color': 'lightBlue', + 'isRemovable': false, }); expect(source.backgroundColor, '#123456'); expect(source.colorId, 'lightBlue'); + expect(source.isRemovable, isFalse); }); test( From f7107897ec1971bc53d82300546c2525b36cb0a8 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 12:04:59 -0700 Subject: [PATCH 05/69] fix: recover optimistic calendar mutations --- .../calendar_providers/calendar_mutation.dart | 1 + .../calendar/data/calendar_repository.dart | 86 +++++++++++- .../sync/calendar_pending_ops_replayer.dart | 9 +- .../sync/pending_op_resolution_service.dart | 9 ++ .../data/calendar_repository_test.dart | 51 +++++++ ...calendar_management_capabilities_test.dart | 22 +++ .../calendar_pending_ops_replayer_test.dart | 130 +++++++++++++++++- .../pending_op_resolution_service_test.dart | 39 ++++++ 8 files changed, 341 insertions(+), 6 deletions(-) diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 5d4ee79f..8a88d4eb 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -19,6 +19,7 @@ class CalendarMutation { const calendarMutationScopeKey = '_calendarMutationScope'; const calendarMutationScopeGlobal = 'global'; const calendarMutationScopePersonal = 'personal'; +const calendarRemovalPreviousHiddenKey = '_previousHidden'; const calendarEventClearFieldsKey = '_clearFields'; const calendarEventRecurrenceField = 'recurrenceJson'; diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index e989b75c..d3875e5f 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -44,6 +44,7 @@ class CalendarSourceEntity { this.dataOwner, this.isRemovable, this.authenticatedAccountEmail, + this.pendingCreate = false, this.davCollectionId, this.allowedConferenceSolutions = const [], }); @@ -51,6 +52,7 @@ class CalendarSourceEntity { factory CalendarSourceEntity.fromRow( CalendarSource row, { String? authenticatedAccountEmail, + bool pendingCreate = false, }) { return CalendarSourceEntity( id: row.id, @@ -73,6 +75,7 @@ class CalendarSourceEntity { dataOwner: row.dataOwner, isRemovable: row.isRemovable, authenticatedAccountEmail: authenticatedAccountEmail, + pendingCreate: pendingCreate, davCollectionId: row.davCollectionId, allowedConferenceSolutions: _conferenceSolutions(row.rawJson), ); @@ -98,6 +101,7 @@ class CalendarSourceEntity { final String? dataOwner; final bool? isRemovable; final String? authenticatedAccountEmail; + final bool pendingCreate; final String? davCollectionId; final List allowedConferenceSolutions; @@ -147,6 +151,9 @@ class CalendarSourceCapabilities { final writable = !source.readOnly && available; final management = calendarManagementCapabilities(source.provider); final renameMode = switch (source.provider) { + BusyProvider.google || BusyProvider.microsoft + when available && source.pendingCreate => + CalendarRenameMode.global, BusyProvider.google when available => source.primaryCalendar || source.isCurrentGoogleDataOwner ? CalendarRenameMode.global @@ -155,6 +162,9 @@ class CalendarSourceCapabilities { _ => CalendarRenameMode.unavailable, }; final removalMode = switch (source.provider) { + BusyProvider.google || BusyProvider.microsoft + when available && source.pendingCreate => + CalendarRemovalMode.delete, BusyProvider.google when available && !source.primaryCalendar && @@ -276,6 +286,14 @@ class CalendarRepository { _database.accounts, _database.accounts.id.equalsExp(_database.calendarSources.accountId), ), + leftOuterJoin( + _database.pendingOps, + _database.pendingOps.calendarSourceId.equalsExp( + _database.calendarSources.id, + ) & + _database.pendingOps.operationType.equals('calendar.create') & + _database.pendingOps.state.equals('pending'), + ), ]); query.where( _database.calendarSources.accountId.isIn(accountIds) & @@ -293,6 +311,7 @@ class CalendarRepository { authenticatedAccountEmail: result .readTable(_database.accounts) .email, + pendingCreate: result.readTableOrNull(_database.pendingOps) != null, ), ], ); @@ -309,6 +328,14 @@ class CalendarRepository { _database.accounts, _database.accounts.id.equalsExp(_database.calendarSources.accountId), ), + leftOuterJoin( + _database.pendingOps, + _database.pendingOps.calendarSourceId.equalsExp( + _database.calendarSources.id, + ) & + _database.pendingOps.operationType.equals('calendar.create') & + _database.pendingOps.state.equals('pending'), + ), ]); query.where( _database.calendarSources.accountId.isIn(accountIds) & @@ -322,6 +349,7 @@ class CalendarRepository { CalendarSourceEntity.fromRow( result.readTable(_database.calendarSources), authenticatedAccountEmail: result.readTable(_database.accounts).email, + pendingCreate: result.readTableOrNull(_database.pendingOps) != null, ), ]; } @@ -340,6 +368,7 @@ class CalendarRepository { return CalendarSourceEntity.fromRow( source, authenticatedAccountEmail: account.email, + pendingCreate: await _pendingCalendarCreate(source.id) != null, ); } @@ -618,7 +647,9 @@ class CalendarRepository { ), calendarSourceId: Value(source.id), providerCalendarId: Value(source.providerCalendarId), - requestJson: '{}', + requestJson: jsonEncode({ + calendarRemovalPreviousHiddenKey: source.hidden, + }), baselineRawJson: Value(source.rawJson), createdAtUtc: nowUtc, updatedAtUtc: nowUtc, @@ -645,7 +676,9 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(id))).getSingleOrNull(); - final isDeleted = (existing?.isDeleted ?? false) || source.isDeleted; + final preservePendingRemoval = + existing != null && await _hasActiveCalendarRemoval(existing.id); + final isDeleted = preservePendingRemoval || source.isDeleted; await _database .into(_database.calendarSources) .insertOnConflictUpdate( @@ -677,6 +710,30 @@ class CalendarRepository { }); } + Future restoreSourceAfterRemovalFailure(PendingOp op) async { + final operationType = + op.operationType ?? '${op.entityType}.${op.operation}'; + if (operationType != 'calendar.delete' && + operationType != 'calendar.remove') { + return; + } + final sourceId = op.calendarSourceId; + if (sourceId == null) return; + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingleOrNull(); + if (source == null || !source.isDeleted) return; + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).write( + CalendarSourcesCompanion( + isDeleted: const Value(false), + hidden: Value(_calendarRemovalPreviousHidden(op)), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + } + /// Stores a provider event. /// /// Pull syncs must preserve local optimistic state while an event is dirty or @@ -2760,6 +2817,22 @@ class CalendarRepository { .getSingleOrNull(); } + Future _hasActiveCalendarRemoval(String sourceId) async { + final operations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.calendarSourceId.equals(sourceId) & + (row.operationType.equals('calendar.delete') | + row.operationType.equals('calendar.remove')) & + row.state.equals('pending'), + )) + .get(); + return operations.any( + (operation) => + operation.nextAttemptAtUtc?.startsWith('9999-12-31') != true, + ); + } + Future _enqueueOrMergeCalendarPatch( CalendarSource source, { required Map request, @@ -3030,6 +3103,15 @@ Map _calendarPendingRequest(PendingOp op) { return decoded.cast(); } +bool _calendarRemovalPreviousHidden(PendingOp op) { + final requestValue = _jsonMap( + op.requestJson, + )[calendarRemovalPreviousHiddenKey]; + if (requestValue is bool) return requestValue; + final baselineValue = _jsonMap(op.baselineRawJson)['hidden']; + return baselineValue is bool ? baselineValue : false; +} + void _requireDavSchedulingUnchanged( EventEditorDraft draft, { required bool creating, diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 50916c04..bea9c7c0 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -1465,14 +1465,19 @@ class CalendarPendingOpsReplayer { ); } - Future _blockOp(PendingOp op, String errorCode, String errorMessage) { - return _database.pendingOpsDao.updateAttempt( + Future _blockOp( + PendingOp op, + String errorCode, + String errorMessage, + ) async { + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, nextAttemptAtUtc: DateTime.utc(9999, 12, 31), lastErrorCode: errorCode, lastErrorMessage: errorMessage, ); + await _repository.restoreSourceAfterRemovalFailure(op); } Future _blockConflict(PendingOp op, String message) async { diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 8eaad26b..9f29b387 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../../db/app_database.dart'; +import '../calendar/data/calendar_repository.dart'; import '../task_lists/data/task_lists_repository.dart'; import '../tasks/data/tasks_repository.dart'; import '../tasks/domain/task_remote_client.dart'; @@ -43,6 +44,14 @@ class PendingOpResolutionService { } Future _refreshOrRemoveLocalState(PendingOp op) async { + if (op.entityType == 'calendar') { + await CalendarRepository( + database: _database, + now: _nowUtc, + ).restoreSourceAfterRemovalFailure(op); + return; + } + if (op.entityType == 'task' && op.taskListId != null && op.taskId != null) { if (op.operation == 'move_task') { await _refreshOrRemoveMovedTask(op); diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index e4eaff40..43c8e69c 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -161,6 +161,12 @@ void main() { expect(operation.calendarSourceId, sourceId); expect(operation.providerCalendarId, source.providerCalendarId); expect(jsonDecode(operation.requestJson), {'summary': 'Project'}); + final entity = (await repository.watchSourcesForAccounts(const [ + 'google:g', + ]).first).single; + expect(entity.pendingCreate, isTrue); + expect(entity.capabilities.renameMode, CalendarRenameMode.global); + expect(entity.capabilities.canRemoveCalendar, isTrue); }, ); @@ -191,6 +197,21 @@ void main() { expect(eventCreate.dependsOnOpId, calendarCreate.id); }); + test('renaming an unsynced calendar updates its create operation', () async { + final sourceId = await repository.createLocalSource( + accountId: 'google:g', + summary: 'Initial name', + ); + + await repository.renameLocalSource(sourceId, 'Final name'); + + final source = await database.select(database.calendarSources).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(source.summary, 'Final name'); + expect(operation.operationType, 'calendar.create'); + expect(jsonDecode(operation.requestJson), {'summary': 'Final name'}); + }); + test('calendar color is projected locally and queued by provider', () async { await _upsertSource(repository); const choice = CalendarColorChoice( @@ -1020,6 +1041,36 @@ void main() { expect(source.isDeleted, isTrue); }); + test( + 'provider upsert restores a source after removal work is gone', + () async { + await _upsertSource(repository); + await repository.deleteLocalSource(_sourceId); + final operation = await database.select(database.pendingOps).getSingle(); + expect(jsonDecode(operation.requestJson), { + calendarRemovalPreviousHiddenKey: false, + }); + await database.pendingOpsDao.deleteOp(operation.id); + + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + summary: 'Still returned by provider', + hidden: false, + isDeleted: false, + ), + ); + + final source = await database + .select(database.calendarSources) + .getSingle(); + expect(source.hidden, isFalse); + expect(source.isDeleted, isFalse); + }, + ); + test('source stream removes locally tombstoned calendars', () async { await _upsertSource(repository); expect( diff --git a/test/features/calendar/domain/calendar_management_capabilities_test.dart b/test/features/calendar/domain/calendar_management_capabilities_test.dart index 1deba5b4..080f92cc 100644 --- a/test/features/calendar/domain/calendar_management_capabilities_test.dart +++ b/test/features/calendar/domain/calendar_management_capabilities_test.dart @@ -146,4 +146,26 @@ void main() { ); expect(editableNotRemovable.capabilities.canRemoveCalendar, isFalse); }); + + test('pending cloud calendar creation can be renamed and cancelled', () { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + final source = CalendarSourceEntity( + id: '${provider.storageValue}-pending', + accountId: '${provider.storageValue}-account', + provider: provider, + providerCalendarId: 'local:pending', + summary: 'New calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + pendingCreate: true, + ); + + expect(source.capabilities.renameMode, CalendarRenameMode.global); + expect(source.capabilities.removalMode, CalendarRemovalMode.delete); + expect(source.capabilities.canRenameCalendar, isTrue); + expect(source.capabilities.canRemoveCalendar, isTrue); + } + }); } diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 8307d2dd..65105c65 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -10,6 +10,7 @@ import 'package:busymax/src/features/calendar/presentation/event_editor_draft.da 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/google_calendar/google_calendar_errors.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; @@ -1353,6 +1354,112 @@ void main() { ); }); + test( + 'Google calendar delete rejected with 403 restores the source', + () async { + await (database.update(database.calendarSources) + ..where((row) => row.id.equals('account|google|cal-1'))) + .write(const CalendarSourcesCompanion(hidden: Value(true))); + await CalendarRepository( + database: database, + ).deleteLocalSource('account|google|cal-1'); + client.calendarDeleteError = const GoogleCalendarApiError( + statusCode: 403, + code: 'forbidden', + message: 'Permission denied', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(applied, 0); + expect(source.isDeleted, isFalse); + expect(source.hidden, isTrue); + expect(operation.nextAttemptAtUtc, startsWith('9999-12-31')); + }, + ); + + test( + 'Google CalendarList removal rejected with 403 restores the source', + () async { + await (database.update( + database.calendarSources, + )..where((row) => row.id.equals('account|google|cal-1'))).write( + const CalendarSourcesCompanion(dataOwner: Value('other@example.com')), + ); + await CalendarRepository( + database: database, + ).deleteLocalSource('account|google|cal-1'); + client.calendarListDeleteError = const GoogleCalendarApiError( + statusCode: 403, + code: 'forbidden', + message: 'Permission denied', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(applied, 0); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(operation.nextAttemptAtUtc, startsWith('9999-12-31')); + }, + ); + + for (final statusCode in [400, 403]) { + test( + 'Microsoft calendar delete rejected with $statusCode restores the source', + () async { + await _insertMicrosoftAccountAndSource(database, isRemovable: true); + const sourceId = 'microsoft-account|microsoft|ms-cal-1'; + await CalendarRepository( + database: database, + ).deleteLocalSource(sourceId); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..calendarDeleteError = MicrosoftCalendarApiError( + statusCode: statusCode, + code: 'ErrorAccessDenied', + message: 'Permission denied', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + + final source = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingle(); + final operation = + await (database.select(database.pendingOps) + ..where((row) => row.accountId.equals('microsoft-account'))) + .getSingle(); + expect(applied, 0); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(operation.nextAttemptAtUtc, startsWith('9999-12-31')); + }, + ); + } + test('sync engine replays pending event ops before pull sync', () async { await CalendarRepository(database: database).createLocalEvent( EventEditorDraft.newEvent( @@ -1554,7 +1661,10 @@ Future _insertAccount(AppDatabase database) { ); } -Future _insertMicrosoftAccountAndSource(AppDatabase database) async { +Future _insertMicrosoftAccountAndSource( + AppDatabase database, { + bool? isRemovable, +}) async { await database .into(database.accounts) .insert( @@ -1572,11 +1682,12 @@ Future _insertMicrosoftAccountAndSource(AppDatabase database) async { ); await CalendarRepository(database: database).upsertSource( accountId: 'microsoft-account', - source: const CalendarSourceDto( + source: CalendarSourceDto( provider: BusyProvider.microsoft, providerCalendarId: 'ms-cal-1', summary: 'Outlook', timeZone: 'America/Vancouver', + isRemovable: isRemovable, ), ); } @@ -1764,6 +1875,8 @@ class _FakeCalendarClient bool persistEventUpdates = false; int _eventUpdateRevision = 0; GoogleCalendarApiError? deleteError; + Object? calendarDeleteError; + GoogleCalendarApiError? calendarListDeleteError; @override BusyProvider get provider => BusyProvider.google; @@ -1968,11 +2081,15 @@ class _FakeCalendarClient @override Future deleteCalendar(String calendarId) async { calls.add('deleteCalendar:$calendarId'); + final error = calendarDeleteError; + if (error != null) throw error; } @override Future deleteCalendarListEntry(String calendarId) async { calls.add('deleteCalendarListEntry:$calendarId'); + final error = calendarListDeleteError; + if (error != null) throw error; } @override @@ -2033,3 +2150,12 @@ class _FakeCalendarClient ); } } + +class _FakeMicrosoftCalendarClient extends _FakeCalendarClient { + @override + BusyProvider get provider => BusyProvider.microsoft; + + @override + CalendarProviderCapabilities get capabilities => + microsoftCalendarProviderCapabilities; +} diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 61db0651..9a1746f2 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -339,6 +340,40 @@ void main() { expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); expect(syncEngine.incrementalSyncCalls, 1); }); + + test('discard blocked calendar removal restores the source', () async { + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'calendar-source', + accountId: 'account', + provider: 'google', + providerCalendarId: 'calendar@example.com', + summary: 'Calendar', + hidden: const Value(true), + isDeleted: const Value(true), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await _enqueueBlockedOp( + database, + entityType: 'calendar', + operation: 'delete', + operationType: 'calendar.delete', + calendarSourceId: 'calendar-source', + request: const {calendarRemovalPreviousHiddenKey: false}, + ); + + await service.discard('op-1'); + + final source = await database.select(database.calendarSources).getSingle(); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); + expect(syncEngine.incrementalSyncCalls, 1); + }); } class _FakeTaskRemoteClient implements TaskRemoteClient { @@ -443,8 +478,10 @@ Future _enqueueBlockedOp( AppDatabase database, { String entityType = 'task', required String operation, + String? operationType, String? taskListId, String? taskId, + String? calendarSourceId, Map request = const {}, }) { return database.pendingOpsDao.enqueue( @@ -453,8 +490,10 @@ Future _enqueueBlockedOp( accountId: 'account', entityType: entityType, operation: operation, + operationType: Value(operationType), taskListId: Value(taskListId), taskId: Value(taskId), + calendarSourceId: Value(calendarSourceId), requestJson: jsonEncode(request), nextAttemptAtUtc: const Value('9999-12-31T00:00:00.000Z'), lastErrorCode: const Value('conflict'), From 9aecff71bfe3395b2fca93f24526882bd9cf4c7b Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 13:17:47 -0700 Subject: [PATCH 06/69] feat: improve calendar mutation handling with pending operation sync and discard support --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fi.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 4 + lib/l10n/generated/app_localizations_de.dart | 4 + lib/l10n/generated/app_localizations_en.dart | 4 + lib/l10n/generated/app_localizations_es.dart | 4 + lib/l10n/generated/app_localizations_et.dart | 4 + lib/l10n/generated/app_localizations_fa.dart | 4 + lib/l10n/generated/app_localizations_fi.dart | 4 + lib/l10n/generated/app_localizations_fr.dart | 4 + lib/l10n/generated/app_localizations_hi.dart | 4 + lib/l10n/generated/app_localizations_it.dart | 4 + lib/l10n/generated/app_localizations_ja.dart | 4 + lib/l10n/generated/app_localizations_ko.dart | 4 + lib/l10n/generated/app_localizations_pt.dart | 4 + lib/l10n/generated/app_localizations_ru.dart | 4 + lib/l10n/generated/app_localizations_vi.dart | 4 + lib/l10n/generated/app_localizations_zh.dart | 12 ++ lib/src/app/app_bootstrap.dart | 11 +- .../calendar_providers/calendar_mutation.dart | 1 + .../calendar/data/calendar_repository.dart | 196 +++++++++++++++++- .../presentation/schedule_sidebar.dart | 20 +- .../sync/pending_op_resolution_service.dart | 79 +++++-- .../data/calendar_repository_test.dart | 51 +++++ .../pending_op_resolution_service_test.dart | 116 ++++++++++- 42 files changed, 527 insertions(+), 43 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 31891ab2..0809529a 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -637,6 +637,7 @@ "removeAction": "إزالة", "removeCalendarConfirmation": "هل تريد إزالة \"{title}\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", "calendarCannotRemove": "لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.", + "calendarPendingChangesPreventRemoval": "انتظر حتى تنتهي مزامنة التغييرات المعلّقة لهذا التقويم قبل حذفه أو إزالته.", "networkOffline": "غير متصل", "networkOfflineDescription": "ستتم مزامنة التغييرات عند استعادة الاتصال.", "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى." diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b9239712..ecd8a7bc 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -700,6 +700,7 @@ "removeAction": "Entfernen", "removeCalendarConfirmation": "„{title}“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.", "calendarCannotRemove": "Dieser Kalender kann in diesem Konto weder gelöscht noch entfernt werden.", + "calendarPendingChangesPreventRemoval": "Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.", "networkOffline": "Offline", "networkOfflineDescription": "Änderungen werden synchronisiert, sobald die Verbindung wiederhergestellt ist.", "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut." diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9450ddea..0d268df6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -658,6 +658,7 @@ "removeCalendarConfirmation": "Remove \"{title}\" from your Google Calendar list? The shared calendar and its events will not be deleted.", "@removeCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}}, "calendarCannotRemove": "This calendar cannot be deleted or removed from this account.", + "calendarPendingChangesPreventRemoval": "Wait for this calendar’s pending changes to finish syncing before deleting or removing it.", "networkOffline": "Offline", "networkOfflineDescription": "Changes will sync when the connection is restored.", "networkOfflineTryAgain": "You’re offline. Connect to the internet and try again." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 6dbe5ff7..88e6a450 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -700,6 +700,7 @@ "removeAction": "Quitar", "removeCalendarConfirmation": "¿Quitar \"{title}\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.", "calendarCannotRemove": "Este calendario no se puede eliminar ni quitar de esta cuenta.", + "calendarPendingChangesPreventRemoval": "Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.", "networkOffline": "Sin conexión", "networkOfflineDescription": "Los cambios se sincronizarán cuando se restablezca la conexión.", "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo." diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index da959455..9d601afa 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -815,6 +815,7 @@ "removeAction": "Eemalda", "removeCalendarConfirmation": "Kas eemaldada „{title}” sinu Google'i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", "calendarCannotRemove": "Seda kalendrit ei saa sellelt kontolt kustutada ega eemaldada.", + "calendarPendingChangesPreventRemoval": "Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.", "networkOffline": "Võrguühenduseta", "networkOfflineDescription": "Muudatused sünkroonitakse ühenduse taastumisel.", "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti." diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index ac070afe..187d1749 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -701,6 +701,7 @@ "removeAction": "حذف", "removeCalendarConfirmation": "«{title}» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف کرد.", + "calendarPendingChangesPreventRemoval": "پیش از حذف یا برداشتن این تقویم، صبر کنید تا تغییرات در انتظار آن همگام‌سازی شوند.", "networkOffline": "آفلاین", "networkOfflineDescription": "تغییرات پس از برقراری دوبارهٔ اتصال همگام‌سازی می‌شوند.", "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید." diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 899e401f..7d9cac46 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -637,6 +637,7 @@ "removeAction": "Poista", "removeCalendarConfirmation": "Poistetaanko ”{title}” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.", "calendarCannotRemove": "Tätä kalenteria ei voi poistaa tältä tililtä.", + "calendarPendingChangesPreventRemoval": "Odota, että tämän kalenterin odottavat muutokset on synkronoitu, ennen kuin poistat kalenterin.", "networkOffline": "Ei verkkoyhteyttä", "networkOfflineDescription": "Muutokset synkronoidaan, kun yhteys palautuu.", "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7ce0b282..e2ee9457 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -700,6 +700,7 @@ "removeAction": "Retirer", "removeCalendarConfirmation": "Retirer « {title} » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.", "calendarCannotRemove": "Cet agenda ne peut pas être supprimé ni retiré de ce compte.", + "calendarPendingChangesPreventRemoval": "Attendez la fin de la synchronisation des modifications en attente de cet agenda avant de le supprimer ou de le retirer.", "networkOffline": "Hors ligne", "networkOfflineDescription": "Les modifications seront synchronisées au rétablissement de la connexion.", "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6af29a48..cbf38a66 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -637,6 +637,7 @@ "removeAction": "हटाएँ", "removeCalendarConfirmation": "क्या \"{title}\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।", "calendarCannotRemove": "इस कैलेंडर को इस खाते से मिटाया या हटाया नहीं जा सकता।", + "calendarPendingChangesPreventRemoval": "इस कैलेंडर को मिटाने या हटाने से पहले इसके लंबित बदलावों का सिंक पूरा होने दें।", "networkOffline": "ऑफ़लाइन", "networkOfflineDescription": "कनेक्शन वापस आने पर बदलाव सिंक हो जाएंगे।", "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।" diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index e75eb6a6..2e2a2e79 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -637,6 +637,7 @@ "removeAction": "Rimuovi", "removeCalendarConfirmation": "Rimuovere \"{title}\" dall'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.", "calendarCannotRemove": "Questo calendario non può essere eliminato o rimosso da questo account.", + "calendarPendingChangesPreventRemoval": "Attendi che le modifiche in sospeso di questo calendario siano sincronizzate prima di eliminarlo o rimuoverlo.", "networkOffline": "Offline", "networkOfflineDescription": "Le modifiche verranno sincronizzate al ripristino della connessione.", "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova." diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 91c8f573..cf16034c 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -637,6 +637,7 @@ "removeAction": "削除", "removeCalendarConfirmation": "「{title}」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。", "calendarCannotRemove": "このカレンダーは、このアカウントから削除できません。", + "calendarPendingChangesPreventRemoval": "このカレンダーを削除する前に、保留中の変更の同期が完了するまでお待ちください。", "networkOffline": "オフライン", "networkOfflineDescription": "接続が復旧すると変更が同期されます。", "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 2f5d5ee9..e03ca73a 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -637,6 +637,7 @@ "removeAction": "삭제", "removeCalendarConfirmation": "Google Calendar 목록에서 \"{title}\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.", "calendarCannotRemove": "이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.", + "calendarPendingChangesPreventRemoval": "이 캘린더를 삭제하거나 제거하기 전에 보류 중인 변경 사항의 동기화가 완료될 때까지 기다리세요.", "networkOffline": "오프라인", "networkOfflineDescription": "연결이 복구되면 변경 사항이 동기화됩니다.", "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 8db86411..0d544223 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -637,6 +637,7 @@ "removeAction": "Remover", "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Agenda? A agenda compartilhada e os eventos dela não serão excluídos.", "calendarCannotRemove": "Não é possível excluir nem remover esta agenda desta conta.", + "calendarPendingChangesPreventRemoval": "Aguarde a sincronização das alterações pendentes desta agenda antes de excluí-la ou removê-la.", "networkOffline": "Offline", "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente." diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 02e79d17..0f325d58 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -637,6 +637,7 @@ "removeAction": "Удалить", "removeCalendarConfirmation": "Удалить «{title}» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.", "calendarCannotRemove": "Этот календарь нельзя удалить из этой учётной записи.", + "calendarPendingChangesPreventRemoval": "Дождитесь синхронизации ожидающих изменений этого календаря, прежде чем удалять его.", "networkOffline": "Нет подключения", "networkOfflineDescription": "Изменения будут синхронизированы после восстановления подключения.", "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку." diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 2a8bfc42..2cf92d91 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -637,6 +637,7 @@ "removeAction": "Xóa", "removeCalendarConfirmation": "Xóa “{title}” khỏi danh sách Google Calendar của bạn? Lịch dùng chung và các sự kiện trong đó sẽ không bị xóa.", "calendarCannotRemove": "Không thể xóa lịch này khỏi tài khoản.", + "calendarPendingChangesPreventRemoval": "Hãy đợi các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.", "networkOffline": "Ngoại tuyến", "networkOfflineDescription": "Các thay đổi sẽ được đồng bộ khi kết nối được khôi phục.", "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3101aafa..0da54363 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -637,6 +637,7 @@ "removeAction": "移除", "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", "calendarCannotRemove": "无法从此账号中删除或移除此日历。", + "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改同步完成,然后再删除或移除它。", "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 680c89a1..06fccd89 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -637,6 +637,7 @@ "removeAction": "移除", "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", "calendarCannotRemove": "无法从此账号中删除或移除此日历。", + "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改同步完成,然后再删除或移除它。", "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 075d9b66..fa50dee9 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -637,6 +637,7 @@ "removeAction": "移除", "removeCalendarConfirmation": "要從您的 Google 日曆清單移除「{title}」嗎?共用日曆及其活動不會被刪除。", "calendarCannotRemove": "無法從此帳戶刪除或移除此日曆。", + "calendarPendingChangesPreventRemoval": "請等待此行事曆的待處理變更同步完成,再刪除或移除它。", "networkOffline": "離線", "networkOfflineDescription": "連線恢復後將同步變更。", "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。" diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index e81b31d7..68a2c0fe 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3672,6 +3672,12 @@ abstract class AppLocalizations { /// **'This calendar cannot be deleted or removed from this account.'** String get calendarCannotRemove; + /// No description provided for @calendarPendingChangesPreventRemoval. + /// + /// In en, this message translates to: + /// **'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'** + String get calendarPendingChangesPreventRemoval; + /// No description provided for @networkOffline. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 38623d9c..813ff0d9 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2018,6 +2018,10 @@ class AppLocalizationsAr extends AppLocalizations { String get calendarCannotRemove => 'لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.'; + @override + String get calendarPendingChangesPreventRemoval => + 'انتظر حتى تنتهي مزامنة التغييرات المعلّقة لهذا التقويم قبل حذفه أو إزالته.'; + @override String get networkOffline => 'غير متصل'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 16949597..da22e6c7 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2019,6 +2019,10 @@ class AppLocalizationsDe extends AppLocalizations { String get calendarCannotRemove => 'Dieser Kalender kann in diesem Konto weder gelöscht noch entfernt werden.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 525f8e60..808c83ff 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2000,6 +2000,10 @@ class AppLocalizationsEn extends AppLocalizations { String get calendarCannotRemove => 'This calendar cannot be deleted or removed from this account.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index c8f85a84..42ac2b78 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2019,6 +2019,10 @@ class AppLocalizationsEs extends AppLocalizations { String get calendarCannotRemove => 'Este calendario no se puede eliminar ni quitar de esta cuenta.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.'; + @override String get networkOffline => 'Sin conexión'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index d2844bd4..b78bce07 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -2007,6 +2007,10 @@ class AppLocalizationsEt extends AppLocalizations { String get calendarCannotRemove => 'Seda kalendrit ei saa sellelt kontolt kustutada ega eemaldada.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.'; + @override String get networkOffline => 'Võrguühenduseta'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 89b2c8ae..09b25ca7 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2041,6 +2041,10 @@ class AppLocalizationsFa extends AppLocalizations { String get calendarCannotRemove => 'این تقویم را نمی‌توان از این حساب حذف کرد.'; + @override + String get calendarPendingChangesPreventRemoval => + 'پیش از حذف یا برداشتن این تقویم، صبر کنید تا تغییرات در انتظار آن همگام‌سازی شوند.'; + @override String get networkOffline => 'آفلاین'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index dfc70f0f..a58483ed 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -2013,6 +2013,10 @@ class AppLocalizationsFi extends AppLocalizations { String get calendarCannotRemove => 'Tätä kalenteria ei voi poistaa tältä tililtä.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Odota, että tämän kalenterin odottavat muutokset on synkronoitu, ennen kuin poistat kalenterin.'; + @override String get networkOffline => 'Ei verkkoyhteyttä'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 5fa21a48..e7573cbe 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2017,6 +2017,10 @@ class AppLocalizationsFr extends AppLocalizations { String get calendarCannotRemove => 'Cet agenda ne peut pas être supprimé ni retiré de ce compte.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Attendez la fin de la synchronisation des modifications en attente de cet agenda avant de le supprimer ou de le retirer.'; + @override String get networkOffline => 'Hors ligne'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 8a7537dc..0e9c749b 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -2007,6 +2007,10 @@ class AppLocalizationsHi extends AppLocalizations { String get calendarCannotRemove => 'इस कैलेंडर को इस खाते से मिटाया या हटाया नहीं जा सकता।'; + @override + String get calendarPendingChangesPreventRemoval => + 'इस कैलेंडर को मिटाने या हटाने से पहले इसके लंबित बदलावों का सिंक पूरा होने दें।'; + @override String get networkOffline => 'ऑफ़लाइन'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index cb0e2d82..cf193c5f 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2019,6 +2019,10 @@ class AppLocalizationsIt extends AppLocalizations { String get calendarCannotRemove => 'Questo calendario non può essere eliminato o rimosso da questo account.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Attendi che le modifiche in sospeso di questo calendario siano sincronizzate prima di eliminarlo o rimuoverlo.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index f3d753e9..20412966 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1978,6 +1978,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get calendarCannotRemove => 'このカレンダーは、このアカウントから削除できません。'; + @override + String get calendarPendingChangesPreventRemoval => + 'このカレンダーを削除する前に、保留中の変更の同期が完了するまでお待ちください。'; + @override String get networkOffline => 'オフライン'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index e9e03241..6cd92b0a 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1979,6 +1979,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get calendarCannotRemove => '이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.'; + @override + String get calendarPendingChangesPreventRemoval => + '이 캘린더를 삭제하거나 제거하기 전에 보류 중인 변경 사항의 동기화가 완료될 때까지 기다리세요.'; + @override String get networkOffline => '오프라인'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index b493aa2c..cef690ec 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2017,6 +2017,10 @@ class AppLocalizationsPt extends AppLocalizations { String get calendarCannotRemove => 'Não é possível excluir nem remover esta agenda desta conta.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Aguarde a sincronização das alterações pendentes desta agenda antes de excluí-la ou removê-la.'; + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index b6c6265d..f7511b6a 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -2020,6 +2020,10 @@ class AppLocalizationsRu extends AppLocalizations { String get calendarCannotRemove => 'Этот календарь нельзя удалить из этой учётной записи.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Дождитесь синхронизации ожидающих изменений этого календаря, прежде чем удалять его.'; + @override String get networkOffline => 'Нет подключения'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 2198f829..1f31efb5 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -2006,6 +2006,10 @@ class AppLocalizationsVi extends AppLocalizations { @override String get calendarCannotRemove => 'Không thể xóa lịch này khỏi tài khoản.'; + @override + String get calendarPendingChangesPreventRemoval => + 'Hãy đợi các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.'; + @override String get networkOffline => 'Ngoại tuyến'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index b981e4d1..4dc5561c 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1966,6 +1966,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get calendarCannotRemove => '无法从此账号中删除或移除此日历。'; + @override + String get calendarPendingChangesPreventRemoval => + '请等待此日历的待处理更改同步完成,然后再删除或移除它。'; + @override String get networkOffline => '离线'; @@ -3934,6 +3938,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get calendarCannotRemove => '无法从此账号中删除或移除此日历。'; + @override + String get calendarPendingChangesPreventRemoval => + '请等待此日历的待处理更改同步完成,然后再删除或移除它。'; + @override String get networkOffline => '离线'; @@ -5903,6 +5911,10 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get calendarCannotRemove => '無法從此帳戶刪除或移除此日曆。'; + @override + String get calendarPendingChangesPreventRemoval => + '請等待此行事曆的待處理變更同步完成,再刪除或移除它。'; + @override String get networkOffline => '離線'; diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 47b22d77..53ec8738 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -818,16 +818,17 @@ final pendingCalendarMutationSyncRequesterForAccountProvider = final pendingOpResolutionServiceProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); - final apiClient = ref.watch(googleTasksApiClientProvider); - final syncEngine = ref.watch(syncEngineProvider); - if (accountId == null || apiClient == null || syncEngine == null) { + if (accountId == null) { return null; } return PendingOpResolutionService( database: ref.watch(databaseProvider), - apiClient: apiClient, + apiClient: ref.watch(googleTasksApiClientProvider), accountId: accountId, - syncEngine: syncEngine, + syncEngine: ref.watch(syncEngineProvider), + syncCalendar: () => ref + .read(accountSyncOperationsProvider) + .syncCalendar(accountId, full: false), ); }); diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 8a88d4eb..1d220044 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -20,6 +20,7 @@ const calendarMutationScopeKey = '_calendarMutationScope'; const calendarMutationScopeGlobal = 'global'; const calendarMutationScopePersonal = 'personal'; const calendarRemovalPreviousHiddenKey = '_previousHidden'; +const calendarPatchPreviousValuesKey = '_previousValues'; const calendarEventClearFieldsKey = '_clearFields'; const calendarEventRecurrenceField = 'recurrenceJson'; diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index d3875e5f..5046466c 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -239,17 +239,22 @@ class CalendarMutationNotAllowed implements Exception { const CalendarMutationNotAllowed({ required this.operation, required this.sourceId, + this.reason = CalendarMutationDenialReason.capability, }); final CalendarMutationOperation operation; final String sourceId; + final CalendarMutationDenialReason reason; @override String toString() { - return 'CalendarMutationNotAllowed(${operation.name}, source: $sourceId)'; + return 'CalendarMutationNotAllowed(' + '${operation.name}, source: $sourceId, reason: ${reason.name})'; } } +enum CalendarMutationDenialReason { capability, pendingChanges } + List writableCalendarSources( Iterable sources, ) { @@ -610,18 +615,27 @@ class CalendarRepository { final now = _now(); final nowUtc = now.toUtc().toIso8601String(); await _database.transaction(() async { - await (_database.delete(_database.pendingOps)..where( - (row) => - row.accountId.equals(source.accountId) & - row.calendarSourceId.equals(source.id), - )) - .go(); if (createOp != null) { + await (_database.delete(_database.pendingOps)..where( + (row) => + row.accountId.equals(source.accountId) & + row.calendarSourceId.equals(source.id), + )) + .go(); await (_database.delete( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).go(); return; } + if ((await _pendingCalendarWork(source.id)).isNotEmpty) { + throw CalendarMutationNotAllowed( + operation: removalMode == CalendarRemovalMode.removeFromList + ? CalendarMutationOperation.removeCalendar + : CalendarMutationOperation.deleteCalendar, + sourceId: source.id, + reason: CalendarMutationDenialReason.pendingChanges, + ); + } await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( @@ -734,6 +748,118 @@ class CalendarRepository { ); } + Future discardPendingCalendarCreation(PendingOp op) async { + if (_calendarOperationType(op) != 'calendar.create') return; + final sourceId = op.calendarSourceId; + if (sourceId == null) return; + await _database.transaction(() async { + final accountOperations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(op.accountId))).get(); + final discardedIds = {op.id}; + var changed = true; + while (changed) { + changed = false; + for (final operation in accountOperations) { + if (discardedIds.contains(operation.id) || + (operation.calendarSourceId != sourceId && + !discardedIds.contains(operation.dependsOnOpId))) { + continue; + } + discardedIds.add(operation.id); + changed = true; + } + } + for (final operationId in discardedIds) { + await _database.pendingOpsDao.deleteOp(operationId); + } + await (_database.delete( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).go(); + }); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + op.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + + Future restoreSourceAfterPatchDiscard(PendingOp op) async { + if (_calendarOperationType(op) != 'calendar.patch') return; + final sourceId = op.calendarSourceId; + if (sourceId == null) return; + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingleOrNull(); + if (source == null) return; + final request = _jsonMap(op.requestJson); + final previous = _jsonObjectMap(request[calendarPatchPreviousValuesKey]); + final baseline = _jsonMap(op.baselineRawJson); + final changesSummary = request.containsKey('summary'); + final changesColor = + request.containsKey('backgroundColor') || + request.containsKey('foregroundColor') || + request.containsKey('colorId'); + if (!changesSummary && !changesColor) return; + final provider = BusyProviderCodec.requireStorageValue(source.provider); + final baselineSummary = switch (provider) { + BusyProvider.google => + baseline['summaryOverride']?.toString().trim().isNotEmpty == true + ? baseline['summaryOverride']!.toString() + : baseline['summary']?.toString(), + BusyProvider.microsoft => baseline['name']?.toString(), + BusyProvider.appleICloud || BusyProvider.nextcloud => null, + }; + final baselineColorId = switch (provider) { + BusyProvider.google => baseline['colorId']?.toString(), + BusyProvider.microsoft => baseline['color']?.toString(), + BusyProvider.appleICloud || BusyProvider.nextcloud => null, + }; + final baselineBackground = switch (provider) { + BusyProvider.google => baseline['backgroundColor']?.toString(), + BusyProvider.microsoft => calendarSourceBackgroundColorHex( + provider: provider, + backgroundColor: baseline['hexColor']?.toString(), + colorId: baselineColorId, + ), + BusyProvider.appleICloud || BusyProvider.nextcloud => null, + }; + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(sourceId))).write( + CalendarSourcesCompanion( + summary: changesSummary + ? Value( + previous['summary']?.toString() ?? + baselineSummary ?? + source.summary, + ) + : const Value.absent(), + backgroundColor: changesColor + ? Value( + previous.containsKey('backgroundColor') + ? previous['backgroundColor']?.toString() + : baselineBackground, + ) + : const Value.absent(), + foregroundColor: changesColor + ? Value( + previous.containsKey('foregroundColor') + ? previous['foregroundColor']?.toString() + : baseline['foregroundColor']?.toString(), + ) + : const Value.absent(), + colorId: changesColor + ? Value( + previous.containsKey('colorId') + ? previous['colorId']?.toString() + : baselineColorId, + ) + : const Value.absent(), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + } + /// Stores a provider event. /// /// Pull syncs must preserve local optimistic state while an event is dirty or @@ -2817,6 +2943,15 @@ class CalendarRepository { .getSingleOrNull(); } + Future> _pendingCalendarWork(String sourceId) { + return (_database.select(_database.pendingOps)..where( + (row) => + row.calendarSourceId.equals(sourceId) & + row.state.equals('pending'), + )) + .get(); + } + Future _hasActiveCalendarRemoval(String sourceId) async { final operations = await (_database.select(_database.pendingOps)..where( @@ -2839,8 +2974,12 @@ class CalendarRepository { required String nowUtc, String? dependsOnOpId, }) async { + final queuedRequest = _calendarPatchRequestWithPreviousValues( + source, + request, + ); final requestedScope = - request[calendarMutationScopeKey]?.toString() ?? + queuedRequest[calendarMutationScopeKey]?.toString() ?? calendarMutationScopeGlobal; final candidates = await (_database.select(_database.pendingOps) @@ -2867,7 +3006,18 @@ class CalendarRepository { } if (existing != null) { final existingOp = existing; - final merged = _calendarPendingRequest(existingOp)..addAll(request); + final existingRequest = _calendarPendingRequest(existingOp); + final existingPrevious = _jsonObjectMap( + existingRequest[calendarPatchPreviousValuesKey], + ); + final queuedPrevious = _jsonObjectMap( + queuedRequest[calendarPatchPreviousValuesKey], + ); + final merged = existingRequest..addAll(queuedRequest); + merged[calendarPatchPreviousValuesKey] = { + ...queuedPrevious, + ...existingPrevious, + }; await (_database.update( _database.pendingOps, )..where((row) => row.id.equals(existingOp.id))).write( @@ -2890,7 +3040,7 @@ class CalendarRepository { calendarSourceId: Value(source.id), providerCalendarId: Value(source.providerCalendarId), dependsOnOpId: Value(dependsOnOpId), - requestJson: jsonEncode(request), + requestJson: jsonEncode(queuedRequest), baselineRawJson: Value(source.rawJson), createdAtUtc: nowUtc, updatedAtUtc: nowUtc, @@ -3103,6 +3253,32 @@ Map _calendarPendingRequest(PendingOp op) { return decoded.cast(); } +String _calendarOperationType(PendingOp op) { + return op.operationType ?? '${op.entityType}.${op.operation}'; +} + +Map _calendarPatchRequestWithPreviousValues( + CalendarSource source, + Map request, +) { + final queued = {...request}; + final previous = {}; + if (request.containsKey('summary')) { + previous['summary'] = source.summary; + } + if (request.containsKey('backgroundColor') || + request.containsKey('foregroundColor') || + request.containsKey('colorId')) { + previous['backgroundColor'] = source.backgroundColor; + previous['foregroundColor'] = source.foregroundColor; + previous['colorId'] = source.colorId; + } + if (previous.isNotEmpty) { + queued[calendarPatchPreviousValuesKey] = previous; + } + return queued; +} + bool _calendarRemovalPreviousHidden(PendingOp op) { final requestValue = _jsonMap( op.requestJson, diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index aa18ddfb..73c6152f 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -1001,15 +1001,17 @@ Future _deleteCalendar( _requestCalendarMutationSync(ref, source.accountId); } on Object catch (error) { if (context.mounted) { - _showCalendarMutationFailure( - context, - context.l10n.calendarDeleteFailed( - syncFailureMessage( - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), - ), - ); + final message = + error is CalendarMutationNotAllowed && + error.reason == CalendarMutationDenialReason.pendingChanges + ? context.l10n.calendarPendingChangesPreventRemoval + : context.l10n.calendarDeleteFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ); + _showCalendarMutationFailure(context, message); } } } diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 9f29b387..9647e684 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -11,25 +11,30 @@ import 'sync_engine.dart'; class PendingOpResolutionService { PendingOpResolutionService({ required AppDatabase database, - required TaskRemoteClient apiClient, + TaskRemoteClient? apiClient, required String accountId, - required SyncEngine syncEngine, + SyncEngine? syncEngine, + Future Function()? syncCalendar, DateTime Function()? nowUtc, }) : _database = database, _apiClient = apiClient, _accountId = accountId, _syncEngine = syncEngine, + _syncCalendar = syncCalendar, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); final AppDatabase _database; - final TaskRemoteClient _apiClient; + final TaskRemoteClient? _apiClient; final String _accountId; - final SyncEngine _syncEngine; + final SyncEngine? _syncEngine; + final Future Function()? _syncCalendar; final DateTime Function() _nowUtc; Future retryNow(String opId) async { + final op = await _database.pendingOpsDao.getOp(opId); + if (op == null) return; await _database.pendingOpsDao.retryNow(opId, _nowUtc()); - await _syncEngine.incrementalSync(); + await _syncAfterResolution(op); } Future discard(String opId) async { @@ -38,32 +43,66 @@ class PendingOpResolutionService { return; } - await _refreshOrRemoveLocalState(op); + final syncAfterDiscard = await _refreshOrRemoveLocalState(op); await _database.pendingOpsDao.deleteOp(op.id); - await _syncEngine.incrementalSync(); + if (syncAfterDiscard) { + await _syncAfterResolution(op); + } } - Future _refreshOrRemoveLocalState(PendingOp op) async { + Future _refreshOrRemoveLocalState(PendingOp op) async { if (op.entityType == 'calendar') { - await CalendarRepository( - database: _database, - now: _nowUtc, - ).restoreSourceAfterRemovalFailure(op); - return; + final repository = CalendarRepository(database: _database, now: _nowUtc); + switch (_operationType(op)) { + case 'calendar.create': + await repository.discardPendingCalendarCreation(op); + return false; + case 'calendar.delete' || 'calendar.remove': + await repository.restoreSourceAfterRemovalFailure(op); + return true; + case 'calendar.patch': + await repository.restoreSourceAfterPatchDiscard(op); + return true; + } + return false; } if (op.entityType == 'task' && op.taskListId != null && op.taskId != null) { if (op.operation == 'move_task') { await _refreshOrRemoveMovedTask(op); - return; + return true; } await _refreshOrRemoveTask(op.taskListId!, op.taskId!); - return; + return true; } if (op.entityType == 'task_list' && op.taskListId != null) { await _refreshOrRemoveTaskList(op.taskListId!); + return true; + } + return false; + } + + Future _syncAfterResolution(PendingOp op) async { + if (op.entityType == 'calendar') { + final syncCalendar = _syncCalendar; + if (syncCalendar != null) { + await syncCalendar(); + return; + } + } + final syncEngine = _syncEngine; + if (syncEngine == null) { + throw StateError( + 'No synchronization path is available for this operation.', + ); } + await syncEngine.incrementalSync(); + } + + TaskRemoteClient get _requiredTaskClient { + return _apiClient ?? + (throw StateError('Task operation recovery is unavailable.')); } Future _refreshOrRemoveMovedTask(PendingOp op) async { @@ -72,7 +111,7 @@ class PendingOpResolutionService { final destinationTaskListId = _destinationTaskListId(op); try { - final dto = await _apiClient.getTask( + final dto = await _requiredTaskClient.getTask( taskListId: sourceTaskListId, taskId: taskId, ); @@ -105,7 +144,7 @@ class PendingOpResolutionService { Future _refreshOrRemoveTask(String taskListId, String taskId) async { try { - final dto = await _apiClient.getTask( + final dto = await _requiredTaskClient.getTask( taskListId: taskListId, taskId: taskId, ); @@ -122,7 +161,7 @@ class PendingOpResolutionService { Future _refreshOrRemoveTaskList(String taskListId) async { try { - final dto = await _apiClient.getTaskList(taskListId); + final dto = await _requiredTaskClient.getTaskList(taskListId); await _database.taskListsDao.upsertTaskList( taskListFromDto(_accountId, dto, _now()), ); @@ -139,5 +178,9 @@ class PendingOpResolutionService { return request['destinationTasklist']?.toString(); } + String _operationType(PendingOp op) { + return op.operationType ?? '${op.entityType}.${op.operation}'; + } + String _now() => _nowUtc().toIso8601String(); } diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index 43c8e69c..eb0dc99e 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -231,6 +231,11 @@ void main() { 'backgroundColor': '#3584e4', 'foregroundColor': '#000000', calendarMutationScopeKey: calendarMutationScopePersonal, + calendarPatchPreviousValuesKey: { + 'backgroundColor': null, + 'foregroundColor': null, + 'colorId': null, + }, }); }); @@ -246,6 +251,48 @@ void main() { expect(await database.select(database.pendingOps).get(), isEmpty); }); + test( + 'calendar removal waits for pending event mutations without losing them', + () async { + await _upsertSource(repository); + await repository.createLocalEvent( + _newEventDraft().copyWith(title: 'Pending event'), + ); + final pendingBefore = await database.select(database.pendingOps).get(); + + await expectLater( + repository.deleteLocalSource(_sourceId), + throwsA( + isA() + .having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.deleteCalendar, + ) + .having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.pendingChanges, + ), + ), + ); + + final source = await database + .select(database.calendarSources) + .getSingle(); + final event = await database.select(database.calendarEvents).getSingle(); + final pendingAfter = await database.select(database.pendingOps).get(); + expect(source.isDeleted, isFalse); + expect(source.hidden, isFalse); + expect(event.syncStatus, 'pending'); + expect( + pendingAfter.map((operation) => operation.id), + pendingBefore.map((operation) => operation.id), + ); + expect(pendingAfter.single.operationType, 'event.create'); + }, + ); + test('DAV calendar management is rejected before local mutation', () async { await database .into(database.accounts) @@ -1440,6 +1487,10 @@ Future _seedScheduledEvent( }, ), ); + await database.delete(database.pendingOps).go(); + await database + .update(database.calendarEvents) + .write(const CalendarEventsCompanion(syncStatus: Value('synced'))); expect( await database.select(database.notificationSchedule).get(), hasLength(1), diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 9a1746f2..4252483c 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -1,31 +1,38 @@ import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.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/pending_op_resolution_service.dart'; import 'package:busymax/src/features/sync/sync_engine.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_client.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_error.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; void main() { late AppDatabase database; late _FakeTaskRemoteClient apiClient; late _FakeSyncEngine syncEngine; late PendingOpResolutionService service; + late int calendarSyncCalls; setUp(() async { database = AppDatabase(NativeDatabase.memory()); apiClient = _FakeTaskRemoteClient(); syncEngine = _FakeSyncEngine(); + calendarSyncCalls = 0; service = PendingOpResolutionService( database: database, apiClient: apiClient, accountId: 'account', syncEngine: syncEngine, + syncCalendar: () async => calendarSyncCalls += 1, nowUtc: () => DateTime.utc(2026, 6, 4), ); @@ -372,7 +379,96 @@ void main() { expect(source.isDeleted, isFalse); expect(source.hidden, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(calendarSyncCalls, 1); + expect(syncEngine.incrementalSyncCalls, 0); + }); + + test( + 'discard blocked calendar creation removes its temporary source', + () async { + final repository = _calendarRepository(database); + final sourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Temporary calendar', + ); + final operation = await database.select(database.pendingOps).getSingle(); + await _blockOperation(database, operation.id); + + await service.discard(operation.id); + + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 0); + expect(syncEngine.incrementalSyncCalls, 0); + expect(sourceId, isNotEmpty); + }, + ); + + test( + 'discard blocked calendar creation removes dependent events and operations', + () async { + final repository = _calendarRepository(database); + final sourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Temporary calendar', + ); + final source = await database + .select(database.calendarSources) + .getSingle(); + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: sourceId, + providerCalendarId: source.providerCalendarId, + start: DateTime.utc(2026, 6, 5, 9), + end: DateTime.utc(2026, 6, 5, 10), + ).copyWith(title: 'Dependent event'), + ); + final operations = await database.select(database.pendingOps).get(); + final calendarCreate = operations.singleWhere( + (operation) => operation.operationType == 'calendar.create', + ); + final eventCreate = operations.singleWhere( + (operation) => operation.operationType == 'event.create', + ); + expect(eventCreate.dependsOnOpId, calendarCreate.id); + await _blockOperation(database, calendarCreate.id); + + await service.discard(calendarCreate.id); + + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 0); + expect(syncEngine.incrementalSyncCalls, 0); + }, + ); + + test('discard blocked calendar patch restores optimistic metadata', () async { + final repository = _calendarRepository(database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + summary: 'Provider name', + dataOwner: 'google-account@example.com', + rawJson: {'id': 'calendar@example.com', 'summary': 'Provider name'}, + ), + ); + const sourceId = 'account|google|calendar@example.com'; + await repository.renameLocalSource(sourceId, 'Optimistic name'); + final operation = await database.select(database.pendingOps).getSingle(); + await _blockOperation(database, operation.id); + + await service.discard(operation.id); + + final source = await database.select(database.calendarSources).getSingle(); + expect(source.summary, 'Provider name'); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 1); + expect(syncEngine.incrementalSyncCalls, 0); }); } @@ -429,12 +525,30 @@ Future _insertAccount(AppDatabase database) { authority: 'https://accounts.google.com', providerAccountId: 'google-account', credentialKind: 'oauth', + email: const Value('google-account@example.com'), createdAtUtc: _now, updatedAtUtc: _now, ), ); } +CalendarRepository _calendarRepository(AppDatabase database) { + return CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 4), + ); +} + +Future _blockOperation(AppDatabase database, String operationId) { + return database.pendingOpsDao.updateAttempt( + id: operationId, + attemptCount: 1, + nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + lastErrorCode: 'provider_rejected', + lastErrorMessage: 'Provider rejected the operation.', + ); +} + TaskListsCompanion _localTaskList( String id, { String title = 'List', From e8ce6833dcfed5d5bc01a6aedf9cb8cad9c66d0e Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 14:22:13 -0700 Subject: [PATCH 07/69] feat: add support for moving events across calendars with validation for pending creations --- .../calendar/data/calendar_repository.dart | 14 +- .../calendar/presentation/event_editor.dart | 40 ++-- .../presentation/event_editor_test.dart | 75 +++++++ .../calendar_pending_ops_replayer_test.dart | 200 ++++++++++++++++++ 4 files changed, 314 insertions(+), 15 deletions(-) diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 5046466c..94ef9f59 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -228,6 +228,7 @@ enum CalendarMutationOperation { createCalendar, createEvent, editEvent, + moveEvent, deleteEvent, renameCalendar, changeCalendarColor, @@ -253,7 +254,11 @@ class CalendarMutationNotAllowed implements Exception { } } -enum CalendarMutationDenialReason { capability, pendingChanges } +enum CalendarMutationDenialReason { + capability, + pendingChanges, + destinationPendingCreate, +} List writableCalendarSources( Iterable sources, @@ -1346,6 +1351,13 @@ class CalendarRepository { required EventEditorDraft draft, required CalendarGuestUpdatePolicy guestUpdatePolicy, }) async { + if (await _pendingCalendarCreate(destinationSource.id) != null) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.moveEvent, + sourceId: destinationSource.id, + reason: CalendarMutationDenialReason.destinationPendingCreate, + ); + } final sourceProvider = BusyProviderCodec.requireStorageValue( originalSource.provider, ); diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 0f37658c..5e1de344 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -325,6 +325,17 @@ class _EventEditorState extends State { var _startTimeValid = true; var _endTimeValid = true; + List get _selectableSources { + if (widget.initialDraft.eventId == null) { + return widget.sources; + } + return [ + for (final source in widget.sources) + if (!source.pendingCreate || source.id == widget.initialDraft.sourceId) + source, + ]; + } + @override void initState() { super.initState(); @@ -346,7 +357,7 @@ class _EventEditorState extends State { ? l10n.newEvent : l10n.editEvent; CalendarSourceEntity? currentSource; - for (final source in widget.sources) { + for (final source in _selectableSources) { if (source.id == _draft.sourceId) { currentSource = source; break; @@ -844,13 +855,14 @@ class _EventEditorState extends State { Widget _accountRow() { final accountIds = - {for (final source in widget.sources) source.accountId}.toList() - ..sort((first, second) { - final labelOrder = _accountLabel( - first, - ).toLowerCase().compareTo(_accountLabel(second).toLowerCase()); - return labelOrder != 0 ? labelOrder : first.compareTo(second); - }); + { + for (final source in _selectableSources) source.accountId, + }.toList()..sort((first, second) { + final labelOrder = _accountLabel( + first, + ).toLowerCase().compareTo(_accountLabel(second).toLowerCase()); + return labelOrder != 0 ? labelOrder : first.compareTo(second); + }); final selected = accountIds.contains(_draft.accountId) ? _draft.accountId : accountIds.first; @@ -867,7 +879,7 @@ class _EventEditorState extends State { Widget _calendarRow() { final sources = [ - for (final source in widget.sources) + for (final source in _selectableSources) if (source.accountId == _draft.accountId) source, ]..sort(_compareCalendarSources); if (sources.isEmpty) { @@ -913,7 +925,7 @@ class _EventEditorState extends State { return candidate.selectorLabel; } } - for (final source in widget.sources) { + for (final source in _selectableSources) { if (source.accountId == accountId) return source.provider.displayName; } return context.l10n.account; @@ -922,7 +934,7 @@ class _EventEditorState extends State { void _selectAccount(String accountId) { if (accountId == _draft.accountId) return; final sources = [ - for (final source in widget.sources) + for (final source in _selectableSources) if (source.accountId == accountId) source, ]..sort(_compareCalendarSources); if (sources.isEmpty) return; @@ -939,7 +951,7 @@ class _EventEditorState extends State { } void _selectCalendarSource(CalendarSourceEntity source) { - final currentProvider = _providerForDraft(_draft, widget.sources); + final currentProvider = _providerForDraft(_draft, _selectableSources); Object? adjustedRecurrence; var clearRecurrence = false; if (_draft.recurrence != null && @@ -1023,7 +1035,7 @@ class _EventEditorState extends State { } CalendarSourceEntity? _sourceForId(String sourceId) { - for (final source in widget.sources) { + for (final source in _selectableSources) { if (source.id == sourceId) return source; } return null; @@ -1058,7 +1070,7 @@ class _EventEditorState extends State { final move = _eventMoveContext( initialDraft: widget.initialDraft, draft: draft.copyWith(recurringMutationScope: scope), - sources: widget.sources, + sources: _selectableSources, ); if (move == null || move.strategy != CalendarEventMoveStrategy.copyThenDelete) { diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index d2e24ce8..2e86d019 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1517,6 +1517,38 @@ void main() { expect(accountRow.enabled, isFalse); }); + testWidgets( + 'existing event excludes calendars whose creation is still pending', + (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + sources: _sourcesWithPendingCalendar, + onCancel: () {}, + onSave: (_) {}, + onDelete: (_, _) {}, + ), + ), + ), + ); + + final calendarRow = _comboRow(tester, 'Calendar'); + expect(calendarRow.values, ['destination-source', 'source']); + expect(calendarRow.values, isNot(contains('pending-source'))); + }, + ); + testWidgets('new event can still select any visible calendar', ( tester, ) async { @@ -1550,6 +1582,32 @@ void main() { expect(calendarRow.enabled, isTrue); }); + testWidgets('new event can select a calendar whose creation is pending', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + sources: _sourcesWithPendingCalendar, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + final calendarRow = _comboRow(tester, 'Calendar'); + expect(calendarRow.values, contains('pending-source')); + }); + testWidgets( 'new event selects an account before choosing among its calendars', (tester) async { @@ -2685,6 +2743,23 @@ const _multipleSources = [ ), ]; +const _sourcesWithPendingCalendar = [ + ..._multipleSources, + CalendarSourceEntity( + id: 'pending-source', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'local:pending-calendar', + summary: 'Project', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + pendingCreate: true, + backgroundColor: '#f6d32d', + ), +]; + const _nextcloudSources = [ CalendarSourceEntity( id: 'nextcloud-source', diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 65105c65..64905350 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -337,6 +337,188 @@ void main() { ]); }); + test('native Google move rejects a pending-created destination', () async { + final repository = CalendarRepository(database: database); + final destinationId = await repository.createLocalSource( + accountId: 'account', + summary: 'Pending destination', + ); + final destination = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingle(); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'account', + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + title: 'Moved', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA() + .having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.moveEvent, + ) + .having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.destinationPendingCreate, + ), + ), + ); + + final operations = await database.select(database.pendingOps).get(); + expect(operations, hasLength(1)); + expect(operations.single.operationType, 'calendar.create'); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).syncStatus, + 'synced', + ); + + await repository.deleteLocalSource(destinationId); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingleOrNull(), + equals(null), + ); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).syncStatus, + 'synced', + ); + }); + + test( + 'same-account copy move rejects a pending-created destination', + () async { + final repository = CalendarRepository(database: database); + final destinationId = await repository.createLocalSource( + accountId: 'account', + summary: 'Pending destination', + ); + final destination = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingle(); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-occurrence', + providerRecurringEventId: 'provider-series', + ); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + providerRecurringEventId: 'provider-series', + recurringMutationScope: + RecurringEventMutationScope.singleOccurrence, + accountId: 'account', + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + title: 'Copied occurrence', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA().having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.destinationPendingCreate, + ), + ), + ); + + final createOperation = await database + .select(database.pendingOps) + .getSingle(); + await repository.discardPendingCalendarCreation(createOperation); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingleOrNull(), + equals(null), + ); + final original = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(original.syncStatus, 'synced'); + expect(original.isDeleted, isFalse); + }, + ); + + test( + 'cross-account copy move rejects a pending-created destination', + () async { + await _insertDestinationGoogleAccount(database); + final repository = CalendarRepository(database: database); + final destinationId = await repository.createLocalSource( + accountId: 'destination-account', + summary: 'Pending destination', + ); + final destination = await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(destinationId))).getSingle(); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + ); + + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'destination-account', + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + title: 'Cross-account copy', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ), + ), + throwsA( + isA().having( + (error) => error.reason, + 'reason', + CalendarMutationDenialReason.destinationPendingCreate, + ), + ), + ); + + final operations = await database.select(database.pendingOps).get(); + expect(operations, hasLength(1)); + expect(operations.single.operationType, 'calendar.create'); + expect(operations.single.accountId, 'destination-account'); + final original = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(original.syncStatus, 'synced'); + expect(original.isDeleted, isFalse); + }, + ); + test('native Google series move removes stale source occurrences', () async { final repository = CalendarRepository(database: database); await repository.upsertSource( @@ -1661,6 +1843,24 @@ Future _insertAccount(AppDatabase database) { ); } +Future _insertDestinationGoogleAccount(AppDatabase database) { + return database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'destination-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'destination-google-account', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); +} + Future _insertMicrosoftAccountAndSource( AppDatabase database, { bool? isRemovable, From 94cfaa8b61697cc309beb129d5c7672b62c6e9d6 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 14:50:55 -0700 Subject: [PATCH 08/69] feat: introduce role-based menu entry handling with validation for command, radio, and toggle types --- lib/src/app/busymax_design.dart | 88 +++++++++---- .../presentation/schedule_sidebar.dart | 1 + .../presentation/schedule_toolbar.dart | 1 + .../presentation/settings_screen.dart | 1 + lib/src/platform/native_menu_service.dart | 7 +- linux/io.busystack.busymax.metainfo.xml | 5 + linux/runner/my_application.cc | 91 ++++++++++--- pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/app/busymax_menu_button_test.dart | 120 +++++++++++++++++- .../schedule_create_menu_test.dart | 2 + .../presentation/schedule_toolbar_test.dart | 2 + test/platform/native_menu_service_test.dart | 15 ++- 13 files changed, 290 insertions(+), 47 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 1f27c0fd..f5d626a9 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -2173,6 +2173,7 @@ class BusyMaxComboRow extends StatelessWidget { BusyMaxMenuEntry( value: value, label: labelFor(value), + role: BusyMaxMenuEntryRole.radio, selected: value == selected, ), ], @@ -2336,6 +2337,7 @@ class BusyMaxMenuEntry { this.child, this.shortcut, this.enabled = true, + this.role = BusyMaxMenuEntryRole.command, this.selected = false, this.tooltip, this.destructive = false, @@ -2347,11 +2349,14 @@ class BusyMaxMenuEntry { final Widget? child; final String? shortcut; final bool enabled; + final BusyMaxMenuEntryRole role; final bool selected; final String? tooltip; final bool destructive; } +enum BusyMaxMenuEntryRole { command, radio, toggle } + @immutable final class BusyMaxMenuSelection { const BusyMaxMenuSelection(this.value); @@ -2488,19 +2493,31 @@ Future?> showBusyMaxMenu({ } void _validateBusyMaxMenuEntries(List> entries) { - final selectedCount = entries.where((entry) => entry.selected).length; - if (selectedCount > 1) { + if (entries.any( + (entry) => entry.role == BusyMaxMenuEntryRole.command && entry.selected, + )) { + throw ArgumentError.value( + entries, + 'entries', + 'Command menu entries cannot be selected.', + ); + } + final radioEntries = entries + .where((entry) => entry.role == BusyMaxMenuEntryRole.radio) + .toList(growable: false); + if (radioEntries.isNotEmpty && + radioEntries.where((entry) => entry.selected).length != 1) { throw ArgumentError.value( entries, 'entries', - 'A single-choice menu can have only one selected entry.', + 'A single-choice menu must have exactly one selected radio entry.', ); } - if (selectedCount == 1 && entries.any((entry) => !entry.enabled)) { + if (radioEntries.any((entry) => !entry.enabled)) { throw ArgumentError.value( entries, 'entries', - 'Single-choice menu entries must all be enabled.', + 'Single-choice radio entries must all be enabled.', ); } } @@ -2538,6 +2555,11 @@ List _nativeMenuEntries(List> entries) { label: entry.label, iconName: BusyMaxGlyphs.nativeMenuIconName(entry.icon), enabled: entry.enabled, + role: switch (entry.role) { + BusyMaxMenuEntryRole.command => NativeMenuEntryRole.command, + BusyMaxMenuEntryRole.radio => NativeMenuEntryRole.radio, + BusyMaxMenuEntryRole.toggle => NativeMenuEntryRole.toggle, + }, selected: entry.selected, shortcut: entry.shortcut, ), @@ -2577,8 +2599,9 @@ Future _showBusyMaxFlutterMenu({ localAnchor.width, 0, ); - final hasSelectedEntry = entries.any((entry) => entry.selected); - final selectedIndex = entries.indexWhere((entry) => entry.selected); + final selectedRadioIndex = entries.indexWhere( + (entry) => entry.role == BusyMaxMenuEntryRole.radio && entry.selected, + ); final firstEnabledIndex = entries.indexWhere((entry) => entry.enabled); final firstEnabledKey = focusFirst && firstEnabledIndex >= 0 ? GlobalKey() @@ -2597,18 +2620,28 @@ Future _showBusyMaxFlutterMenu({ child: _busyMaxFocusableFallbackEntry( context, entries[index], - selectionIndicator: hasSelectedEntry - ? ExcludeSemantics( - child: IgnorePointer( - child: YaruRadio( - value: index, - groupValue: selectedIndex, - onChanged: (_) {}, - hasFocusBorder: false, - ), - ), - ) - : null, + selectionIndicator: switch (entries[index].role) { + BusyMaxMenuEntryRole.command => null, + BusyMaxMenuEntryRole.radio => ExcludeSemantics( + child: IgnorePointer( + child: YaruRadio( + value: index, + groupValue: selectedRadioIndex, + onChanged: (_) {}, + hasFocusBorder: false, + ), + ), + ), + BusyMaxMenuEntryRole.toggle => ExcludeSemantics( + child: IgnorePointer( + child: YaruCheckbox( + value: entries[index].selected, + onChanged: (_) {}, + hasFocusBorder: false, + ), + ), + ), + }, focusKey: index == firstEnabledIndex ? firstEnabledKey : null, routeKey: index == 0 ? routeKey : null, ), @@ -2678,12 +2711,17 @@ Widget _busyMaxFocusableFallbackEntry( entry, selectionIndicator: selectionIndicator, ); - if (selectionIndicator != null) { - child = Semantics( - selected: entry.selected, - inMutuallyExclusiveGroup: true, - child: child, - ); + switch (entry.role) { + case BusyMaxMenuEntryRole.command: + break; + case BusyMaxMenuEntryRole.radio: + child = Semantics( + selected: entry.selected, + inMutuallyExclusiveGroup: true, + child: child, + ); + case BusyMaxMenuEntryRole.toggle: + child = Semantics(checked: entry.selected, child: child); } if (focusKey != null) { child = KeyedSubtree(key: focusKey, child: child); diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 73c6152f..807f1b9b 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -152,6 +152,7 @@ class _SourceRow extends ConsumerWidget { value: 'toggle-reminders', label: context.l10n.eventReminders, icon: Icons.notifications_outlined, + role: BusyMaxMenuEntryRole.toggle, selected: source.remindersEnabled, ), BusyMaxMenuEntry( diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 914c92a2..84e16a11 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -145,6 +145,7 @@ class ScheduleToolbar extends StatelessWidget { value: value, label: _modeLabel(context, value), icon: _modeIcon(value), + role: BusyMaxMenuEntryRole.radio, selected: mode == value, shortcut: BusyMaxShortcutLabels.forViewMode(value), ), diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index e2192d5e..0064122e 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -864,6 +864,7 @@ class _SettingsPageSelector extends StatelessWidget { value: page, label: _settingsPageLabel(context, page), icon: _settingsPageIcon(page), + role: BusyMaxMenuEntryRole.radio, selected: page == selected, ), ], diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart index e365e1be..537ab525 100644 --- a/lib/src/platform/native_menu_service.dart +++ b/lib/src/platform/native_menu_service.dart @@ -17,7 +17,9 @@ final class NativeMenuSession { final int id; } -/// One command exposed by a host-toolkit menu. +enum NativeMenuEntryRole { command, radio, toggle } + +/// One entry exposed by a host-toolkit menu. /// /// Entries intentionally contain presentation state only. The selected index /// is mapped back to the caller's domain value after the native menu closes. @@ -27,6 +29,7 @@ final class NativeMenuEntry { required this.label, this.iconName, this.enabled = true, + this.role = NativeMenuEntryRole.command, this.selected = false, this.shortcut, }); @@ -34,6 +37,7 @@ final class NativeMenuEntry { final String label; final String? iconName; final bool enabled; + final NativeMenuEntryRole role; final bool selected; final String? shortcut; @@ -42,6 +46,7 @@ final class NativeMenuEntry { 'label': label, if (iconName != null && iconName!.isNotEmpty) 'icon': iconName!, 'enabled': enabled, + 'role': role.name, 'selected': selected, if (shortcut != null && shortcut!.isNotEmpty) 'shortcut': shortcut!, }; diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index aac5688f..4133a5c2 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -91,6 +91,11 @@ + + +

Beta maintenance release.

+
+

Beta maintenance release.

diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index f7d00882..e2d24e8f 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1380,8 +1380,29 @@ struct NativeMenuSession { struct NativeMenuHandlerData { GtkWidget* view; NativeMenuSession* active; + GdkEvent* trigger_event; + gulong trigger_event_signal_id; }; +static void native_menu_event_after_cb(GtkWidget*, + GdkEvent* event, + gpointer user_data) { + switch (event->type) { + case GDK_BUTTON_PRESS: + case GDK_BUTTON_RELEASE: + case GDK_KEY_PRESS: + case GDK_KEY_RELEASE: + case GDK_TOUCH_BEGIN: + case GDK_TOUCH_END: + break; + default: + return; + } + auto* data = static_cast(user_data); + g_clear_pointer(&data->trigger_event, gdk_event_free); + data->trigger_event = gdk_event_copy(event); +} + static void native_menu_session_respond(NativeMenuSession* session, gint selected_index) { if (session->method_call == nullptr) { @@ -1461,6 +1482,12 @@ static void native_menu_action_activated_cb(GSimpleAction* action, GVariant*, gpointer user_data) { auto* session = static_cast(user_data); + g_autoptr(GVariant) state = g_action_get_state(G_ACTION(action)); + if (state != nullptr && + g_variant_is_of_type(state, G_VARIANT_TYPE_BOOLEAN)) { + g_simple_action_set_state( + action, g_variant_new_boolean(!g_variant_get_boolean(state))); + } session->pending_selected_index = GPOINTER_TO_INT( g_object_get_data(G_OBJECT(action), kNativeMenuActionIndexKey)) - @@ -1675,38 +1702,51 @@ static void show_native_menu(NativeMenuHandlerData* data, return; } - size_t selected_entry_count = 0; - gboolean has_disabled_entry = FALSE; + size_t radio_entry_count = 0; + size_t selected_radio_entry_count = 0; + gboolean has_disabled_radio_entry = FALSE; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); const gchar* label = fl_lookup_string_arg(entry, "label"); FlValue* icon = fl_value_lookup_string(entry, "icon"); FlValue* shortcut = fl_value_lookup_string(entry, "shortcut"); + const gchar* role = fl_lookup_string_arg(entry, "role"); gboolean enabled = TRUE; gboolean selected = FALSE; if (label == nullptr || (icon != nullptr && fl_value_get_type(icon) != FL_VALUE_TYPE_STRING) || (shortcut != nullptr && fl_value_get_type(shortcut) != FL_VALUE_TYPE_STRING) || + (role != nullptr && g_strcmp0(role, "command") != 0 && + g_strcmp0(role, "radio") != 0 && g_strcmp0(role, "toggle") != 0) || !fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled) || !fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected)) { respond_native_menu_argument_error( method_call, "each entry must contain a label, optional string icon and shortcut, " - "and optional boolean enabled and selected values."); + "a command, radio, or toggle role, and optional boolean enabled and " + "selected values."); return; } - if (selected) { - selected_entry_count++; + const gboolean is_radio = g_strcmp0(role, "radio") == 0; + const gboolean is_toggle = g_strcmp0(role, "toggle") == 0; + if (selected && !is_radio && !is_toggle) { + respond_native_menu_argument_error( + method_call, "command entries cannot be selected."); + return; + } + if (is_radio) { + radio_entry_count++; + selected_radio_entry_count += selected ? 1 : 0; + has_disabled_radio_entry = has_disabled_radio_entry || !enabled; } - has_disabled_entry = has_disabled_entry || !enabled; } - if (selected_entry_count > 1 || - (selected_entry_count == 1 && has_disabled_entry)) { + if ((radio_entry_count > 0 && selected_radio_entry_count != 1) || + has_disabled_radio_entry) { respond_native_menu_argument_error( method_call, - "single-choice menus require exactly one selected entry and all " - "entries enabled."); + "single-choice menus require exactly one selected radio entry and all " + "radio entries enabled."); return; } @@ -1727,13 +1767,14 @@ static void show_native_menu(NativeMenuHandlerData* data, GSimpleAction* selection_action = nullptr; g_autofree gchar* detailed_selection_action = nullptr; - if (selected_entry_count == 1) { + if (radio_entry_count > 0) { g_autofree gchar* selected_target = nullptr; for (size_t index = 0; index < fl_value_get_length(entries); index++) { FlValue* entry = fl_value_get_list_value(entries, index); gboolean selected = FALSE; fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); - if (selected) { + if (selected && + g_strcmp0(fl_lookup_string_arg(entry, "role"), "radio") == 0) { selected_target = g_strdup_printf("%zu", index); break; } @@ -1755,17 +1796,24 @@ static void show_native_menu(NativeMenuHandlerData* data, const gchar* label = fl_lookup_string_arg(entry, "label"); const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); + const gchar* role = fl_lookup_string_arg(entry, "role"); gboolean enabled = TRUE; + gboolean selected = FALSE; fl_lookup_optional_bool_arg(entry, "enabled", TRUE, &enabled); + fl_lookup_optional_bool_arg(entry, "selected", FALSE, &selected); g_autoptr(GMenuItem) item = g_menu_item_new(label, nullptr); - if (selection_action != nullptr) { + if (g_strcmp0(role, "radio") == 0) { g_autofree gchar* target = g_strdup_printf("%zu", index); g_menu_item_set_action_and_target_value( item, detailed_selection_action, g_variant_new_string(target)); } else { g_autofree gchar* action_name = g_strdup_printf("select-%zu", index); - GSimpleAction* action = g_simple_action_new(action_name, nullptr); + GSimpleAction* action = g_strcmp0(role, "toggle") == 0 + ? g_simple_action_new_stateful( + action_name, nullptr, + g_variant_new_boolean(selected)) + : g_simple_action_new(action_name, nullptr); g_simple_action_set_enabled(action, enabled); g_object_set_data(G_OBJECT(action), kNativeMenuActionIndexKey, GINT_TO_POINTER(static_cast(index) + 1)); @@ -1826,10 +1874,16 @@ static void show_native_menu(NativeMenuHandlerData* data, // widget whose GdkWindow belongs to the toplevel. Anchor to the translated // rectangle directly: moving a hidden proxy widget would only queue a later // size allocation, so an immediate popup would still see its old position. + g_autoptr(GdkEvent) current_event = gtk_get_current_event(); + const GdkEvent* trigger_event = current_event != nullptr + ? current_event + : data->trigger_event; gtk_menu_popup_at_rect( GTK_MENU(session->menu), rect_window, &window_anchor, open_above ? GDK_GRAVITY_NORTH_WEST : GDK_GRAVITY_SOUTH_WEST, - open_above ? GDK_GRAVITY_SOUTH_WEST : GDK_GRAVITY_NORTH_WEST, nullptr); + open_above ? GDK_GRAVITY_SOUTH_WEST : GDK_GRAVITY_NORTH_WEST, + trigger_event); + g_clear_pointer(&data->trigger_event, gdk_event_free); if (focus_first) { gtk_menu_shell_select_first(GTK_MENU_SHELL(session->menu), TRUE); } else { @@ -1844,10 +1898,15 @@ static void native_menu_handler_data_free(gpointer user_data) { native_menu_session_dispose(data->active); } if (data->view != nullptr) { + if (data->trigger_event_signal_id != 0) { + g_signal_handler_disconnect(data->view, + data->trigger_event_signal_id); + } g_object_remove_weak_pointer( G_OBJECT(data->view), reinterpret_cast(&data->view)); } + g_clear_pointer(&data->trigger_event, gdk_event_free); g_free(data); } @@ -1882,6 +1941,8 @@ static FlMethodChannel* create_native_menu_channel(FlView* view) { data->view = GTK_WIDGET(view); g_object_add_weak_pointer(G_OBJECT(data->view), reinterpret_cast(&data->view)); + data->trigger_event_signal_id = g_signal_connect( + data->view, "event-after", G_CALLBACK(native_menu_event_after_cb), data); fl_method_channel_set_method_call_handler( channel, native_menu_method_call_cb, data, native_menu_handler_data_free); diff --git a/pubspec.yaml b/pubspec.yaml index b4ba8db2..73483f82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.1.6 +version: 0.1.7 environment: sdk: ^3.12.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index c55c277f..3b1b5b57 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.1.6" +version: "0.1.7" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. diff --git a/test/app/busymax_menu_button_test.dart b/test/app/busymax_menu_button_test.dart index f0a53f42..d6deb14e 100644 --- a/test/app/busymax_menu_button_test.dart +++ b/test/app/busymax_menu_button_test.dart @@ -251,10 +251,15 @@ void main() { value: 'refresh', label: 'Refresh calendar', icon: YaruIcons.refresh, + role: BusyMaxMenuEntryRole.radio, selected: true, shortcut: 'Ctrl+R', ), - BusyMaxMenuEntry(value: 'open', label: 'Open in provider'), + BusyMaxMenuEntry( + value: 'open', + label: 'Open in provider', + role: BusyMaxMenuEntryRole.radio, + ), ], onSelected: (value) => selected = value, ), @@ -286,13 +291,124 @@ void main() { 'label': 'Refresh calendar', 'icon': 'view-refresh-symbolic', 'enabled': true, + 'role': 'radio', 'selected': true, 'shortcut': 'Ctrl+R', }, - {'label': 'Open in provider', 'enabled': true, 'selected': false}, + { + 'label': 'Open in provider', + 'enabled': true, + 'role': 'radio', + 'selected': false, + }, ]); }); + testWidgets('toggle entries remain valid beside disabled calendar commands', ( + tester, + ) async { + MethodCall? nativeCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + nativeCall = call; + return null; + }); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh calendar'), + BusyMaxMenuEntry( + value: 'reminders', + label: 'Event reminders', + role: BusyMaxMenuEntryRole.toggle, + selected: true, + ), + BusyMaxMenuEntry( + value: 'delete', + label: 'Delete', + enabled: false, + ), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final entries = + (nativeCall!.arguments as Map)['entries']! + as List; + expect(entries[1], { + 'label': 'Event reminders', + 'enabled': true, + 'role': 'toggle', + 'selected': true, + }); + expect(entries[2], { + 'label': 'Delete', + 'enabled': false, + 'role': 'command', + 'selected': false, + }); + }); + + testWidgets('fallback renders toggle state independently of commands', ( + tester, + ) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: BusyMaxMenuButton( + tooltip: 'Options', + nativeMenuService: const NativeMenuService(channel: channel), + entries: const [ + BusyMaxMenuEntry(value: 'refresh', label: 'Refresh calendar'), + BusyMaxMenuEntry( + value: 'reminders', + label: 'Event reminders', + role: BusyMaxMenuEntryRole.toggle, + selected: true, + ), + BusyMaxMenuEntry( + value: 'delete', + label: 'Delete', + enabled: false, + ), + ], + onSelected: (_) {}, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(YaruRadio), findsNothing); + expect(find.byType(YaruCheckbox), findsOneWidget); + expect( + tester.widget(find.byType(YaruCheckbox)).value, + isTrue, + ); + expect(find.text('Delete'), findsOneWidget); + }); + testWidgets('controller dismissal carries the owned native session', ( tester, ) async { diff --git a/test/features/schedule/presentation/schedule_create_menu_test.dart b/test/features/schedule/presentation/schedule_create_menu_test.dart index dc8f6d44..53bb2b0e 100644 --- a/test/features/schedule/presentation/schedule_create_menu_test.dart +++ b/test/features/schedule/presentation/schedule_create_menu_test.dart @@ -80,6 +80,7 @@ void main() { 'label': 'Event', 'icon': 'x-office-calendar-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'E', }, @@ -87,6 +88,7 @@ void main() { 'label': 'Task', 'icon': 'checkbox-checked-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'T', }, diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index 316e2709..c206d7ca 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -161,6 +161,7 @@ void main() { 'label': 'Event', 'icon': 'x-office-calendar-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'E', }, @@ -168,6 +169,7 @@ void main() { 'label': 'Task', 'icon': 'checkbox-checked-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'T', }, diff --git a/test/platform/native_menu_service_test.dart b/test/platform/native_menu_service_test.dart index be91c7fe..3b7775a7 100644 --- a/test/platform/native_menu_service_test.dart +++ b/test/platform/native_menu_service_test.dart @@ -43,15 +43,26 @@ void main() { 'sessionId': session.id, 'anchor': {'x': 24.0, 'y': 36.0, 'width': 140.0, 'height': 34.0}, 'entries': [ - {'label': 'Personal', 'enabled': true, 'selected': false}, + { + 'label': 'Personal', + 'enabled': true, + 'role': 'command', + 'selected': false, + }, { 'label': 'Work', 'icon': 'folder-symbolic', 'enabled': true, + 'role': 'command', 'selected': false, 'shortcut': 'Ctrl+W', }, - {'label': 'Archived', 'enabled': false, 'selected': false}, + { + 'label': 'Archived', + 'enabled': false, + 'role': 'command', + 'selected': false, + }, ], 'focusFirst': false, 'preferredPosition': 'bottom', From 466b32968c65b36797c6d886e406c16c65e866c6 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 16:30:13 -0700 Subject: [PATCH 09/69] feat: add WebCal subscription support with management options and backend integration. Improve tray menu. --- lib/l10n/app_ar.arb | 81 + lib/l10n/app_de.arb | 81 + lib/l10n/app_en.arb | 81 + lib/l10n/app_es.arb | 81 + lib/l10n/app_et.arb | 81 + lib/l10n/app_fa.arb | 81 + lib/l10n/app_fi.arb | 81 + lib/l10n/app_fr.arb | 81 + lib/l10n/app_hi.arb | 81 + lib/l10n/app_it.arb | 81 + lib/l10n/app_ja.arb | 81 + lib/l10n/app_ko.arb | 81 + lib/l10n/app_pt.arb | 81 + lib/l10n/app_ru.arb | 81 + lib/l10n/app_vi.arb | 81 + lib/l10n/app_zh.arb | 81 + lib/l10n/app_zh_Hans.arb | 81 + lib/l10n/app_zh_Hant.arb | 81 + lib/l10n/generated/app_localizations.dart | 384 + lib/l10n/generated/app_localizations_ar.dart | 256 + lib/l10n/generated/app_localizations_de.dart | 256 + lib/l10n/generated/app_localizations_en.dart | 256 + lib/l10n/generated/app_localizations_es.dart | 256 + lib/l10n/generated/app_localizations_et.dart | 256 + lib/l10n/generated/app_localizations_fa.dart | 256 + lib/l10n/generated/app_localizations_fi.dart | 256 + lib/l10n/generated/app_localizations_fr.dart | 256 + lib/l10n/generated/app_localizations_hi.dart | 256 + lib/l10n/generated/app_localizations_it.dart | 256 + lib/l10n/generated/app_localizations_ja.dart | 256 + lib/l10n/generated/app_localizations_ko.dart | 256 + lib/l10n/generated/app_localizations_pt.dart | 256 + lib/l10n/generated/app_localizations_ru.dart | 256 + lib/l10n/generated/app_localizations_vi.dart | 256 + lib/l10n/generated/app_localizations_zh.dart | 768 + lib/src/app/app_bootstrap.dart | 114 +- lib/src/app/app_router.dart | 15 +- lib/src/app/busymax_app.dart | 245 +- .../calendar_providers/calendar_colors.dart | 4 +- .../calendar_provider_capabilities.dart | 4 +- lib/src/core/secrets/secret_store.dart | 64 +- lib/src/dav/dav_provider_profile.dart | 8 +- lib/src/dav/ical/ical_semantics.dart | 10 +- .../dav/storage/dav_object_repository.dart | 213 +- lib/src/db/app_database.dart | 2 + lib/src/db/app_database.g.dart | 15392 ++++++++++------ lib/src/db/migrations.dart | 19 +- lib/src/db/tables.dart | 57 +- lib/src/demo/demo_profile.dart | 4 +- .../accounts/data/accounts_repository.dart | 28 +- .../features/auth/data/auth_repository.dart | 3 + .../auth/presentation/sign_in_screen.dart | 14 + .../calendar/data/calendar_repository.dart | 97 +- .../presentation/calendar_color_dialog.dart | 4 +- .../calendar/presentation/event_editor.dart | 4 +- .../presentation/event_editor_draft.dart | 8 +- .../notification_schedule_service.dart | 3 +- .../domain/event_recurrence_codec.dart | 7 +- .../presentation/schedule_sidebar.dart | 416 +- .../presentation/schedule_workspace.dart | 13 +- .../presentation/settings_screen.dart | 442 + .../sync/account_sync_operations.dart | 54 +- .../sync/all_accounts_sync_scheduler.dart | 30 +- .../tasks/domain/task_capabilities.dart | 1 + .../tasks/presentation/task_details_pane.dart | 6 +- .../tray/data/tray_presentation_service.dart | 203 + .../tray/domain/tray_presentation.dart | 214 + .../domain/tray_presentation_formatter.dart | 156 + lib/src/ical/ical_event_projection.dart | 298 + lib/src/ical/ical_import_service.dart | 445 + lib/src/ical/ical_ingestion.dart | 247 + lib/src/platform/busymax_tray_service.dart | 555 +- .../external_calendar_open_service.dart | 63 + lib/src/providers/account_authority.dart | 30 +- lib/src/providers/busy_provider.dart | 5 +- lib/src/schedule/schedule_projection.dart | 1 + lib/src/schedule/schedule_repository.dart | 12 +- lib/src/webcal/webcal_http_client.dart | 258 + .../webcal/webcal_subscription_service.dart | 855 + lib/src/webcal/webcal_uri.dart | 100 + linux/io.busystack.busymax.desktop | 3 +- linux/io.busystack.busymax.metainfo.xml | 3 + linux/runner/my_application.cc | 145 +- test/app/native_ui_audit_test.dart | 18 +- test/app/theme_localization_test.dart | 248 +- test/db/app_database_test.dart | 68 +- .../data/accounts_repository_test.dart | 64 + .../notification_schedule_service_test.dart | 2 + .../schedule_sidebar_provider_test.dart | 1 + .../presentation/schedule_views_test.dart | 2 +- .../sync/account_sync_operations_test.dart | 36 + test/features/sync/sync_scheduler_test.dart | 44 +- .../presentation/task_details_pane_test.dart | 1 + .../features/tray/tray_presentation_test.dart | 482 + test/ical/ical_import_service_test.dart | 321 + test/ical/ical_ingestion_test.dart | 332 + test/platform/busymax_tray_service_test.dart | 803 +- .../external_calendar_open_service_test.dart | 91 + test/providers/busy_provider_test.dart | 4 +- test/webcal/webcal_http_client_test.dart | 314 + test/webcal/webcal_read_only_test.dart | 191 + .../webcal_subscription_service_test.dart | 461 + test/webcal/webcal_uri_test.dart | 49 + tools/install_linux_dev_desktop.sh | 2 +- 104 files changed, 23860 insertions(+), 6998 deletions(-) create mode 100644 lib/src/features/tray/data/tray_presentation_service.dart create mode 100644 lib/src/features/tray/domain/tray_presentation.dart create mode 100644 lib/src/features/tray/domain/tray_presentation_formatter.dart create mode 100644 lib/src/ical/ical_event_projection.dart create mode 100644 lib/src/ical/ical_import_service.dart create mode 100644 lib/src/ical/ical_ingestion.dart create mode 100644 lib/src/platform/external_calendar_open_service.dart create mode 100644 lib/src/webcal/webcal_http_client.dart create mode 100644 lib/src/webcal/webcal_subscription_service.dart create mode 100644 lib/src/webcal/webcal_uri.dart create mode 100644 test/features/sync/account_sync_operations_test.dart create mode 100644 test/features/tray/tray_presentation_test.dart create mode 100644 test/ical/ical_import_service_test.dart create mode 100644 test/ical/ical_ingestion_test.dart create mode 100644 test/platform/external_calendar_open_service_test.dart create mode 100644 test/webcal/webcal_http_client_test.dart create mode 100644 test/webcal/webcal_read_only_test.dart create mode 100644 test/webcal/webcal_subscription_service_test.dart create mode 100644 test/webcal/webcal_uri_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0809529a..4fe9c789 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.", "refresh": "تحديث", "trayOpenBusyMax": "فتح BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "تحميل المزيد من المهام المتأخرة", "agendaLoadMoreNoDate": "تحميل المزيد من المهام بلا تاريخ", "viewDay": "يوم", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "هل تريد إزالة \"{title}\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", "calendarCannotRemove": "لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.", "calendarPendingChangesPreventRemoval": "انتظر حتى تنتهي مزامنة التغييرات المعلّقة لهذا التقويم قبل حذفه أو إزالته.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "غير متصل", "networkOfflineDescription": "ستتم مزامنة التغييرات عند استعادة الاتصال.", "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى." diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ecd8a7bc..18ce98f8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.", "refresh": "Aktualisieren", "trayOpenBusyMax": "BusyMax öffnen", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Weitere überfällige Aufgaben laden", "agendaLoadMoreNoDate": "Weitere Aufgaben ohne Datum laden", "viewDay": "Tag", @@ -701,6 +726,62 @@ "removeCalendarConfirmation": "„{title}“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.", "calendarCannotRemove": "Dieser Kalender kann in diesem Konto weder gelöscht noch entfernt werden.", "calendarPendingChangesPreventRemoval": "Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Offline", "networkOfflineDescription": "Änderungen werden synchronisiert, sobald die Verbindung wiederhergestellt ist.", "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut." diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0d268df6..e8b60bb5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -65,6 +65,31 @@ "scheduleNoSearchResultsDescription": "Try a different search or clear the current filters.", "refresh": "Refresh", "trayOpenBusyMax": "Open BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Load more overdue tasks", "agendaLoadMoreNoDate": "Load more no-date tasks", "viewDay": "Day", @@ -659,6 +684,62 @@ "@removeCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}}, "calendarCannotRemove": "This calendar cannot be deleted or removed from this account.", "calendarPendingChangesPreventRemoval": "Wait for this calendar’s pending changes to finish syncing before deleting or removing it.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Offline", "networkOfflineDescription": "Changes will sync when the connection is restored.", "networkOfflineTryAgain": "You’re offline. Connect to the internet and try again." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 88e6a450..d8b7788c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Prueba con otra búsqueda o borra los filtros actuales.", "refresh": "Actualizar", "trayOpenBusyMax": "Abrir BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Cargar más tareas vencidas", "agendaLoadMoreNoDate": "Cargar más tareas sin fecha", "viewDay": "Día", @@ -701,6 +726,62 @@ "removeCalendarConfirmation": "¿Quitar \"{title}\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.", "calendarCannotRemove": "Este calendario no se puede eliminar ni quitar de esta cuenta.", "calendarPendingChangesPreventRemoval": "Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Sin conexión", "networkOfflineDescription": "Los cambios se sincronizarán cuando se restablezca la conexión.", "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo." diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 9d601afa..f9da772e 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -71,6 +71,31 @@ "scheduleNoSearchResultsDescription": "Proovige teistsugust otsingut või eemaldage praegused filtrid.", "refresh": "Värskenda", "trayOpenBusyMax": "Ava BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", "agendaLoadMoreNoDate": "Laadi veel kuupäevata ülesandeid", "viewDay": "Päev", @@ -816,6 +841,62 @@ "removeCalendarConfirmation": "Kas eemaldada „{title}” sinu Google'i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", "calendarCannotRemove": "Seda kalendrit ei saa sellelt kontolt kustutada ega eemaldada.", "calendarPendingChangesPreventRemoval": "Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Võrguühenduseta", "networkOfflineDescription": "Muudatused sünkroonitakse ühenduse taastumisel.", "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti." diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 187d1749..e12c95b3 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.", "refresh": "تازه‌سازی", "trayOpenBusyMax": "باز کردن BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", "agendaLoadMoreNoDate": "بارگیری کارهای بدون تاریخ بیشتر", "viewDay": "روز", @@ -702,6 +727,62 @@ "removeCalendarConfirmation": "«{title}» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف کرد.", "calendarPendingChangesPreventRemoval": "پیش از حذف یا برداشتن این تقویم، صبر کنید تا تغییرات در انتظار آن همگام‌سازی شوند.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "آفلاین", "networkOfflineDescription": "تغییرات پس از برقراری دوبارهٔ اتصال همگام‌سازی می‌شوند.", "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید." diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 7d9cac46..ec061b98 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Kokeile toista hakua tai tyhjennä nykyiset suodattimet.", "refresh": "Päivitä", "trayOpenBusyMax": "Avaa BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", "agendaLoadMoreNoDate": "Lataa lisää päiväämättömiä tehtäviä", "viewDay": "Päivä", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "Poistetaanko ”{title}” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.", "calendarCannotRemove": "Tätä kalenteria ei voi poistaa tältä tililtä.", "calendarPendingChangesPreventRemoval": "Odota, että tämän kalenterin odottavat muutokset on synkronoitu, ennen kuin poistat kalenterin.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Ei verkkoyhteyttä", "networkOfflineDescription": "Muutokset synkronoidaan, kun yhteys palautuu.", "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e2ee9457..467fd173 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Essayez une autre recherche ou effacez les filtres actuels.", "refresh": "Actualiser", "trayOpenBusyMax": "Ouvrir BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Charger plus de tâches en retard", "agendaLoadMoreNoDate": "Charger plus de tâches sans date", "viewDay": "Jour", @@ -701,6 +726,62 @@ "removeCalendarConfirmation": "Retirer « {title} » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.", "calendarCannotRemove": "Cet agenda ne peut pas être supprimé ni retiré de ce compte.", "calendarPendingChangesPreventRemoval": "Attendez la fin de la synchronisation des modifications en attente de cet agenda avant de le supprimer ou de le retirer.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Hors ligne", "networkOfflineDescription": "Les modifications seront synchronisées au rétablissement de la connexion.", "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index cbf38a66..c3dcf313 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।", "refresh": "रीफ़्रेश करें", "trayOpenBusyMax": "BusyMax खोलें", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", "agendaLoadMoreNoDate": "बिना तारीख वाले और कार्य लोड करें", "viewDay": "दिन", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "क्या \"{title}\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।", "calendarCannotRemove": "इस कैलेंडर को इस खाते से मिटाया या हटाया नहीं जा सकता।", "calendarPendingChangesPreventRemoval": "इस कैलेंडर को मिटाने या हटाने से पहले इसके लंबित बदलावों का सिंक पूरा होने दें।", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "ऑफ़लाइन", "networkOfflineDescription": "कनेक्शन वापस आने पर बदलाव सिंक हो जाएंगे।", "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।" diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 2e2a2e79..7b9dff85 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Prova una ricerca diversa o cancella i filtri attuali.", "refresh": "Aggiorna", "trayOpenBusyMax": "Apri BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Carica altre attività scadute", "agendaLoadMoreNoDate": "Carica altre attività senza data", "viewDay": "Giorno", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "Rimuovere \"{title}\" dall'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.", "calendarCannotRemove": "Questo calendario non può essere eliminato o rimosso da questo account.", "calendarPendingChangesPreventRemoval": "Attendi che le modifiche in sospeso di questo calendario siano sincronizzate prima di eliminarlo o rimuoverlo.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Offline", "networkOfflineDescription": "Le modifiche verranno sincronizzate al ripristino della connessione.", "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova." diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index cf16034c..b3ad0a49 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "別の条件で検索するか、現在のフィルターを解除してください。", "refresh": "更新", "trayOpenBusyMax": "BusyMax を開く", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "期限切れのタスクをさらに読み込む", "agendaLoadMoreNoDate": "日付のないタスクをさらに読み込む", "viewDay": "日", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "「{title}」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。", "calendarCannotRemove": "このカレンダーは、このアカウントから削除できません。", "calendarPendingChangesPreventRemoval": "このカレンダーを削除する前に、保留中の変更の同期が完了するまでお待ちください。", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "オフライン", "networkOfflineDescription": "接続が復旧すると変更が同期されます。", "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e03ca73a..dcd8fec4 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "다른 검색어를 사용하거나 현재 필터를 지우세요.", "refresh": "새로 고침", "trayOpenBusyMax": "BusyMax 열기", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "기한이 지난 할 일 더 불러오기", "agendaLoadMoreNoDate": "날짜 없는 할 일 더 불러오기", "viewDay": "일", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "Google Calendar 목록에서 \"{title}\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.", "calendarCannotRemove": "이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.", "calendarPendingChangesPreventRemoval": "이 캘린더를 삭제하거나 제거하기 전에 보류 중인 변경 사항의 동기화가 완료될 때까지 기다리세요.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "오프라인", "networkOfflineDescription": "연결이 복구되면 변경 사항이 동기화됩니다.", "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 0d544223..506b7787 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", "refresh": "Atualizar", "trayOpenBusyMax": "Abrir o BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", "viewDay": "Dia", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Agenda? A agenda compartilhada e os eventos dela não serão excluídos.", "calendarCannotRemove": "Não é possível excluir nem remover esta agenda desta conta.", "calendarPendingChangesPreventRemoval": "Aguarde a sincronização das alterações pendentes desta agenda antes de excluí-la ou removê-la.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Offline", "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente." diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0f325d58..a47ebfcd 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Попробуйте изменить запрос или сбросить текущие фильтры.", "refresh": "Обновить", "trayOpenBusyMax": "Открыть BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Загрузить ещё просроченные задачи", "agendaLoadMoreNoDate": "Загрузить ещё задачи без даты", "viewDay": "День", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "Удалить «{title}» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.", "calendarCannotRemove": "Этот календарь нельзя удалить из этой учётной записи.", "calendarPendingChangesPreventRemoval": "Дождитесь синхронизации ожидающих изменений этого календаря, прежде чем удалять его.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Нет подключения", "networkOfflineDescription": "Изменения будут синхронизированы после восстановления подключения.", "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку." diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 2cf92d91..38115aa7 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.", "refresh": "Làm mới", "trayOpenBusyMax": "Mở BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Tải thêm công việc quá hạn", "agendaLoadMoreNoDate": "Tải thêm công việc không có ngày", "viewDay": "Ngày", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "Xóa “{title}” khỏi danh sách Google Calendar của bạn? Lịch dùng chung và các sự kiện trong đó sẽ không bị xóa.", "calendarCannotRemove": "Không thể xóa lịch này khỏi tài khoản.", "calendarPendingChangesPreventRemoval": "Hãy đợi các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "Ngoại tuyến", "networkOfflineDescription": "Các thay đổi sẽ được đồng bộ khi kết nối được khôi phục.", "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0da54363..a348843c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", "calendarCannotRemove": "无法从此账号中删除或移除此日历。", "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改同步完成,然后再删除或移除它。", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 06fccd89..b43ded9f 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", "calendarCannotRemove": "无法从此账号中删除或移除此日历。", "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改同步完成,然后再删除或移除它。", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index fa50dee9..0c5e535c 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -64,6 +64,31 @@ "scheduleNoSearchResultsDescription": "請嘗試其他搜尋內容或清除目前的篩選條件。", "refresh": "重新整理", "trayOpenBusyMax": "開啟 BusyMax", + "trayShowBusyMax": "Show BusyMax", + "trayNewEvent": "New event…", + "trayNewTask": "New task…", + "trayToday": "Today", + "trayAllDay": "All day", + "trayNow": "Now", + "trayCalendarEvent": "Calendar event", + "trayUntitledEvent": "Untitled event", + "trayNothingElseToday": "Nothing else today", + "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", + "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "trayOpenTodayAgenda": "Open today’s agenda", + "traySyncNow": "Sync now", + "traySyncing": "Syncing…", + "trayNotConnected": "Not connected", + "trayNotYetSynced": "Not yet synced", + "trayLastSyncedJustNow": "Last synced just now", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", + "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", + "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", + "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "traySettings": "Settings", + "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "載入更多逾期待辦事項", "agendaLoadMoreNoDate": "載入更多無日期待辦事項", "viewDay": "日", @@ -638,6 +663,62 @@ "removeCalendarConfirmation": "要從您的 Google 日曆清單移除「{title}」嗎?共用日曆及其活動不會被刪除。", "calendarCannotRemove": "無法從此帳戶刪除或移除此日曆。", "calendarPendingChangesPreventRemoval": "請等待此行事曆的待處理變更同步完成,再刪除或移除它。", + "calendarSubscriptions": "Calendar subscriptions", + "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", + "addCalendarSubscription": "Add calendar subscription", + "subscriptionName": "Local name", + "subscriptionUrl": "Subscription URL", + "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", + "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", + "subscriptionColor": "Local color", + "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", + "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", + "subscriptionRefreshMode": "Refresh frequency", + "subscriptionAutomatic": "Automatic", + "subscriptionHourly": "Hourly", + "subscriptionSixHours": "Every six hours", + "subscriptionDaily": "Daily", + "subscriptionSafeOrigin": "Source: {origin}", + "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", + "subscriptionReadOnly": "Read-only subscription", + "subscriptionNeverRefreshed": "Not refreshed yet", + "subscriptionLastRefresh": "Last successful refresh: {time}", + "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionNextRefresh": "Next refresh: {time}", + "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "subscriptionStatusHealthy": "Up to date", + "subscriptionStatusIssue": "Refresh issue: {code}", + "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "refreshNow": "Refresh now", + "unsubscribe": "Unsubscribe", + "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", + "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", + "addSubscriptionAction": "Add subscription", + "subscriptionOperationFailed": "Calendar subscription failed: {error}", + "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "subscriptions": "Subscriptions", + "importIcsFile": "Import .ics file", + "importIcsPreview": "Import calendar events", + "importEventsFound": "Importable event sets: {count}", + "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "importInvalidEvents": "Invalid events: {count}", + "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "importFieldsOmitted": "Intentionally omitted: {fields}", + "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "noWritableCalendars": "No writable destination calendar is available.", + "importDestinationCalendar": "Destination calendar", + "importIcsConfirm": "Import events", + "importIcsComplete": "Import complete", + "importQueued": "Imported or queued: {count}", + "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "importDuplicatesSkipped": "Duplicates skipped: {count}", + "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "importUnsupportedSets": "Unsupported recurrence sets: {count}", + "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "importIcsFailed": "Could not import the calendar file: {error}", + "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, "networkOffline": "離線", "networkOfflineDescription": "連線恢復後將同步變更。", "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。" diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 68a2c0fe..d90d8674 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -492,6 +492,132 @@ abstract class AppLocalizations { /// **'Open BusyMax'** String get trayOpenBusyMax; + /// No description provided for @trayShowBusyMax. + /// + /// In en, this message translates to: + /// **'Show BusyMax'** + String get trayShowBusyMax; + + /// No description provided for @trayNewEvent. + /// + /// In en, this message translates to: + /// **'New event…'** + String get trayNewEvent; + + /// No description provided for @trayNewTask. + /// + /// In en, this message translates to: + /// **'New task…'** + String get trayNewTask; + + /// No description provided for @trayToday. + /// + /// In en, this message translates to: + /// **'Today'** + String get trayToday; + + /// No description provided for @trayAllDay. + /// + /// In en, this message translates to: + /// **'All day'** + String get trayAllDay; + + /// No description provided for @trayNow. + /// + /// In en, this message translates to: + /// **'Now'** + String get trayNow; + + /// No description provided for @trayCalendarEvent. + /// + /// In en, this message translates to: + /// **'Calendar event'** + String get trayCalendarEvent; + + /// No description provided for @trayUntitledEvent. + /// + /// In en, this message translates to: + /// **'Untitled event'** + String get trayUntitledEvent; + + /// No description provided for @trayNothingElseToday. + /// + /// In en, this message translates to: + /// **'Nothing else today'** + String get trayNothingElseToday; + + /// No description provided for @trayTasksDueToday. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 task due today} other{{count} tasks due today}}'** + String trayTasksDueToday(int count); + + /// No description provided for @trayOpenTodayAgenda. + /// + /// In en, this message translates to: + /// **'Open today’s agenda'** + String get trayOpenTodayAgenda; + + /// No description provided for @traySyncNow. + /// + /// In en, this message translates to: + /// **'Sync now'** + String get traySyncNow; + + /// No description provided for @traySyncing. + /// + /// In en, this message translates to: + /// **'Syncing…'** + String get traySyncing; + + /// No description provided for @trayNotConnected. + /// + /// In en, this message translates to: + /// **'Not connected'** + String get trayNotConnected; + + /// No description provided for @trayNotYetSynced. + /// + /// In en, this message translates to: + /// **'Not yet synced'** + String get trayNotYetSynced; + + /// No description provided for @trayLastSyncedJustNow. + /// + /// In en, this message translates to: + /// **'Last synced just now'** + String get trayLastSyncedJustNow; + + /// No description provided for @trayLastSyncedMinutesAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}'** + String trayLastSyncedMinutesAgo(int count); + + /// No description provided for @trayLastSyncedHoursAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}'** + String trayLastSyncedHoursAgo(int count); + + /// No description provided for @trayLastSyncedDaysAgo. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}'** + String trayLastSyncedDaysAgo(int count); + + /// No description provided for @traySettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get traySettings; + + /// No description provided for @trayQuitBusyMax. + /// + /// In en, this message translates to: + /// **'Quit BusyMax'** + String get trayQuitBusyMax; + /// No description provided for @agendaLoadMoreOverdue. /// /// In en, this message translates to: @@ -3678,6 +3804,264 @@ abstract class AppLocalizations { /// **'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'** String get calendarPendingChangesPreventRemoval; + /// No description provided for @calendarSubscriptions. + /// + /// In en, this message translates to: + /// **'Calendar subscriptions'** + String get calendarSubscriptions; + + /// No description provided for @calendarSubscriptionsDescription. + /// + /// In en, this message translates to: + /// **'Add read-only calendars that refresh from a secure WebCal URL.'** + String get calendarSubscriptionsDescription; + + /// No description provided for @addCalendarSubscription. + /// + /// In en, this message translates to: + /// **'Add calendar subscription'** + String get addCalendarSubscription; + + /// No description provided for @subscriptionName. + /// + /// In en, this message translates to: + /// **'Local name'** + String get subscriptionName; + + /// No description provided for @subscriptionUrl. + /// + /// In en, this message translates to: + /// **'Subscription URL'** + String get subscriptionUrl; + + /// No description provided for @subscriptionUrlHelp. + /// + /// In en, this message translates to: + /// **'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'** + String get subscriptionUrlHelp; + + /// No description provided for @subscriptionUrlInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid HTTPS or webcal URL without user information or a fragment.'** + String get subscriptionUrlInvalid; + + /// No description provided for @subscriptionColor. + /// + /// In en, this message translates to: + /// **'Local color'** + String get subscriptionColor; + + /// No description provided for @subscriptionColorHelp. + /// + /// In en, this message translates to: + /// **'Use a six-digit color such as #3584E4.'** + String get subscriptionColorHelp; + + /// No description provided for @subscriptionColorInvalid. + /// + /// In en, this message translates to: + /// **'Enter a six-digit hexadecimal color.'** + String get subscriptionColorInvalid; + + /// No description provided for @subscriptionRefreshMode. + /// + /// In en, this message translates to: + /// **'Refresh frequency'** + String get subscriptionRefreshMode; + + /// No description provided for @subscriptionAutomatic. + /// + /// In en, this message translates to: + /// **'Automatic'** + String get subscriptionAutomatic; + + /// No description provided for @subscriptionHourly. + /// + /// In en, this message translates to: + /// **'Hourly'** + String get subscriptionHourly; + + /// No description provided for @subscriptionSixHours. + /// + /// In en, this message translates to: + /// **'Every six hours'** + String get subscriptionSixHours; + + /// No description provided for @subscriptionDaily. + /// + /// In en, this message translates to: + /// **'Daily'** + String get subscriptionDaily; + + /// No description provided for @subscriptionSafeOrigin. + /// + /// In en, this message translates to: + /// **'Source: {origin}'** + String subscriptionSafeOrigin(String origin); + + /// No description provided for @subscriptionSafeOriginUnavailable. + /// + /// In en, this message translates to: + /// **'Enter a valid URL to preview its safe origin.'** + String get subscriptionSafeOriginUnavailable; + + /// No description provided for @subscriptionReadOnly. + /// + /// In en, this message translates to: + /// **'Read-only subscription'** + String get subscriptionReadOnly; + + /// No description provided for @subscriptionNeverRefreshed. + /// + /// In en, this message translates to: + /// **'Not refreshed yet'** + String get subscriptionNeverRefreshed; + + /// No description provided for @subscriptionLastRefresh. + /// + /// In en, this message translates to: + /// **'Last successful refresh: {time}'** + String subscriptionLastRefresh(String time); + + /// No description provided for @subscriptionNextRefresh. + /// + /// In en, this message translates to: + /// **'Next refresh: {time}'** + String subscriptionNextRefresh(String time); + + /// No description provided for @subscriptionStatusHealthy. + /// + /// In en, this message translates to: + /// **'Up to date'** + String get subscriptionStatusHealthy; + + /// No description provided for @subscriptionStatusIssue. + /// + /// In en, this message translates to: + /// **'Refresh issue: {code}'** + String subscriptionStatusIssue(String code); + + /// No description provided for @refreshNow. + /// + /// In en, this message translates to: + /// **'Refresh now'** + String get refreshNow; + + /// No description provided for @unsubscribe. + /// + /// In en, this message translates to: + /// **'Unsubscribe'** + String get unsubscribe; + + /// No description provided for @unsubscribeCalendarTitle. + /// + /// In en, this message translates to: + /// **'Unsubscribe from “{name}”?'** + String unsubscribeCalendarTitle(String name); + + /// No description provided for @unsubscribeCalendarConfirmation. + /// + /// In en, this message translates to: + /// **'This removes the local subscription and its cached events. The published calendar is not changed.'** + String get unsubscribeCalendarConfirmation; + + /// No description provided for @addSubscriptionAction. + /// + /// In en, this message translates to: + /// **'Add subscription'** + String get addSubscriptionAction; + + /// No description provided for @subscriptionOperationFailed. + /// + /// In en, this message translates to: + /// **'Calendar subscription failed: {error}'** + String subscriptionOperationFailed(String error); + + /// No description provided for @subscriptions. + /// + /// In en, this message translates to: + /// **'Subscriptions'** + String get subscriptions; + + /// No description provided for @importIcsFile. + /// + /// In en, this message translates to: + /// **'Import .ics file'** + String get importIcsFile; + + /// No description provided for @importIcsPreview. + /// + /// In en, this message translates to: + /// **'Import calendar events'** + String get importIcsPreview; + + /// No description provided for @importEventsFound. + /// + /// In en, this message translates to: + /// **'Importable event sets: {count}'** + String importEventsFound(int count); + + /// No description provided for @importInvalidEvents. + /// + /// In en, this message translates to: + /// **'Invalid events: {count}'** + String importInvalidEvents(int count); + + /// No description provided for @importFieldsOmitted. + /// + /// In en, this message translates to: + /// **'Intentionally omitted: {fields}'** + String importFieldsOmitted(String fields); + + /// No description provided for @noWritableCalendars. + /// + /// In en, this message translates to: + /// **'No writable destination calendar is available.'** + String get noWritableCalendars; + + /// No description provided for @importDestinationCalendar. + /// + /// In en, this message translates to: + /// **'Destination calendar'** + String get importDestinationCalendar; + + /// No description provided for @importIcsConfirm. + /// + /// In en, this message translates to: + /// **'Import events'** + String get importIcsConfirm; + + /// No description provided for @importIcsComplete. + /// + /// In en, this message translates to: + /// **'Import complete'** + String get importIcsComplete; + + /// No description provided for @importQueued. + /// + /// In en, this message translates to: + /// **'Imported or queued: {count}'** + String importQueued(int count); + + /// No description provided for @importDuplicatesSkipped. + /// + /// In en, this message translates to: + /// **'Duplicates skipped: {count}'** + String importDuplicatesSkipped(int count); + + /// No description provided for @importUnsupportedSets. + /// + /// In en, this message translates to: + /// **'Unsupported recurrence sets: {count}'** + String importUnsupportedSets(int count); + + /// No description provided for @importIcsFailed. + /// + /// In en, this message translates to: + /// **'Could not import the calendar file: {error}'** + String importIcsFailed(String error); + /// No description provided for @networkOffline. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 813ff0d9..f52a7575 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -213,6 +213,101 @@ class AppLocalizationsAr extends AppLocalizations { @override String get trayOpenBusyMax => 'فتح BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; @@ -2022,6 +2117,167 @@ class AppLocalizationsAr extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'انتظر حتى تنتهي مزامنة التغييرات المعلّقة لهذا التقويم قبل حذفه أو إزالته.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'غير متصل'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index da22e6c7..b51b7844 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -214,6 +214,101 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax öffnen'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Weitere überfällige Aufgaben laden'; @@ -2023,6 +2118,167 @@ class AppLocalizationsDe extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 808c83ff..b58d2a63 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -213,6 +213,101 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trayOpenBusyMax => 'Open BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Load more overdue tasks'; @@ -2004,6 +2099,167 @@ class AppLocalizationsEn extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 42ac2b78..517b7c2e 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -216,6 +216,101 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trayOpenBusyMax => 'Abrir BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Cargar más tareas vencidas'; @@ -2023,6 +2118,167 @@ class AppLocalizationsEs extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Sin conexión'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index b78bce07..b22379c2 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -214,6 +214,101 @@ class AppLocalizationsEt extends AppLocalizations { @override String get trayOpenBusyMax => 'Ava BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Laadi veel tähtaja ületanud ülesandeid'; @@ -2011,6 +2106,167 @@ class AppLocalizationsEt extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Võrguühenduseta'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 09b25ca7..3361a23f 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -217,6 +217,101 @@ class AppLocalizationsFa extends AppLocalizations { @override String get trayOpenBusyMax => 'باز کردن BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; @@ -2045,6 +2140,167 @@ class AppLocalizationsFa extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'پیش از حذف یا برداشتن این تقویم، صبر کنید تا تغییرات در انتظار آن همگام‌سازی شوند.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'آفلاین'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index a58483ed..faa5994a 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -216,6 +216,101 @@ class AppLocalizationsFi extends AppLocalizations { @override String get trayOpenBusyMax => 'Avaa BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; @@ -2017,6 +2112,167 @@ class AppLocalizationsFi extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Odota, että tämän kalenterin odottavat muutokset on synkronoitu, ennen kuin poistat kalenterin.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Ei verkkoyhteyttä'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index e7573cbe..45262224 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -215,6 +215,101 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trayOpenBusyMax => 'Ouvrir BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Charger plus de tâches en retard'; @@ -2021,6 +2116,167 @@ class AppLocalizationsFr extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Attendez la fin de la synchronisation des modifications en attente de cet agenda avant de le supprimer ou de le retirer.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Hors ligne'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 0e9c749b..7c89ef43 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -216,6 +216,101 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax खोलें'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें'; @@ -2011,6 +2106,167 @@ class AppLocalizationsHi extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'इस कैलेंडर को मिटाने या हटाने से पहले इसके लंबित बदलावों का सिंक पूरा होने दें।'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'ऑफ़लाइन'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index cf193c5f..b84c465e 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -217,6 +217,101 @@ class AppLocalizationsIt extends AppLocalizations { @override String get trayOpenBusyMax => 'Apri BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Carica altre attività scadute'; @@ -2023,6 +2118,167 @@ class AppLocalizationsIt extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Attendi che le modifiche in sospeso di questo calendario siano sincronizzate prima di eliminarlo o rimuoverlo.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 20412966..95a26061 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -210,6 +210,101 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax を開く'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => '期限切れのタスクをさらに読み込む'; @@ -1982,6 +2077,167 @@ class AppLocalizationsJa extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'このカレンダーを削除する前に、保留中の変更の同期が完了するまでお待ちください。'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'オフライン'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 6cd92b0a..70c167b2 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -209,6 +209,101 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trayOpenBusyMax => 'BusyMax 열기'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => '기한이 지난 할 일 더 불러오기'; @@ -1983,6 +2078,167 @@ class AppLocalizationsKo extends AppLocalizations { String get calendarPendingChangesPreventRemoval => '이 캘린더를 삭제하거나 제거하기 전에 보류 중인 변경 사항의 동기화가 완료될 때까지 기다리세요.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => '오프라인'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index cef690ec..3dee71b3 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -217,6 +217,101 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trayOpenBusyMax => 'Abrir o BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Carregar mais tarefas em atraso'; @@ -2021,6 +2116,167 @@ class AppLocalizationsPt extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Aguarde a sincronização das alterações pendentes desta agenda antes de excluí-la ou removê-la.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Offline'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index f7511b6a..7db7dd6f 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -213,6 +213,101 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trayOpenBusyMax => 'Открыть BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Загрузить ещё просроченные задачи'; @@ -2024,6 +2119,167 @@ class AppLocalizationsRu extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Дождитесь синхронизации ожидающих изменений этого календаря, прежде чем удалять его.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Нет подключения'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 1f31efb5..ad254d00 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -214,6 +214,101 @@ class AppLocalizationsVi extends AppLocalizations { @override String get trayOpenBusyMax => 'Mở BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => 'Tải thêm công việc quá hạn'; @@ -2010,6 +2105,167 @@ class AppLocalizationsVi extends AppLocalizations { String get calendarPendingChangesPreventRemoval => 'Hãy đợi các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => 'Ngoại tuyến'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 4dc5561c..fde9c678 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -207,6 +207,101 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trayOpenBusyMax => '打开 BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => '加载更多逾期任务'; @@ -1970,6 +2065,167 @@ class AppLocalizationsZh extends AppLocalizations { String get calendarPendingChangesPreventRemoval => '请等待此日历的待处理更改同步完成,然后再删除或移除它。'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => '离线'; @@ -2179,6 +2435,101 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get trayOpenBusyMax => '打开 BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => '加载更多逾期任务'; @@ -3942,6 +4293,167 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get calendarPendingChangesPreventRemoval => '请等待此日历的待处理更改同步完成,然后再删除或移除它。'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => '离线'; @@ -4151,6 +4663,101 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get trayOpenBusyMax => '開啟 BusyMax'; + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $count days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + @override String get agendaLoadMoreOverdue => '載入更多逾期待辦事項'; @@ -5915,6 +6522,167 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get calendarPendingChangesPreventRemoval => '請等待此行事曆的待處理變更同步完成,再刪除或移除它。'; + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + return 'Importable event sets: $count'; + } + + @override + String importInvalidEvents(int count) { + return 'Invalid events: $count'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + return 'Imported or queued: $count'; + } + + @override + String importDuplicatesSkipped(int count) { + return 'Duplicates skipped: $count'; + } + + @override + String importUnsupportedSets(int count) { + return 'Unsupported recurrence sets: $count'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + @override String get networkOffline => '離線'; diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 53ec8738..669a9bfa 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -22,6 +22,7 @@ import '../dav/mutation/dav_task_list_mutation_service.dart'; import '../dav/sync/dav_account_sync_engine.dart'; import '../dav/storage/dav_settings_repository.dart'; import '../features/calendar/data/calendar_repository.dart'; +import '../ical/ical_import_service.dart'; import '../features/accounts/data/accounts_repository.dart'; import '../features/auth/data/auth_repository.dart'; import '../features/connectivity/network_connectivity_service.dart'; @@ -38,6 +39,7 @@ import '../features/sync/sync_failure_notification_policy.dart'; import '../features/task_lists/data/task_lists_repository.dart'; import '../features/tasks/data/tasks_repository.dart'; import '../features/tasks/domain/task_remote_client.dart'; +import '../features/tray/data/tray_presentation_service.dart'; import '../google_tasks/api/google_tasks_api_client.dart'; import '../google_tasks/http/authenticated_http_client.dart'; import '../google_tasks/http/retrying_http_client.dart'; @@ -51,11 +53,14 @@ import '../microsoft_todo/api/microsoft_todo_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_task_remote_client.dart'; import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../platform/linux_window_service.dart'; +import '../platform/external_calendar_open_service.dart'; import '../platform/linux_autostart_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../schedule/schedule_commands.dart'; import '../schedule/schedule_repository.dart'; +import '../webcal/webcal_http_client.dart'; +import '../webcal/webcal_subscription_service.dart'; import 'app_router.dart'; import 'app_settings.dart'; @@ -257,6 +262,14 @@ final linuxWindowServiceProvider = Provider( (ref) => const LinuxWindowService(), ); +final externalCalendarOpenServiceProvider = + Provider((ref) { + final service = ExternalCalendarOpenService(); + unawaited(service.initialize()); + ref.onDispose(() => unawaited(service.dispose())); + return service; + }); + final linuxAutostartServiceProvider = Provider( (ref) => LinuxAutostartService(), ); @@ -334,7 +347,9 @@ final davTaskCollectionCapabilitiesProvider = final selectedAccountIdProvider = StateProvider((ref) => null); final selectedAccountProvider = Provider((ref) { - final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; + final accounts = (ref.watch(accountsStreamProvider).valueOrNull ?? const []) + .where((account) => account.isTaskCapable) + .toList(growable: false); final selectedId = ref.watch(selectedAccountIdProvider); if (selectedId != null) { for (final account in accounts) { @@ -448,7 +463,9 @@ final taskRemoteApiClientForAccountProvider = BusyProvider.google => ref.watch( googleTasksApiClientForAccountProvider(accountId), ), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; }); @@ -469,6 +486,9 @@ final syncEngineForAccountFactoryProvider = BusyProvider.nextcloud => throw StateError( 'DAV task accounts must use DavSynchronizationEngine.', ), + BusyProvider.webCal => throw StateError( + 'WebCal subscriptions do not have a task client.', + ), }; return SyncEngine( @@ -500,6 +520,9 @@ final calendarSyncEngineForAccountFactoryProvider = BusyProvider.nextcloud => throw StateError( 'DAV calendar accounts must use DavSynchronizationEngine.', ), + BusyProvider.webCal => throw StateError( + 'WebCal subscriptions use WebCalSubscriptionService.', + ), }; return CalendarSyncEngine( database: ref.read(databaseProvider), @@ -545,16 +568,15 @@ final accountSyncOperationsProvider = Provider((ref) { } final routing = RoutingAccountSyncOperations( - usesDav: (accountId) async { - final provider = await providerForAccount(accountId); - return provider == BusyProvider.appleICloud || - provider == BusyProvider.nextcloud; - }, + providerForAccount: providerForAccount, syncDav: (accountId, {required full}) async { await 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)( @@ -613,8 +635,8 @@ final allAccountsSyncRunnerProvider = Provider((ref) { return () async { await ref.read(networkConnectivityMonitorProvider).requireNetwork(); - await runAllSignedInAccountSync( - listSignedInAccounts: ref + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: ref .read(accountsRepositoryProvider) .listSyncEligibleAccounts, syncAccount: syncAccount, @@ -647,6 +669,34 @@ final calendarRepositoryProvider = Provider((ref) { ); }); +final webCalHttpTransportProvider = Provider( + (ref) => IoWebCalHttpTransport(), +); + +final webCalSubscriptionServiceProvider = Provider(( + ref, +) { + return WebCalSubscriptionService( + database: ref.watch(databaseProvider), + secretStore: ref.watch(secretStoreProvider), + httpTransport: ref.watch(webCalHttpTransportProvider), + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), + ); +}); + +final webCalSubscriptionsProvider = + StreamProvider>((ref) { + return ref.watch(webCalSubscriptionServiceProvider).watchSubscriptions(); + }); + +final icalImportServiceProvider = Provider((ref) { + return IcalImportService( + database: ref.watch(databaseProvider), + calendarRepository: ref.watch(calendarRepositoryProvider), + ); +}); + final scheduleRepositoryProvider = Provider((ref) { return ScheduleRepository(ref.watch(databaseProvider)); }); @@ -840,9 +890,9 @@ final syncSchedulerProvider = Provider((ref) { } final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: ref + listSyncEligibleAccounts: ref .read(accountsRepositoryProvider) - .listSignedInAccounts, + .listSyncEligibleAccounts, syncAccount: syncAccount, onSyncFailure: ref .watch(desktopNotificationServiceProvider) @@ -852,15 +902,55 @@ final syncSchedulerProvider = Provider((ref) { canSync: ref.watch(networkConnectivityMonitorProvider).canUseNetwork, ); scheduler.start(); - ref.onDispose(scheduler.stop); + ref.onDispose(() => unawaited(scheduler.dispose())); return scheduler; }); +final syncSchedulerRunningProvider = StreamProvider((ref) { + return ref.watch(syncSchedulerProvider).watchRunning(); +}); + +final trayPresentationServiceProvider = + Provider((ref) { + return BusyMaxTrayPresentationService( + accountsRepository: ref.watch(accountsRepositoryProvider), + calendarRepository: ref.watch(calendarRepositoryProvider), + scheduleRepository: ref.watch(scheduleRepositoryProvider), + listTaskLists: (account) => ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .listTaskLists(), + canCreateTask: (account, taskList) async { + if (account.provider == BusyProvider.google || + account.provider == BusyProvider.microsoft) { + return adapterDefaultTaskCapabilities( + account.provider, + ).canCreateTasks; + } + if (account.provider != BusyProvider.nextcloud) return false; + final capabilities = await ref.read( + davTaskCollectionCapabilitiesProvider(( + accountId: account.id, + taskListId: taskList.id, + )).future, + ); + return capabilities?.canCreateTasks == true; + }, + settings: () => ref.read(appSettingsControllerProvider), + connectivity: () => + ref.read(networkConnectivityMonitorProvider).availability, + synchronizationRunning: () => ref.read(syncSchedulerProvider).isRunning, + ); + }); + Future _markAccountReconnectRequiredForSyncError( Ref ref, String accountId, Object error, ) async { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (account?.isAuthenticationAccount != true) return; if (syncFailureNotificationDisposition(error) != SyncFailureNotificationDisposition.reconnectRequired) { return; diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 2891ffb8..2b28b828 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -17,12 +17,15 @@ final rootNavigatorKey = GlobalKey(); final appRouterProvider = Provider((ref) { final session = ref.watch(authSessionControllerProvider); + final hasSubscriptions = + ref.watch(webCalSubscriptionsProvider).valueOrNull?.isNotEmpty == true; + final canOpenSchedule = session.isSignedIn || hasSubscriptions; return GoRouter( navigatorKey: rootNavigatorKey, initialLocation: session.status == AuthSessionStatus.loading ? '/' - : session.isSignedIn + : canOpenSchedule ? '/schedule' : '/sign-in', redirect: (context, state) { @@ -31,16 +34,16 @@ final appRouterProvider = Provider((ref) { } if (state.matchedLocation == '/') { - return session.isSignedIn ? '/schedule' : '/sign-in'; + return canOpenSchedule ? '/schedule' : '/sign-in'; } - if (!session.isSignedIn && state.matchedLocation != '/sign-in') { + if (!canOpenSchedule && + state.matchedLocation != '/sign-in' && + state.matchedLocation != '/settings') { return '/sign-in'; } - if (session.isSignedIn && - session.status == AuthSessionStatus.signedIn && - state.matchedLocation == '/sign-in') { + if (canOpenSchedule && state.matchedLocation == '/sign-in') { return '/schedule'; } return null; diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 1f0ba324..b4b1a781 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -6,11 +6,13 @@ import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../platform/busymax_tray_service.dart'; -import '../features/connectivity/network_connectivity_service.dart'; +import '../features/tray/domain/tray_presentation.dart'; +import '../features/tray/domain/tray_presentation_formatter.dart'; import '../platform/gtk_font_service.dart'; import '../platform/linux_header_bar_configuration_synchronizer.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/linux_window_service.dart'; +import '../platform/external_calendar_open_service.dart'; import '../l10n/locale_resolution.dart'; import '../schedule/schedule_commands.dart'; import 'app_bootstrap.dart'; @@ -22,13 +24,11 @@ import 'busymax_yaru_theme.dart'; import 'busymax_design.dart'; import 'system_accent.dart'; import 'app_theme.dart'; +import '../features/settings/presentation/settings_screen.dart'; +import '../features/schedule/presentation/schedule_sidebar.dart'; typedef BusyMaxTrayServiceFactory = - BusyMaxTrayService Function({ - required LinuxWindowService windowService, - required BusyMaxTrayLabels labels, - required Future Function() onOpenAgenda, - }); + BusyMaxTrayService Function(BusyMaxTrayServiceConfiguration configuration); BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( ThemeData theme, { @@ -83,6 +83,9 @@ class _BusyMaxAppState extends ConsumerState { bool _startMinimizedHandled = false; bool _settingsReady = false; var _scheduleCommandSequence = 0; + BusyMaxTrayPresentationFormatter? _trayPresentationFormatter; + StreamSubscription? _externalOpenSubscription; + Future _externalOpenTail = Future.value(); late final BusyMaxHeaderBarConfigurationSynchronizer _headerBarConfigurationSynchronizer; @@ -93,12 +96,21 @@ class _BusyMaxAppState extends ConsumerState { BusyMaxHeaderBarConfigurationSynchronizer( ref.read(linuxHeaderBarServiceProvider), ); + _externalOpenSubscription = ref + .read(externalCalendarOpenServiceProvider) + .requests + .listen((request) { + _externalOpenTail = _externalOpenTail + .catchError((Object _) {}) + .then((_) => _handleExternalCalendarOpen(request)); + }); unawaited(_waitForSettings()); } @override void dispose() { _headerBarConfigurationSynchronizer.dispose(); + unawaited(_externalOpenSubscription?.cancel()); final tray = _trayService; if (tray != null) { unawaited(tray.stop()); @@ -106,6 +118,34 @@ class _BusyMaxAppState extends ConsumerState { super.dispose(); } + Future _handleExternalCalendarOpen( + ExternalCalendarOpenRequest request, + ) async { + if (!mounted) return; + final router = ref.read(appRouterProvider); + switch (request.kind) { + case ExternalCalendarOpenKind.webCal: + router.go('/settings?page=accounts'); + case ExternalCalendarOpenKind.icsFile: + final session = ref.read(authSessionControllerProvider); + router.go(session.isSignedIn ? '/schedule' : '/settings?page=accounts'); + } + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final dialogContext = rootNavigatorKey.currentContext; + if (dialogContext == null || !dialogContext.mounted) return; + switch (request.kind) { + case ExternalCalendarOpenKind.webCal: + await showAddCalendarSubscriptionFlow( + dialogContext, + ref, + initialUrl: request.value, + ); + case ExternalCalendarOpenKind.icsFile: + await showIcsImportFlow(dialogContext, ref, filePath: request.value); + } + } + Future _waitForSettings() async { await ref.read(appSettingsControllerProvider.notifier).ready; if (!mounted) { @@ -123,10 +163,9 @@ class _BusyMaxAppState extends ConsumerState { .valueOrNull; final gtkFont = ref.watch(gtkFontSettingsProvider).valueOrNull; final gtkThemeColors = ref.watch(gtkThemeColorsProvider).valueOrNull; - final networkAvailability = - ref.watch(networkAvailabilityProvider).valueOrNull ?? - ref.read(networkConnectivityMonitorProvider).availability; + ref.watch(networkAvailabilityProvider); ref.watch(syncSchedulerProvider); + ref.watch(syncSchedulerRunningProvider); ref.watch(networkReconnectSyncCoordinatorProvider); ref.watch(notificationSchedulerProvider); ref.watch(dueTodayNotificationProvider); @@ -182,19 +221,44 @@ class _BusyMaxAppState extends ConsumerState { supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { final l10n = AppLocalizations.of(context); - _configureNativeHeaderBarTheme(context); - _configureBackgroundServices( - ref, - settings, - BusyMaxTrayLabels( - openBusyMax: l10n.trayOpenBusyMax, - agenda: l10n.viewAgenda, - quitBusyMax: l10n.exit, + final material = MaterialLocalizations.of(context); + final alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf( + context, + ); + final trayFormatter = BusyMaxTrayPresentationFormatter( + BusyMaxTrayPresentationStrings( + showBusyMax: l10n.trayShowBusyMax, + newEvent: l10n.trayNewEvent, + newTask: l10n.trayNewTask, + today: l10n.trayToday, + allDay: l10n.trayAllDay, + now: l10n.trayNow, + calendarEvent: l10n.trayCalendarEvent, + untitledEvent: l10n.trayUntitledEvent, + nothingElseToday: l10n.trayNothingElseToday, + openTodayAgenda: l10n.trayOpenTodayAgenda, + syncNow: l10n.traySyncNow, + syncing: l10n.traySyncing, + notConnected: l10n.trayNotConnected, + notYetSynced: l10n.trayNotYetSynced, + settings: l10n.traySettings, + quitBusyMax: l10n.trayQuitBusyMax, offline: l10n.networkOffline, offlineDescription: l10n.networkOfflineDescription, + formatTime: (value) => material.formatTimeOfDay( + TimeOfDay.fromDateTime(value), + alwaysUse24HourFormat: alwaysUse24HourFormat, + ), + tasksDueToday: l10n.trayTasksDueToday, + lastSyncedJustNow: l10n.trayLastSyncedJustNow, + lastSyncedMinutesAgo: l10n.trayLastSyncedMinutesAgo, + lastSyncedHoursAgo: l10n.trayLastSyncedHoursAgo, + lastSyncedDaysAgo: l10n.trayLastSyncedDaysAgo, ), - offline: networkAvailability == NetworkAvailability.offline, ); + _trayPresentationFormatter = trayFormatter; + _configureNativeHeaderBarTheme(context); + _configureBackgroundServices(ref, settings, trayFormatter); return Shortcuts( shortcuts: const { BusyMaxShortcutActivators.keyboardShortcuts: @@ -300,9 +364,8 @@ class _BusyMaxAppState extends ConsumerState { void _configureBackgroundServices( WidgetRef ref, AppSettings settings, - BusyMaxTrayLabels labels, { - required bool offline, - }) { + BusyMaxTrayPresentationFormatter trayFormatter, + ) { if (!_settingsReady) { return; } @@ -320,13 +383,7 @@ class _BusyMaxAppState extends ConsumerState { (_trayService?.available ?? false), ); if (_trayService != null) { - unawaited( - _updateTrayPresentation( - _trayService!, - labels: labels, - offline: offline, - ), - ); + unawaited(_trayService!.refreshPresentation()); } if (_lastTrayEnabled == trayEnabled) { return; @@ -334,10 +391,10 @@ class _BusyMaxAppState extends ConsumerState { _lastTrayEnabled = trayEnabled; final tray = _trayService ??= _createTrayService( windowService: windowService, - labels: labels, - onOpenAgenda: () => _openMainAgenda(ref, windowService), + formatter: trayFormatter, + settings: settings, ); - unawaited(_updateTrayPresentation(tray, labels: labels, offline: offline)); + unawaited(tray.refreshPresentation()); if (trayEnabled) { unawaited( _startTray( @@ -353,47 +410,123 @@ class _BusyMaxAppState extends ConsumerState { } } - Future _updateTrayPresentation( - BusyMaxTrayService tray, { - required BusyMaxTrayLabels labels, - required bool offline, - }) async { - await tray.updateOfflineState(offline); - await tray.updateLabels(labels); - } - BusyMaxTrayService _createTrayService({ required LinuxWindowService windowService, - required BusyMaxTrayLabels labels, - required Future Function() onOpenAgenda, + required BusyMaxTrayPresentationFormatter formatter, + required AppSettings settings, }) { + final initialPresentation = formatter.format( + BusyMaxTrayPresentation( + connectivity: ref.read(networkConnectivityMonitorProvider).availability, + synchronizationRunning: ref.read(syncSchedulerProvider).isRunning, + lastSuccessfulSynchronizationUtc: null, + events: const [], + incompleteTasksDueToday: 0, + canCreateEvent: false, + canCreateTask: false, + hasSyncEligibleAccount: false, + notificationDetailLevel: settings.notificationDetailLevel, + localNow: DateTime.now(), + ), + ); + final configuration = BusyMaxTrayServiceConfiguration( + initialPresentation: initialPresentation, + loadPresentation: _loadTrayPresentation, + actions: BusyMaxTrayActions( + showBusyMax: windowService.showWindow, + newEvent: () => _openTrayNewEvent(windowService), + newTask: () => _openTrayNewTask(windowService), + openEvent: (event) => _openTrayEvent(windowService, event), + openTasksDueToday: () => _openTrayTasksDueToday(windowService), + openTodayAgenda: () => _openMainAgenda(windowService), + synchronize: () => ref.read(syncSchedulerProvider).runNow(), + openSettings: () => _openTraySettings(windowService), + quitBusyMax: windowService.quitApp, + ), + ); final factory = widget.trayServiceFactory; if (factory != null) { - return factory( - windowService: windowService, - labels: labels, - onOpenAgenda: onOpenAgenda, - ); + return factory(configuration); } - return BusyMaxTrayService( - windowService: windowService, - labels: labels, - onOpenAgenda: onOpenAgenda, - ); + return BusyMaxTrayService(configuration: configuration); } - Future _openMainAgenda( - WidgetRef ref, + Future _loadTrayPresentation() async { + final formatter = _trayPresentationFormatter; + if (formatter == null) { + throw StateError('Tray localization is not ready.'); + } + final presentation = await ref.read(trayPresentationServiceProvider).load(); + return formatter.format(presentation); + } + + Future _openTrayNewEvent(LinuxWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/schedule'); + _issueScheduleCommand(ScheduleWorkspaceCommandKind.newEvent); + } + + Future _openTrayNewTask(LinuxWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/tasks'); + _issueScheduleCommand(ScheduleWorkspaceCommandKind.newTask); + } + + Future _openTrayEvent( LinuxWindowService windowService, + BusyMaxTrayEventEntry event, ) async { await windowService.showWindow(); + ref.read(appRouterProvider).go('/schedule'); + _issueScheduleCommand( + ScheduleWorkspaceCommandKind.openCalendarEvent, + date: event.start, + accountId: event.accountId, + sourceId: event.calendarSourceId, + itemId: event.eventId, + ); + } + + Future _openTrayTasksDueToday(LinuxWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/tasks'); + _issueScheduleCommand( + ScheduleWorkspaceCommandKind.agenda, + date: DateTime.now(), + ); + } + + Future _openTraySettings(LinuxWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/settings'); + } + + Future _openMainAgenda(LinuxWindowService windowService) async { + await windowService.showWindow(); + ref.read(appRouterProvider).go('/schedule'); + _issueScheduleCommand( + ScheduleWorkspaceCommandKind.agenda, + date: DateTime.now(), + ); + } + + void _issueScheduleCommand( + ScheduleWorkspaceCommandKind kind, { + DateTime? date, + String? accountId, + String? sourceId, + String? itemId, + }) { ref .read(scheduleWorkspaceCommandProvider.notifier) .state = ScheduleWorkspaceCommand( - ScheduleWorkspaceCommandKind.agenda, + kind, ++_scheduleCommandSequence, + date: date, + accountId: accountId, + sourceId: sourceId, + itemId: itemId, ); - ref.read(appRouterProvider).go('/schedule'); } void _setHideOnClose(LinuxWindowService windowService, bool enabled) { diff --git a/lib/src/calendar_providers/calendar_colors.dart b/lib/src/calendar_providers/calendar_colors.dart index d9078af6..2c3c6479 100644 --- a/lib/src/calendar_providers/calendar_colors.dart +++ b/lib/src/calendar_providers/calendar_colors.dart @@ -43,7 +43,9 @@ List calendarColorChoices(BusyProvider provider) { return switch (provider) { BusyProvider.google => googleCalendarColorChoices, BusyProvider.microsoft => microsoftCalendarColorChoices, - BusyProvider.appleICloud || BusyProvider.nextcloud => const [], + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => const [], }; } diff --git a/lib/src/calendar_providers/calendar_provider_capabilities.dart b/lib/src/calendar_providers/calendar_provider_capabilities.dart index 201032a6..804aaed2 100644 --- a/lib/src/calendar_providers/calendar_provider_capabilities.dart +++ b/lib/src/calendar_providers/calendar_provider_capabilities.dart @@ -88,7 +88,9 @@ CalendarManagementCapabilities calendarManagementCapabilities( final cloudCapabilities = switch (provider) { BusyProvider.google => googleCalendarProviderCapabilities, BusyProvider.microsoft => microsoftCalendarProviderCapabilities, - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; return CalendarManagementCapabilities( supportsCreate: cloudCapabilities?.supportsCreateCalendar ?? false, diff --git a/lib/src/core/secrets/secret_store.dart b/lib/src/core/secrets/secret_store.dart index 0e9d083c..6c9de7cc 100644 --- a/lib/src/core/secrets/secret_store.dart +++ b/lib/src/core/secrets/secret_store.dart @@ -12,13 +12,19 @@ import '../logging/redacting_logger.dart'; const secretRecordSchemaVersion = 1; -enum CredentialKind { oauth, appleAppSpecificPassword, nextcloudAppPassword } +enum CredentialKind { + oauth, + appleAppSpecificPassword, + nextcloudAppPassword, + webCalSubscription, +} extension CredentialKindValue on CredentialKind { String get storageValue => switch (this) { CredentialKind.oauth => 'oauth', CredentialKind.appleAppSpecificPassword => 'apple_app_specific_password', CredentialKind.nextcloudAppPassword => 'nextcloud_app_password', + CredentialKind.webCalSubscription => 'webcal_subscription', }; } @@ -64,6 +70,19 @@ sealed class SecretRecord { loginName: _requiredSecretString(json, 'loginName'), appPassword: _requiredSecretString(json, 'appPassword'), ), + 'webcal_subscription' => WebCalSecretRecord( + normalizedSubscriptionUri: _requiredSecretString( + json, + 'normalizedSubscriptionUri', + ), + validatorTargetUri: switch (_optionalSecretString( + json, + 'validatorTargetUri', + )) { + final value? => Uri.parse(value), + null => null, + }, + ), final Object? value => throw SecretStoreCorruptException( 'Unsupported credential kind $value.', ), @@ -168,6 +187,49 @@ final class NextcloudSecretRecord extends SecretRecord { }; } +final class WebCalSecretRecord extends SecretRecord { + WebCalSecretRecord({ + required this.normalizedSubscriptionUri, + this.validatorTargetUri, + }) : super( + provider: BusyProvider.webCal, + kind: CredentialKind.webCalSubscription, + ) { + _requireSafeSubscriptionUri(Uri.parse(normalizedSubscriptionUri)); + final target = validatorTargetUri; + if (target != null) _requireSafeSubscriptionUri(target); + } + + final String normalizedSubscriptionUri; + final Uri? validatorTargetUri; + + @override + Map toJson() => { + 'schemaVersion': secretRecordSchemaVersion, + 'kind': kind.storageValue, + 'provider': provider.storageValue, + 'normalizedSubscriptionUri': normalizedSubscriptionUri, + if (validatorTargetUri != null) + 'validatorTargetUri': validatorTargetUri.toString(), + }; + + WebCalSecretRecord copyWithValidatorTarget(Uri? value) => WebCalSecretRecord( + normalizedSubscriptionUri: normalizedSubscriptionUri, + validatorTargetUri: value, + ); +} + +void _requireSafeSubscriptionUri(Uri value) { + if (value.scheme != 'https' || + value.host.isEmpty || + value.userInfo.isNotEmpty || + value.hasFragment) { + throw const SecretStoreCorruptException( + 'A WebCal credential contains an invalid subscription URI.', + ); + } +} + abstract interface class SecretStore { Future readActiveAccountId(); Future setActiveAccountId(String accountId); diff --git a/lib/src/dav/dav_provider_profile.dart b/lib/src/dav/dav_provider_profile.dart index 1a69ddbe..103aedd1 100644 --- a/lib/src/dav/dav_provider_profile.dart +++ b/lib/src/dav/dav_provider_profile.dart @@ -37,7 +37,9 @@ final class DavProviderProfile { BusyProvider.nextcloud => _sameOrigin(destination, accountAuthority) && _preservesInstallationPath(destination, accountAuthority), - BusyProvider.google || BusyProvider.microsoft => false, + BusyProvider.google || + BusyProvider.microsoft || + BusyProvider.webCal => false, }; } } @@ -76,7 +78,9 @@ DavProviderProfile davProviderProfile( allowSchedulingMutations: false, allowMove: true, ), - BusyProvider.google || BusyProvider.microsoft => throw ArgumentError.value( + BusyProvider.google || + BusyProvider.microsoft || + BusyProvider.webCal => throw ArgumentError.value( provider, 'provider', 'The provider does not use the DAV transport.', diff --git a/lib/src/dav/ical/ical_semantics.dart b/lib/src/dav/ical/ical_semantics.dart index bb326bb8..db572c3f 100644 --- a/lib/src/dav/ical/ical_semantics.dart +++ b/lib/src/dav/ical/ical_semantics.dart @@ -257,7 +257,15 @@ final class IcalSemanticDocument { }); factory IcalSemanticDocument.parse(String rawIcs) { - final document = IcalDocument.parse(rawIcs); + return IcalSemanticDocument.fromDocument(IcalDocument.parse(rawIcs)); + } + + /// Builds the CalDAV semantic view from an already parsed document. + /// + /// This retains the existing one-component-type, one-UID recurrence-set + /// invariant. Multi-UID calendar ingestion must split a snapshot into + /// recurrence-set documents before using this factory. + factory IcalSemanticDocument.fromDocument(IcalDocument document) { final semanticComponents = []; final timeZones = []; for (final component in document.calendarComponents) { diff --git a/lib/src/dav/storage/dav_object_repository.dart b/lib/src/dav/storage/dav_object_repository.dart index 34275972..37f9c7c2 100644 --- a/lib/src/dav/storage/dav_object_repository.dart +++ b/lib/src/dav/storage/dav_object_repository.dart @@ -5,6 +5,7 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../../db/app_database.dart'; +import '../../ical/ical_event_projection.dart'; import '../../providers/busy_provider.dart'; import '../dav_errors.dart'; import '../ical/ical_document.dart'; @@ -892,70 +893,27 @@ final class DavObjectRepository { safeMessage: 'The calendar projection source was missing.', ); } - final occurrences = _recurrenceExpander.expand( - semantic, - rangeStartUtc: commit.projectionRangeStartUtc, - rangeEndUtc: commit.projectionRangeEndUtc, - ); - final timeZoneResolver = IcalTimeZoneResolver.fromDocument(semantic); - final recurringUids = { - for (final component in semantic.components) - if (component.componentType == 'VEVENT' && - (component.recurrenceId != null || - component.recurrenceRules.isNotEmpty || - component.recurrenceDates.isNotEmpty || - component.exceptionDates.isNotEmpty)) - component.uid, - }; + final projections = + IcalEventProjector( + recurrenceExpander: _recurrenceExpander, + ).projectSemantic( + semantic, + rangeStartUtc: commit.projectionRangeStartUtc, + rangeEndUtc: commit.projectionRangeEndUtc, + transport: 'caldav', + ); final now = commit.completedAtUtc.toUtc().millisecondsSinceEpoch; - for (final occurrence in occurrences) { - final component = occurrence.effectiveComponent; - final identityComponent = occurrence.identityComponent; + for (final projected in projections) { final componentId = componentIds[_componentKey( - identityComponent.componentType, - identityComponent.uid!, - identityComponent.recurrenceIdKey, + 'VEVENT', + projected.uid, + projected.recurrenceIdKey, )]!; final eventId = _stableProjectionId( 'dav-event', - '$objectId\u0000${occurrence.occurrenceKey}', + '$objectId\u0000${projected.occurrenceKey}', ); - final allDay = occurrence.start.kind == IcalTemporalKind.date; - final recurrence = { - 'rules': occurrence.master.recurrenceRules, - 'dates': occurrence.master.recurrenceDates, - 'excludedDates': occurrence.master.exceptionDates, - }; - final attendees = component.attendees.isEmpty - ? occurrence.master.attendees - : component.attendees; - final organizers = component.organizers.isEmpty - ? occurrence.master.organizers - : component.organizers; - final categories = component.categories.isEmpty - ? occurrence.master.categories - : component.categories; - final alarms = component.alarms.isEmpty - ? occurrence.master.alarms - : component.alarms; - final startUtc = _resolvedTemporalUtc(occurrence.start, timeZoneResolver); - final endUtc = occurrence.end == null - ? null - : _resolvedTemporalUtc(occurrence.end!, timeZoneResolver); - final projectionJson = jsonEncode({ - 'transport': 'caldav', - 'uid': component.uid, - 'occurrenceKey': occurrence.occurrenceKey, - 'nativeStart': _temporalJson(occurrence.start), - if (occurrence.end != null) 'nativeEnd': _temporalJson(occurrence.end!), - if (startUtc != null) 'startUtc': startUtc.toIso8601String(), - if (endUtc != null) 'endUtc': endUtc.toIso8601String(), - 'extensionProperties': { - ...occurrence.master.extensionProperties, - ...component.extensionProperties, - }, - }); await _database .into(_database.calendarEvents) .insertOnConflictUpdate( @@ -969,93 +927,47 @@ final class DavObjectRepository { davCollectionId: Value(commit.collectionId), davObjectId: Value(objectId), davComponentId: Value(componentId), - icalUid: Value(component.uid), - recurrenceIdKey: Value(identityComponent.recurrenceIdKey), - occurrenceKey: Value(occurrence.occurrenceKey), + icalUid: Value(projected.uid), + recurrenceIdKey: Value(projected.recurrenceIdKey), + occurrenceKey: Value(projected.occurrenceKey), projectionVersion: const Value(davProjectionVersion), providerRecurringEventId: Value( - recurringUids.contains(occurrence.master.uid) - ? occurrence.master.uid - : null, + projected.recurring ? projected.uid : null, ), providerOriginalStartKey: Value( - recurringUids.contains(occurrence.master.uid) - ? occurrence.occurrenceKey - : null, + projected.recurring ? projected.occurrenceKey : null, ), etagOrChangeKey: Value(etag), - status: Value(component.status ?? occurrence.master.status), - title: occurrence.summary ?? '', - description: Value(occurrence.description), - location: Value(occurrence.location), - allDay: Value(allDay), - startDate: Value( - allDay ? _storageTemporal(occurrence.start) : null, - ), - startDateTime: Value( - allDay ? null : _storageTemporal(occurrence.start), - ), - startTimeZone: Value(_timeZoneName(occurrence.start)), - endDate: Value( - allDay && occurrence.end != null - ? _storageTemporal(occurrence.end!) - : null, - ), - endDateTime: Value( - !allDay && occurrence.end != null - ? _storageTemporal(occurrence.end!) - : null, - ), - endTimeZone: Value( - occurrence.end == null ? null : _timeZoneName(occurrence.end!), - ), - recurrenceJson: Value(jsonEncode(recurrence)), - remindersJson: Value( - jsonEncode({ - 'minutes': _eventReminderMinutes(alarms), - 'alarms': _alarmProjection(alarms), - }), - ), - attendeesJson: Value(jsonEncode(attendees)), - categoriesJson: Value(jsonEncode(categories)), - organizerJson: Value( - organizers.isEmpty ? null : jsonEncode(organizers.first), - ), + status: Value(projected.status), + title: projected.title, + description: Value(projected.description), + location: Value(projected.location), + allDay: Value(projected.allDay), + startDate: Value(projected.startDate), + startDateTime: Value(projected.startDateTime), + startTimeZone: Value(projected.startTimeZone), + endDate: Value(projected.endDate), + endDateTime: Value(projected.endDateTime), + endTimeZone: Value(projected.endTimeZone), + recurrenceJson: Value(projected.recurrenceJson), + remindersJson: Value(projected.remindersJson), + attendeesJson: Value(projected.attendeesJson), + categoriesJson: Value(projected.categoriesJson), + organizerJson: Value(projected.organizerJson), colorHex: Value(collection.color), - visibility: Value( - component.classification ?? occurrence.master.classification, - ), - transparencyOrShowAs: Value( - component.transparency ?? occurrence.master.transparency, - ), - webLink: Value( - _propertyRaw(component, 'URL') ?? - _propertyRaw(occurrence.master, 'URL'), - ), - attachmentsJson: Value( - jsonEncode( - _propertyRawValues(component, 'ATTACH').isEmpty - ? _propertyRawValues(occurrence.master, 'ATTACH') - : _propertyRawValues(component, 'ATTACH'), - ), - ), - isCancelled: Value(occurrence.isCancelled), + visibility: Value(projected.visibility), + transparencyOrShowAs: Value(projected.transparency), + webLink: Value(projected.webLink), + attachmentsJson: Value(projected.attachmentsJson), + isCancelled: Value(projected.cancelled), isDeleted: const Value(false), - rawJson: Value(projectionJson), - createdAtServer: Value( - _storageTemporalNullable( - component.created ?? occurrence.master.created, - ), - ), - updatedAtServer: Value( - _storageTemporalNullable( - component.lastModified ?? occurrence.master.lastModified, - ), - ), + rawJson: Value(projected.rawJson), + createdAtServer: Value(projected.createdAtServer), + updatedAtServer: Value(projected.updatedAtServer), createdAtLocal: now, updatedAtLocal: now, syncStatus: const Value('synced'), - baselineRawJson: Value(projectionJson), + baselineRawJson: Value(projected.rawJson), ), ); } @@ -1398,32 +1310,6 @@ List> _alarmProjection(List alarms) => [ }, ]; -List _eventReminderMinutes(List alarms) { - final result = []; - for (final alarm in alarms) { - final action = alarm.firstProperty('ACTION')?.rawValue.toUpperCase(); - if (action != 'DISPLAY' && action != 'AUDIO') { - continue; - } - final trigger = alarm.firstProperty('TRIGGER'); - if (trigger == null || - trigger.parameterValue('RELATED')?.toUpperCase() == 'END') { - continue; - } - try { - final duration = parseIcalDuration(trigger.rawValue.toUpperCase()); - if (duration == null || !duration.negative) continue; - final absolute = -duration.duration; - if (absolute.inSeconds <= 0 || absolute.inSeconds % 60 != 0) continue; - final minutes = absolute.inMinutes; - if (!result.contains(minutes)) result.add(minutes); - } on DavException { - // Absolute and unsupported trigger forms remain in the raw alarm list. - } - } - return result; -} - ({String dateTime, String? timeZone})? _taskReminderProjection( List alarms, { required IcalTemporalValue? start, @@ -1493,15 +1379,6 @@ DateTime? _resolvedTemporalUtc( }; } -String? _propertyRaw(IcalSemanticComponent component, String name) => - component.documentComponent.firstProperty(name)?.rawValue; - -List _propertyRawValues(IcalSemanticComponent component, String name) => - component.documentComponent - .propertiesNamed(name) - .map((property) => property.rawValue) - .toList(growable: false); - bool _extensionFlag(Map> extensions, String name) { final value = extensions[name]?.firstOrNull?.trim().toUpperCase(); return value == 'TRUE' || value == '1'; diff --git a/lib/src/db/app_database.dart b/lib/src/db/app_database.dart index 699efea7..4b3c9cb3 100644 --- a/lib/src/db/app_database.dart +++ b/lib/src/db/app_database.dart @@ -28,6 +28,8 @@ part 'daos/tasks_dao.dart'; SyncRuns, CalendarSources, CalendarEvents, + IcalImportReceipts, + WebCalSubscriptions, CalendarEventAttendees, CalendarEventReminders, SyncCursors, diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart index da3ecf88..7ec99b3b 100644 --- a/lib/src/db/app_database.g.dart +++ b/lib/src/db/app_database.g.dart @@ -18889,534 +18889,304 @@ class CalendarEventsCompanion extends UpdateCompanion { } } -class $CalendarEventAttendeesTable extends CalendarEventAttendees - with TableInfo<$CalendarEventAttendeesTable, CalendarEventAttendee> { +class $IcalImportReceiptsTable extends IcalImportReceipts + with TableInfo<$IcalImportReceiptsTable, IcalImportReceipt> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $CalendarEventAttendeesTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( - 'calendarEventId', + $IcalImportReceiptsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _calendarSourceIdMeta = const VerificationMeta( + 'calendarSourceId', ); @override - late final GeneratedColumn calendarEventId = GeneratedColumn( - 'calendar_event_id', + late final GeneratedColumn calendarSourceId = GeneratedColumn( + 'calendar_source_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES calendar_events (id) ON DELETE CASCADE', + 'REFERENCES calendar_sources (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _emailMeta = const VerificationMeta('email'); + static const VerificationMeta _icalUidMeta = const VerificationMeta( + 'icalUid', + ); @override - late final GeneratedColumn email = GeneratedColumn( - 'email', + late final GeneratedColumn icalUid = GeneratedColumn( + 'ical_uid', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _displayNameMeta = const VerificationMeta( - 'displayName', - ); - @override - late final GeneratedColumn displayName = GeneratedColumn( - 'display_name', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _responseStatusMeta = const VerificationMeta( - 'responseStatus', + static const VerificationMeta _eventIdMeta = const VerificationMeta( + 'eventId', ); @override - late final GeneratedColumn responseStatus = GeneratedColumn( - 'response_status', + late final GeneratedColumn eventId = GeneratedColumn( + 'event_id', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _optionalMeta = const VerificationMeta( - 'optional', - ); - @override - late final GeneratedColumn optional = GeneratedColumn( - 'optional', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("optional" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); - static const VerificationMeta _organizerMeta = const VerificationMeta( - 'organizer', - ); - @override - late final GeneratedColumn organizer = GeneratedColumn( - 'organizer', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("organizer" IN (0, 1))', - ), - defaultValue: const Constant(false), + static const VerificationMeta _importedAtUtcMeta = const VerificationMeta( + 'importedAtUtc', ); - static const VerificationMeta _selfMeta = const VerificationMeta('self'); @override - late final GeneratedColumn self = GeneratedColumn( - 'self', + late final GeneratedColumn importedAtUtc = GeneratedColumn( + 'imported_at_utc', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("self" IN (0, 1))', - ), - defaultValue: const Constant(false), - ); - static const VerificationMeta _rawJsonMeta = const VerificationMeta( - 'rawJson', - ); - @override - late final GeneratedColumn rawJson = GeneratedColumn( - 'raw_json', - aliasedName, - true, type: DriftSqlType.string, - requiredDuringInsert: false, + requiredDuringInsert: true, ); @override List get $columns => [ - id, - calendarEventId, - email, - displayName, - responseStatus, - optional, - organizer, - self, - rawJson, + calendarSourceId, + icalUid, + eventId, + importedAtUtc, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'calendar_event_attendees'; + static const String $name = 'ical_import_receipts'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('calendar_event_id')) { + if (data.containsKey('calendar_source_id')) { context.handle( - _calendarEventIdMeta, - calendarEventId.isAcceptableOrUnknown( - data['calendar_event_id']!, - _calendarEventIdMeta, + _calendarSourceIdMeta, + calendarSourceId.isAcceptableOrUnknown( + data['calendar_source_id']!, + _calendarSourceIdMeta, ), ); } else if (isInserting) { - context.missing(_calendarEventIdMeta); + context.missing(_calendarSourceIdMeta); } - if (data.containsKey('email')) { + if (data.containsKey('ical_uid')) { context.handle( - _emailMeta, - email.isAcceptableOrUnknown(data['email']!, _emailMeta), + _icalUidMeta, + icalUid.isAcceptableOrUnknown(data['ical_uid']!, _icalUidMeta), ); } else if (isInserting) { - context.missing(_emailMeta); + context.missing(_icalUidMeta); } - if (data.containsKey('display_name')) { + if (data.containsKey('event_id')) { context.handle( - _displayNameMeta, - displayName.isAcceptableOrUnknown( - data['display_name']!, - _displayNameMeta, - ), + _eventIdMeta, + eventId.isAcceptableOrUnknown(data['event_id']!, _eventIdMeta), ); } - if (data.containsKey('response_status')) { + if (data.containsKey('imported_at_utc')) { context.handle( - _responseStatusMeta, - responseStatus.isAcceptableOrUnknown( - data['response_status']!, - _responseStatusMeta, + _importedAtUtcMeta, + importedAtUtc.isAcceptableOrUnknown( + data['imported_at_utc']!, + _importedAtUtcMeta, ), ); - } - if (data.containsKey('optional')) { - context.handle( - _optionalMeta, - optional.isAcceptableOrUnknown(data['optional']!, _optionalMeta), - ); - } - if (data.containsKey('organizer')) { - context.handle( - _organizerMeta, - organizer.isAcceptableOrUnknown(data['organizer']!, _organizerMeta), - ); - } - if (data.containsKey('self')) { - context.handle( - _selfMeta, - self.isAcceptableOrUnknown(data['self']!, _selfMeta), - ); - } - if (data.containsKey('raw_json')) { - context.handle( - _rawJsonMeta, - rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), - ); + } else if (isInserting) { + context.missing(_importedAtUtcMeta); } return context; } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {calendarSourceId, icalUid}; @override - CalendarEventAttendee map(Map data, {String? tablePrefix}) { + IcalImportReceipt map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return CalendarEventAttendee( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - calendarEventId: attachedDatabase.typeMapping.read( + return IcalImportReceipt( + calendarSourceId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}calendar_event_id'], + data['${effectivePrefix}calendar_source_id'], )!, - email: attachedDatabase.typeMapping.read( + icalUid: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}email'], + data['${effectivePrefix}ical_uid'], )!, - displayName: attachedDatabase.typeMapping.read( + eventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}display_name'], + data['${effectivePrefix}event_id'], ), - responseStatus: attachedDatabase.typeMapping.read( + importedAtUtc: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}response_status'], - ), - optional: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}optional'], - )!, - organizer: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}organizer'], - )!, - self: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}self'], + data['${effectivePrefix}imported_at_utc'], )!, - rawJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}raw_json'], - ), ); } @override - $CalendarEventAttendeesTable createAlias(String alias) { - return $CalendarEventAttendeesTable(attachedDatabase, alias); + $IcalImportReceiptsTable createAlias(String alias) { + return $IcalImportReceiptsTable(attachedDatabase, alias); } } -class CalendarEventAttendee extends DataClass - implements Insertable { - final String id; - final String calendarEventId; - final String email; - final String? displayName; - final String? responseStatus; - final bool optional; - final bool organizer; - final bool self; - final String? rawJson; - const CalendarEventAttendee({ - required this.id, - required this.calendarEventId, - required this.email, - this.displayName, - this.responseStatus, - required this.optional, - required this.organizer, - required this.self, - this.rawJson, +class IcalImportReceipt extends DataClass + implements Insertable { + final String calendarSourceId; + final String icalUid; + final String? eventId; + final String importedAtUtc; + const IcalImportReceipt({ + required this.calendarSourceId, + required this.icalUid, + this.eventId, + required this.importedAtUtc, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); - map['calendar_event_id'] = Variable(calendarEventId); - map['email'] = Variable(email); - if (!nullToAbsent || displayName != null) { - map['display_name'] = Variable(displayName); - } - if (!nullToAbsent || responseStatus != null) { - map['response_status'] = Variable(responseStatus); - } - map['optional'] = Variable(optional); - map['organizer'] = Variable(organizer); - map['self'] = Variable(self); - if (!nullToAbsent || rawJson != null) { - map['raw_json'] = Variable(rawJson); + map['calendar_source_id'] = Variable(calendarSourceId); + map['ical_uid'] = Variable(icalUid); + if (!nullToAbsent || eventId != null) { + map['event_id'] = Variable(eventId); } + map['imported_at_utc'] = Variable(importedAtUtc); return map; } - CalendarEventAttendeesCompanion toCompanion(bool nullToAbsent) { - return CalendarEventAttendeesCompanion( - id: Value(id), - calendarEventId: Value(calendarEventId), - email: Value(email), - displayName: displayName == null && nullToAbsent - ? const Value.absent() - : Value(displayName), - responseStatus: responseStatus == null && nullToAbsent - ? const Value.absent() - : Value(responseStatus), - optional: Value(optional), - organizer: Value(organizer), - self: Value(self), - rawJson: rawJson == null && nullToAbsent + IcalImportReceiptsCompanion toCompanion(bool nullToAbsent) { + return IcalImportReceiptsCompanion( + calendarSourceId: Value(calendarSourceId), + icalUid: Value(icalUid), + eventId: eventId == null && nullToAbsent ? const Value.absent() - : Value(rawJson), + : Value(eventId), + importedAtUtc: Value(importedAtUtc), ); } - factory CalendarEventAttendee.fromJson( + factory IcalImportReceipt.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return CalendarEventAttendee( - id: serializer.fromJson(json['id']), - calendarEventId: serializer.fromJson(json['calendarEventId']), - email: serializer.fromJson(json['email']), - displayName: serializer.fromJson(json['displayName']), - responseStatus: serializer.fromJson(json['responseStatus']), - optional: serializer.fromJson(json['optional']), - organizer: serializer.fromJson(json['organizer']), - self: serializer.fromJson(json['self']), - rawJson: serializer.fromJson(json['rawJson']), + return IcalImportReceipt( + calendarSourceId: serializer.fromJson(json['calendarSourceId']), + icalUid: serializer.fromJson(json['icalUid']), + eventId: serializer.fromJson(json['eventId']), + importedAtUtc: serializer.fromJson(json['importedAtUtc']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), - 'calendarEventId': serializer.toJson(calendarEventId), - 'email': serializer.toJson(email), - 'displayName': serializer.toJson(displayName), - 'responseStatus': serializer.toJson(responseStatus), - 'optional': serializer.toJson(optional), - 'organizer': serializer.toJson(organizer), - 'self': serializer.toJson(self), - 'rawJson': serializer.toJson(rawJson), + 'calendarSourceId': serializer.toJson(calendarSourceId), + 'icalUid': serializer.toJson(icalUid), + 'eventId': serializer.toJson(eventId), + 'importedAtUtc': serializer.toJson(importedAtUtc), }; } - CalendarEventAttendee copyWith({ - String? id, - String? calendarEventId, - String? email, - Value displayName = const Value.absent(), - Value responseStatus = const Value.absent(), - bool? optional, - bool? organizer, - bool? self, - Value rawJson = const Value.absent(), - }) => CalendarEventAttendee( - id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - email: email ?? this.email, - displayName: displayName.present ? displayName.value : this.displayName, - responseStatus: responseStatus.present - ? responseStatus.value - : this.responseStatus, - optional: optional ?? this.optional, - organizer: organizer ?? this.organizer, - self: self ?? this.self, - rawJson: rawJson.present ? rawJson.value : this.rawJson, + IcalImportReceipt copyWith({ + String? calendarSourceId, + String? icalUid, + Value eventId = const Value.absent(), + String? importedAtUtc, + }) => IcalImportReceipt( + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + icalUid: icalUid ?? this.icalUid, + eventId: eventId.present ? eventId.value : this.eventId, + importedAtUtc: importedAtUtc ?? this.importedAtUtc, ); - CalendarEventAttendee copyWithCompanion( - CalendarEventAttendeesCompanion data, - ) { - return CalendarEventAttendee( - id: data.id.present ? data.id.value : this.id, - calendarEventId: data.calendarEventId.present - ? data.calendarEventId.value - : this.calendarEventId, - email: data.email.present ? data.email.value : this.email, - displayName: data.displayName.present - ? data.displayName.value - : this.displayName, - responseStatus: data.responseStatus.present - ? data.responseStatus.value - : this.responseStatus, - optional: data.optional.present ? data.optional.value : this.optional, - organizer: data.organizer.present ? data.organizer.value : this.organizer, - self: data.self.present ? data.self.value : this.self, - rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, + IcalImportReceipt copyWithCompanion(IcalImportReceiptsCompanion data) { + return IcalImportReceipt( + calendarSourceId: data.calendarSourceId.present + ? data.calendarSourceId.value + : this.calendarSourceId, + icalUid: data.icalUid.present ? data.icalUid.value : this.icalUid, + eventId: data.eventId.present ? data.eventId.value : this.eventId, + importedAtUtc: data.importedAtUtc.present + ? data.importedAtUtc.value + : this.importedAtUtc, ); } @override String toString() { - return (StringBuffer('CalendarEventAttendee(') - ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('email: $email, ') - ..write('displayName: $displayName, ') - ..write('responseStatus: $responseStatus, ') - ..write('optional: $optional, ') - ..write('organizer: $organizer, ') - ..write('self: $self, ') - ..write('rawJson: $rawJson') + return (StringBuffer('IcalImportReceipt(') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('icalUid: $icalUid, ') + ..write('eventId: $eventId, ') + ..write('importedAtUtc: $importedAtUtc') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( - id, - calendarEventId, - email, - displayName, - responseStatus, - optional, - organizer, - self, - rawJson, - ); + int get hashCode => + Object.hash(calendarSourceId, icalUid, eventId, importedAtUtc); @override bool operator ==(Object other) => identical(this, other) || - (other is CalendarEventAttendee && - other.id == this.id && - other.calendarEventId == this.calendarEventId && - other.email == this.email && - other.displayName == this.displayName && - other.responseStatus == this.responseStatus && - other.optional == this.optional && - other.organizer == this.organizer && - other.self == this.self && - other.rawJson == this.rawJson); + (other is IcalImportReceipt && + other.calendarSourceId == this.calendarSourceId && + other.icalUid == this.icalUid && + other.eventId == this.eventId && + other.importedAtUtc == this.importedAtUtc); } -class CalendarEventAttendeesCompanion - extends UpdateCompanion { - final Value id; - final Value calendarEventId; - final Value email; - final Value displayName; - final Value responseStatus; - final Value optional; - final Value organizer; - final Value self; - final Value rawJson; +class IcalImportReceiptsCompanion extends UpdateCompanion { + final Value calendarSourceId; + final Value icalUid; + final Value eventId; + final Value importedAtUtc; final Value rowid; - const CalendarEventAttendeesCompanion({ - this.id = const Value.absent(), - this.calendarEventId = const Value.absent(), - this.email = const Value.absent(), - this.displayName = const Value.absent(), - this.responseStatus = const Value.absent(), - this.optional = const Value.absent(), - this.organizer = const Value.absent(), - this.self = const Value.absent(), - this.rawJson = const Value.absent(), + const IcalImportReceiptsCompanion({ + this.calendarSourceId = const Value.absent(), + this.icalUid = const Value.absent(), + this.eventId = const Value.absent(), + this.importedAtUtc = const Value.absent(), this.rowid = const Value.absent(), }); - CalendarEventAttendeesCompanion.insert({ - required String id, - required String calendarEventId, - required String email, - this.displayName = const Value.absent(), - this.responseStatus = const Value.absent(), - this.optional = const Value.absent(), - this.organizer = const Value.absent(), - this.self = const Value.absent(), - this.rawJson = const Value.absent(), + IcalImportReceiptsCompanion.insert({ + required String calendarSourceId, + required String icalUid, + this.eventId = const Value.absent(), + required String importedAtUtc, this.rowid = const Value.absent(), - }) : id = Value(id), - calendarEventId = Value(calendarEventId), - email = Value(email); - static Insertable custom({ - Expression? id, - Expression? calendarEventId, - Expression? email, - Expression? displayName, - Expression? responseStatus, - Expression? optional, - Expression? organizer, - Expression? self, - Expression? rawJson, + }) : calendarSourceId = Value(calendarSourceId), + icalUid = Value(icalUid), + importedAtUtc = Value(importedAtUtc); + static Insertable custom({ + Expression? calendarSourceId, + Expression? icalUid, + Expression? eventId, + Expression? importedAtUtc, Expression? rowid, }) { return RawValuesInsertable({ - if (id != null) 'id': id, - if (calendarEventId != null) 'calendar_event_id': calendarEventId, - if (email != null) 'email': email, - if (displayName != null) 'display_name': displayName, - if (responseStatus != null) 'response_status': responseStatus, - if (optional != null) 'optional': optional, - if (organizer != null) 'organizer': organizer, - if (self != null) 'self': self, - if (rawJson != null) 'raw_json': rawJson, + if (calendarSourceId != null) 'calendar_source_id': calendarSourceId, + if (icalUid != null) 'ical_uid': icalUid, + if (eventId != null) 'event_id': eventId, + if (importedAtUtc != null) 'imported_at_utc': importedAtUtc, if (rowid != null) 'rowid': rowid, }); } - CalendarEventAttendeesCompanion copyWith({ - Value? id, - Value? calendarEventId, - Value? email, - Value? displayName, - Value? responseStatus, - Value? optional, - Value? organizer, - Value? self, - Value? rawJson, + IcalImportReceiptsCompanion copyWith({ + Value? calendarSourceId, + Value? icalUid, + Value? eventId, + Value? importedAtUtc, Value? rowid, }) { - return CalendarEventAttendeesCompanion( - id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - email: email ?? this.email, - displayName: displayName ?? this.displayName, - responseStatus: responseStatus ?? this.responseStatus, - optional: optional ?? this.optional, - organizer: organizer ?? this.organizer, - self: self ?? this.self, - rawJson: rawJson ?? this.rawJson, + return IcalImportReceiptsCompanion( + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + icalUid: icalUid ?? this.icalUid, + eventId: eventId ?? this.eventId, + importedAtUtc: importedAtUtc ?? this.importedAtUtc, rowid: rowid ?? this.rowid, ); } @@ -19424,32 +19194,17 @@ class CalendarEventAttendeesCompanion @override Map toColumns(bool nullToAbsent) { final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (calendarEventId.present) { - map['calendar_event_id'] = Variable(calendarEventId.value); - } - if (email.present) { - map['email'] = Variable(email.value); - } - if (displayName.present) { - map['display_name'] = Variable(displayName.value); - } - if (responseStatus.present) { - map['response_status'] = Variable(responseStatus.value); - } - if (optional.present) { - map['optional'] = Variable(optional.value); + if (calendarSourceId.present) { + map['calendar_source_id'] = Variable(calendarSourceId.value); } - if (organizer.present) { - map['organizer'] = Variable(organizer.value); + if (icalUid.present) { + map['ical_uid'] = Variable(icalUid.value); } - if (self.present) { - map['self'] = Variable(self.value); + if (eventId.present) { + map['event_id'] = Variable(eventId.value); } - if (rawJson.present) { - map['raw_json'] = Variable(rawJson.value); + if (importedAtUtc.present) { + map['imported_at_utc'] = Variable(importedAtUtc.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -19459,28 +19214,23 @@ class CalendarEventAttendeesCompanion @override String toString() { - return (StringBuffer('CalendarEventAttendeesCompanion(') - ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('email: $email, ') - ..write('displayName: $displayName, ') - ..write('responseStatus: $responseStatus, ') - ..write('optional: $optional, ') - ..write('organizer: $organizer, ') - ..write('self: $self, ') - ..write('rawJson: $rawJson, ') + return (StringBuffer('IcalImportReceiptsCompanion(') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('icalUid: $icalUid, ') + ..write('eventId: $eventId, ') + ..write('importedAtUtc: $importedAtUtc, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $CalendarEventRemindersTable extends CalendarEventReminders - with TableInfo<$CalendarEventRemindersTable, CalendarEventReminder> { +class $WebCalSubscriptionsTable extends WebCalSubscriptions + with TableInfo<$WebCalSubscriptionsTable, WebCalSubscription> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $CalendarEventRemindersTable(this.attachedDatabase, [this._alias]); + $WebCalSubscriptionsTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( @@ -19490,107 +19240,326 @@ class $CalendarEventRemindersTable extends CalendarEventReminders type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( - 'calendarEventId', + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', ); @override - late final GeneratedColumn calendarEventId = GeneratedColumn( - 'calendar_event_id', + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES calendar_events (id) ON DELETE CASCADE', + 'REFERENCES accounts (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _providerMeta = const VerificationMeta( - 'provider', + static const VerificationMeta _calendarSourceIdMeta = const VerificationMeta( + 'calendarSourceId', ); @override - late final GeneratedColumn provider = GeneratedColumn( - 'provider', + late final GeneratedColumn calendarSourceId = GeneratedColumn( + 'calendar_source_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES calendar_sources (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _feedFingerprintMeta = const VerificationMeta( + 'feedFingerprint', ); - static const VerificationMeta _methodMeta = const VerificationMeta('method'); @override - late final GeneratedColumn method = GeneratedColumn( - 'method', + late final GeneratedColumn feedFingerprint = GeneratedColumn( + 'feed_fingerprint', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + static const VerificationMeta _safeOriginMeta = const VerificationMeta( + 'safeOrigin', + ); + @override + late final GeneratedColumn safeOrigin = GeneratedColumn( + 'safe_origin', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _validatorTargetFingerprintMeta = + const VerificationMeta('validatorTargetFingerprint'); + @override + late final GeneratedColumn validatorTargetFingerprint = + GeneratedColumn( + 'validator_target_fingerprint', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _etagMeta = const VerificationMeta('etag'); + @override + late final GeneratedColumn etag = GeneratedColumn( + 'etag', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _minutesBeforeMeta = const VerificationMeta( - 'minutesBefore', + static const VerificationMeta _lastModifiedMeta = const VerificationMeta( + 'lastModified', ); @override - late final GeneratedColumn minutesBefore = GeneratedColumn( - 'minutes_before', + late final GeneratedColumn lastModified = GeneratedColumn( + 'last_modified', aliasedName, true, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _absoluteTimeMeta = const VerificationMeta( - 'absoluteTime', + static const VerificationMeta _contentTypeMeta = const VerificationMeta( + 'contentType', ); @override - late final GeneratedColumn absoluteTime = GeneratedColumn( - 'absolute_time', + late final GeneratedColumn contentType = GeneratedColumn( + 'content_type', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _enabledMeta = const VerificationMeta( - 'enabled', + static const VerificationMeta _snapshotIcsBodyMeta = const VerificationMeta( + 'snapshotIcsBody', ); @override - late final GeneratedColumn enabled = GeneratedColumn( - 'enabled', + late final GeneratedColumn snapshotIcsBody = GeneratedColumn( + 'snapshot_ics_body', aliasedName, false, - type: DriftSqlType.bool, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _rawBodyHashMeta = const VerificationMeta( + 'rawBodyHash', + ); + @override + late final GeneratedColumn rawBodyHash = GeneratedColumn( + 'raw_body_hash', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _semanticHashMeta = const VerificationMeta( + 'semanticHash', + ); + @override + late final GeneratedColumn semanticHash = GeneratedColumn( + 'semantic_hash', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _refreshModeMeta = const VerificationMeta( + 'refreshMode', + ); + @override + late final GeneratedColumn refreshMode = GeneratedColumn( + 'refresh_mode', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _serverRefreshIntervalSecondsMeta = + const VerificationMeta('serverRefreshIntervalSeconds'); + @override + late final GeneratedColumn serverRefreshIntervalSeconds = + GeneratedColumn( + 'server_refresh_interval_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _nextRefreshAtUtcMeta = const VerificationMeta( + 'nextRefreshAtUtc', + ); + @override + late final GeneratedColumn nextRefreshAtUtc = GeneratedColumn( + 'next_refresh_at_utc', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastCheckedAtUtcMeta = const VerificationMeta( + 'lastCheckedAtUtc', + ); + @override + late final GeneratedColumn lastCheckedAtUtc = GeneratedColumn( + 'last_checked_at_utc', + aliasedName, + true, + type: DriftSqlType.string, requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("enabled" IN (0, 1))', - ), - defaultValue: const Constant(true), ); - static const VerificationMeta _rawJsonMeta = const VerificationMeta( - 'rawJson', + static const VerificationMeta _lastSuccessfulSyncAtUtcMeta = + const VerificationMeta('lastSuccessfulSyncAtUtc'); + @override + late final GeneratedColumn lastSuccessfulSyncAtUtc = + GeneratedColumn( + 'last_successful_sync_at_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastChangedAtUtcMeta = const VerificationMeta( + 'lastChangedAtUtc', ); @override - late final GeneratedColumn rawJson = GeneratedColumn( - 'raw_json', + late final GeneratedColumn lastChangedAtUtc = GeneratedColumn( + 'last_changed_at_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _consecutiveFailureCountMeta = + const VerificationMeta('consecutiveFailureCount'); + @override + late final GeneratedColumn consecutiveFailureCount = + GeneratedColumn( + 'consecutive_failure_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _lastFailureCodeMeta = const VerificationMeta( + 'lastFailureCode', + ); + @override + late final GeneratedColumn lastFailureCode = GeneratedColumn( + 'last_failure_code', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _lastFailureHttpStatusMeta = + const VerificationMeta('lastFailureHttpStatus'); + @override + late final GeneratedColumn lastFailureHttpStatus = GeneratedColumn( + 'last_failure_http_status', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _generationMeta = const VerificationMeta( + 'generation', + ); + @override + late final GeneratedColumn generation = GeneratedColumn( + 'generation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _parserVersionMeta = const VerificationMeta( + 'parserVersion', + ); + @override + late final GeneratedColumn parserVersion = GeneratedColumn( + 'parser_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _projectionVersionMeta = const VerificationMeta( + 'projectionVersion', + ); + @override + late final GeneratedColumn projectionVersion = GeneratedColumn( + 'projection_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _createdAtUtcMeta = const VerificationMeta( + 'createdAtUtc', + ); + @override + late final GeneratedColumn createdAtUtc = GeneratedColumn( + 'created_at_utc', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtUtcMeta = const VerificationMeta( + 'updatedAtUtc', + ); + @override + late final GeneratedColumn updatedAtUtc = GeneratedColumn( + 'updated_at_utc', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); @override List get $columns => [ id, - calendarEventId, - provider, - method, - minutesBefore, - absoluteTime, - enabled, - rawJson, + accountId, + calendarSourceId, + feedFingerprint, + safeOrigin, + validatorTargetFingerprint, + etag, + lastModified, + contentType, + snapshotIcsBody, + rawBodyHash, + semanticHash, + refreshMode, + serverRefreshIntervalSeconds, + nextRefreshAtUtc, + lastCheckedAtUtc, + lastSuccessfulSyncAtUtc, + lastChangedAtUtc, + consecutiveFailureCount, + lastFailureCode, + lastFailureHttpStatus, + generation, + parserVersion, + projectionVersion, + createdAtUtc, + updatedAtUtc, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'calendar_event_reminders'; + static const String $name = 'web_cal_subscriptions'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -19600,60 +19569,240 @@ class $CalendarEventRemindersTable extends CalendarEventReminders } else if (isInserting) { context.missing(_idMeta); } - if (data.containsKey('calendar_event_id')) { + if (data.containsKey('account_id')) { context.handle( - _calendarEventIdMeta, - calendarEventId.isAcceptableOrUnknown( - data['calendar_event_id']!, - _calendarEventIdMeta, + _accountIdMeta, + accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + ); + } else if (isInserting) { + context.missing(_accountIdMeta); + } + if (data.containsKey('calendar_source_id')) { + context.handle( + _calendarSourceIdMeta, + calendarSourceId.isAcceptableOrUnknown( + data['calendar_source_id']!, + _calendarSourceIdMeta, ), ); } else if (isInserting) { - context.missing(_calendarEventIdMeta); + context.missing(_calendarSourceIdMeta); } - if (data.containsKey('provider')) { + if (data.containsKey('feed_fingerprint')) { context.handle( - _providerMeta, - provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), + _feedFingerprintMeta, + feedFingerprint.isAcceptableOrUnknown( + data['feed_fingerprint']!, + _feedFingerprintMeta, + ), ); } else if (isInserting) { - context.missing(_providerMeta); + context.missing(_feedFingerprintMeta); } - if (data.containsKey('method')) { + if (data.containsKey('safe_origin')) { context.handle( - _methodMeta, - method.isAcceptableOrUnknown(data['method']!, _methodMeta), + _safeOriginMeta, + safeOrigin.isAcceptableOrUnknown(data['safe_origin']!, _safeOriginMeta), ); + } else if (isInserting) { + context.missing(_safeOriginMeta); } - if (data.containsKey('minutes_before')) { + if (data.containsKey('validator_target_fingerprint')) { context.handle( - _minutesBeforeMeta, - minutesBefore.isAcceptableOrUnknown( - data['minutes_before']!, - _minutesBeforeMeta, + _validatorTargetFingerprintMeta, + validatorTargetFingerprint.isAcceptableOrUnknown( + data['validator_target_fingerprint']!, + _validatorTargetFingerprintMeta, ), ); } - if (data.containsKey('absolute_time')) { + if (data.containsKey('etag')) { context.handle( - _absoluteTimeMeta, - absoluteTime.isAcceptableOrUnknown( - data['absolute_time']!, - _absoluteTimeMeta, + _etagMeta, + etag.isAcceptableOrUnknown(data['etag']!, _etagMeta), + ); + } + if (data.containsKey('last_modified')) { + context.handle( + _lastModifiedMeta, + lastModified.isAcceptableOrUnknown( + data['last_modified']!, + _lastModifiedMeta, ), ); } - if (data.containsKey('enabled')) { + if (data.containsKey('content_type')) { context.handle( - _enabledMeta, - enabled.isAcceptableOrUnknown(data['enabled']!, _enabledMeta), + _contentTypeMeta, + contentType.isAcceptableOrUnknown( + data['content_type']!, + _contentTypeMeta, + ), ); } - if (data.containsKey('raw_json')) { + if (data.containsKey('snapshot_ics_body')) { context.handle( - _rawJsonMeta, - rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), + _snapshotIcsBodyMeta, + snapshotIcsBody.isAcceptableOrUnknown( + data['snapshot_ics_body']!, + _snapshotIcsBodyMeta, + ), + ); + } else if (isInserting) { + context.missing(_snapshotIcsBodyMeta); + } + if (data.containsKey('raw_body_hash')) { + context.handle( + _rawBodyHashMeta, + rawBodyHash.isAcceptableOrUnknown( + data['raw_body_hash']!, + _rawBodyHashMeta, + ), + ); + } else if (isInserting) { + context.missing(_rawBodyHashMeta); + } + if (data.containsKey('semantic_hash')) { + context.handle( + _semanticHashMeta, + semanticHash.isAcceptableOrUnknown( + data['semantic_hash']!, + _semanticHashMeta, + ), + ); + } else if (isInserting) { + context.missing(_semanticHashMeta); + } + if (data.containsKey('refresh_mode')) { + context.handle( + _refreshModeMeta, + refreshMode.isAcceptableOrUnknown( + data['refresh_mode']!, + _refreshModeMeta, + ), + ); + } else if (isInserting) { + context.missing(_refreshModeMeta); + } + if (data.containsKey('server_refresh_interval_seconds')) { + context.handle( + _serverRefreshIntervalSecondsMeta, + serverRefreshIntervalSeconds.isAcceptableOrUnknown( + data['server_refresh_interval_seconds']!, + _serverRefreshIntervalSecondsMeta, + ), + ); + } + if (data.containsKey('next_refresh_at_utc')) { + context.handle( + _nextRefreshAtUtcMeta, + nextRefreshAtUtc.isAcceptableOrUnknown( + data['next_refresh_at_utc']!, + _nextRefreshAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_nextRefreshAtUtcMeta); + } + if (data.containsKey('last_checked_at_utc')) { + context.handle( + _lastCheckedAtUtcMeta, + lastCheckedAtUtc.isAcceptableOrUnknown( + data['last_checked_at_utc']!, + _lastCheckedAtUtcMeta, + ), + ); + } + if (data.containsKey('last_successful_sync_at_utc')) { + context.handle( + _lastSuccessfulSyncAtUtcMeta, + lastSuccessfulSyncAtUtc.isAcceptableOrUnknown( + data['last_successful_sync_at_utc']!, + _lastSuccessfulSyncAtUtcMeta, + ), + ); + } + if (data.containsKey('last_changed_at_utc')) { + context.handle( + _lastChangedAtUtcMeta, + lastChangedAtUtc.isAcceptableOrUnknown( + data['last_changed_at_utc']!, + _lastChangedAtUtcMeta, + ), + ); + } + if (data.containsKey('consecutive_failure_count')) { + context.handle( + _consecutiveFailureCountMeta, + consecutiveFailureCount.isAcceptableOrUnknown( + data['consecutive_failure_count']!, + _consecutiveFailureCountMeta, + ), + ); + } + if (data.containsKey('last_failure_code')) { + context.handle( + _lastFailureCodeMeta, + lastFailureCode.isAcceptableOrUnknown( + data['last_failure_code']!, + _lastFailureCodeMeta, + ), + ); + } + if (data.containsKey('last_failure_http_status')) { + context.handle( + _lastFailureHttpStatusMeta, + lastFailureHttpStatus.isAcceptableOrUnknown( + data['last_failure_http_status']!, + _lastFailureHttpStatusMeta, + ), + ); + } + if (data.containsKey('generation')) { + context.handle( + _generationMeta, + generation.isAcceptableOrUnknown(data['generation']!, _generationMeta), + ); + } + if (data.containsKey('parser_version')) { + context.handle( + _parserVersionMeta, + parserVersion.isAcceptableOrUnknown( + data['parser_version']!, + _parserVersionMeta, + ), + ); + } + if (data.containsKey('projection_version')) { + context.handle( + _projectionVersionMeta, + projectionVersion.isAcceptableOrUnknown( + data['projection_version']!, + _projectionVersionMeta, + ), + ); + } + if (data.containsKey('created_at_utc')) { + context.handle( + _createdAtUtcMeta, + createdAtUtc.isAcceptableOrUnknown( + data['created_at_utc']!, + _createdAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_createdAtUtcMeta); + } + if (data.containsKey('updated_at_utc')) { + context.handle( + _updatedAtUtcMeta, + updatedAtUtc.isAcceptableOrUnknown( + data['updated_at_utc']!, + _updatedAtUtcMeta, + ), ); + } else if (isInserting) { + context.missing(_updatedAtUtcMeta); } return context; } @@ -19661,127 +19810,334 @@ class $CalendarEventRemindersTable extends CalendarEventReminders @override Set get $primaryKey => {id}; @override - CalendarEventReminder map(Map data, {String? tablePrefix}) { + List> get uniqueKeys => [ + {accountId}, + {calendarSourceId}, + ]; + @override + WebCalSubscription map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return CalendarEventReminder( + return WebCalSubscription( id: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}id'], )!, - calendarEventId: attachedDatabase.typeMapping.read( + accountId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}calendar_event_id'], + data['${effectivePrefix}account_id'], )!, - provider: attachedDatabase.typeMapping.read( + calendarSourceId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}provider'], + data['${effectivePrefix}calendar_source_id'], )!, - method: attachedDatabase.typeMapping.read( + feedFingerprint: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}method'], + data['${effectivePrefix}feed_fingerprint'], + )!, + safeOrigin: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}safe_origin'], + )!, + validatorTargetFingerprint: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}validator_target_fingerprint'], ), - minutesBefore: attachedDatabase.typeMapping.read( + etag: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}etag'], + ), + lastModified: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_modified'], + ), + contentType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content_type'], + ), + snapshotIcsBody: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}snapshot_ics_body'], + )!, + rawBodyHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}raw_body_hash'], + )!, + semanticHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}semantic_hash'], + )!, + refreshMode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}refresh_mode'], + )!, + serverRefreshIntervalSeconds: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}minutes_before'], + data['${effectivePrefix}server_refresh_interval_seconds'], ), - absoluteTime: attachedDatabase.typeMapping.read( + nextRefreshAtUtc: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}absolute_time'], + data['${effectivePrefix}next_refresh_at_utc'], + )!, + lastCheckedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_checked_at_utc'], ), - enabled: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}enabled'], + lastSuccessfulSyncAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_successful_sync_at_utc'], + ), + lastChangedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_changed_at_utc'], + ), + consecutiveFailureCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}consecutive_failure_count'], )!, - rawJson: attachedDatabase.typeMapping.read( + lastFailureCode: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}raw_json'], + data['${effectivePrefix}last_failure_code'], + ), + lastFailureHttpStatus: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_failure_http_status'], ), + generation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}generation'], + )!, + parserVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}parser_version'], + )!, + projectionVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}projection_version'], + )!, + createdAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}created_at_utc'], + )!, + updatedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}updated_at_utc'], + )!, ); } @override - $CalendarEventRemindersTable createAlias(String alias) { - return $CalendarEventRemindersTable(attachedDatabase, alias); + $WebCalSubscriptionsTable createAlias(String alias) { + return $WebCalSubscriptionsTable(attachedDatabase, alias); } } -class CalendarEventReminder extends DataClass - implements Insertable { +class WebCalSubscription extends DataClass + implements Insertable { final String id; - final String calendarEventId; - final String provider; - final String? method; - final int? minutesBefore; - final String? absoluteTime; - final bool enabled; - final String? rawJson; - const CalendarEventReminder({ + final String accountId; + final String calendarSourceId; + final String feedFingerprint; + final String safeOrigin; + final String? validatorTargetFingerprint; + final String? etag; + final String? lastModified; + final String? contentType; + final String snapshotIcsBody; + final String rawBodyHash; + final String semanticHash; + final String refreshMode; + final int? serverRefreshIntervalSeconds; + final String nextRefreshAtUtc; + final String? lastCheckedAtUtc; + final String? lastSuccessfulSyncAtUtc; + final String? lastChangedAtUtc; + final int consecutiveFailureCount; + final String? lastFailureCode; + final int? lastFailureHttpStatus; + final int generation; + final int parserVersion; + final int projectionVersion; + final String createdAtUtc; + final String updatedAtUtc; + const WebCalSubscription({ required this.id, - required this.calendarEventId, - required this.provider, - this.method, - this.minutesBefore, - this.absoluteTime, - required this.enabled, - this.rawJson, + required this.accountId, + required this.calendarSourceId, + required this.feedFingerprint, + required this.safeOrigin, + this.validatorTargetFingerprint, + this.etag, + this.lastModified, + this.contentType, + required this.snapshotIcsBody, + required this.rawBodyHash, + required this.semanticHash, + required this.refreshMode, + this.serverRefreshIntervalSeconds, + required this.nextRefreshAtUtc, + this.lastCheckedAtUtc, + this.lastSuccessfulSyncAtUtc, + this.lastChangedAtUtc, + required this.consecutiveFailureCount, + this.lastFailureCode, + this.lastFailureHttpStatus, + required this.generation, + required this.parserVersion, + required this.projectionVersion, + required this.createdAtUtc, + required this.updatedAtUtc, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); - map['calendar_event_id'] = Variable(calendarEventId); - map['provider'] = Variable(provider); - if (!nullToAbsent || method != null) { - map['method'] = Variable(method); + map['account_id'] = Variable(accountId); + map['calendar_source_id'] = Variable(calendarSourceId); + map['feed_fingerprint'] = Variable(feedFingerprint); + map['safe_origin'] = Variable(safeOrigin); + if (!nullToAbsent || validatorTargetFingerprint != null) { + map['validator_target_fingerprint'] = Variable( + validatorTargetFingerprint, + ); } - if (!nullToAbsent || minutesBefore != null) { - map['minutes_before'] = Variable(minutesBefore); + if (!nullToAbsent || etag != null) { + map['etag'] = Variable(etag); } - if (!nullToAbsent || absoluteTime != null) { - map['absolute_time'] = Variable(absoluteTime); + if (!nullToAbsent || lastModified != null) { + map['last_modified'] = Variable(lastModified); } - map['enabled'] = Variable(enabled); - if (!nullToAbsent || rawJson != null) { - map['raw_json'] = Variable(rawJson); + if (!nullToAbsent || contentType != null) { + map['content_type'] = Variable(contentType); + } + map['snapshot_ics_body'] = Variable(snapshotIcsBody); + map['raw_body_hash'] = Variable(rawBodyHash); + map['semantic_hash'] = Variable(semanticHash); + map['refresh_mode'] = Variable(refreshMode); + if (!nullToAbsent || serverRefreshIntervalSeconds != null) { + map['server_refresh_interval_seconds'] = Variable( + serverRefreshIntervalSeconds, + ); + } + map['next_refresh_at_utc'] = Variable(nextRefreshAtUtc); + if (!nullToAbsent || lastCheckedAtUtc != null) { + map['last_checked_at_utc'] = Variable(lastCheckedAtUtc); } + if (!nullToAbsent || lastSuccessfulSyncAtUtc != null) { + map['last_successful_sync_at_utc'] = Variable( + lastSuccessfulSyncAtUtc, + ); + } + if (!nullToAbsent || lastChangedAtUtc != null) { + map['last_changed_at_utc'] = Variable(lastChangedAtUtc); + } + map['consecutive_failure_count'] = Variable(consecutiveFailureCount); + if (!nullToAbsent || lastFailureCode != null) { + map['last_failure_code'] = Variable(lastFailureCode); + } + if (!nullToAbsent || lastFailureHttpStatus != null) { + map['last_failure_http_status'] = Variable(lastFailureHttpStatus); + } + map['generation'] = Variable(generation); + map['parser_version'] = Variable(parserVersion); + map['projection_version'] = Variable(projectionVersion); + map['created_at_utc'] = Variable(createdAtUtc); + map['updated_at_utc'] = Variable(updatedAtUtc); return map; } - CalendarEventRemindersCompanion toCompanion(bool nullToAbsent) { - return CalendarEventRemindersCompanion( + WebCalSubscriptionsCompanion toCompanion(bool nullToAbsent) { + return WebCalSubscriptionsCompanion( id: Value(id), - calendarEventId: Value(calendarEventId), - provider: Value(provider), - method: method == null && nullToAbsent + accountId: Value(accountId), + calendarSourceId: Value(calendarSourceId), + feedFingerprint: Value(feedFingerprint), + safeOrigin: Value(safeOrigin), + validatorTargetFingerprint: + validatorTargetFingerprint == null && nullToAbsent ? const Value.absent() - : Value(method), - minutesBefore: minutesBefore == null && nullToAbsent + : Value(validatorTargetFingerprint), + etag: etag == null && nullToAbsent ? const Value.absent() : Value(etag), + lastModified: lastModified == null && nullToAbsent ? const Value.absent() - : Value(minutesBefore), - absoluteTime: absoluteTime == null && nullToAbsent + : Value(lastModified), + contentType: contentType == null && nullToAbsent ? const Value.absent() - : Value(absoluteTime), - enabled: Value(enabled), - rawJson: rawJson == null && nullToAbsent + : Value(contentType), + snapshotIcsBody: Value(snapshotIcsBody), + rawBodyHash: Value(rawBodyHash), + semanticHash: Value(semanticHash), + refreshMode: Value(refreshMode), + serverRefreshIntervalSeconds: + serverRefreshIntervalSeconds == null && nullToAbsent ? const Value.absent() - : Value(rawJson), + : Value(serverRefreshIntervalSeconds), + nextRefreshAtUtc: Value(nextRefreshAtUtc), + lastCheckedAtUtc: lastCheckedAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(lastCheckedAtUtc), + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(lastSuccessfulSyncAtUtc), + lastChangedAtUtc: lastChangedAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(lastChangedAtUtc), + consecutiveFailureCount: Value(consecutiveFailureCount), + lastFailureCode: lastFailureCode == null && nullToAbsent + ? const Value.absent() + : Value(lastFailureCode), + lastFailureHttpStatus: lastFailureHttpStatus == null && nullToAbsent + ? const Value.absent() + : Value(lastFailureHttpStatus), + generation: Value(generation), + parserVersion: Value(parserVersion), + projectionVersion: Value(projectionVersion), + createdAtUtc: Value(createdAtUtc), + updatedAtUtc: Value(updatedAtUtc), ); } - factory CalendarEventReminder.fromJson( + factory WebCalSubscription.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return CalendarEventReminder( + return WebCalSubscription( id: serializer.fromJson(json['id']), - calendarEventId: serializer.fromJson(json['calendarEventId']), - provider: serializer.fromJson(json['provider']), - method: serializer.fromJson(json['method']), - minutesBefore: serializer.fromJson(json['minutesBefore']), - absoluteTime: serializer.fromJson(json['absoluteTime']), - enabled: serializer.fromJson(json['enabled']), - rawJson: serializer.fromJson(json['rawJson']), + accountId: serializer.fromJson(json['accountId']), + calendarSourceId: serializer.fromJson(json['calendarSourceId']), + feedFingerprint: serializer.fromJson(json['feedFingerprint']), + safeOrigin: serializer.fromJson(json['safeOrigin']), + validatorTargetFingerprint: serializer.fromJson( + json['validatorTargetFingerprint'], + ), + etag: serializer.fromJson(json['etag']), + lastModified: serializer.fromJson(json['lastModified']), + contentType: serializer.fromJson(json['contentType']), + snapshotIcsBody: serializer.fromJson(json['snapshotIcsBody']), + rawBodyHash: serializer.fromJson(json['rawBodyHash']), + semanticHash: serializer.fromJson(json['semanticHash']), + refreshMode: serializer.fromJson(json['refreshMode']), + serverRefreshIntervalSeconds: serializer.fromJson( + json['serverRefreshIntervalSeconds'], + ), + nextRefreshAtUtc: serializer.fromJson(json['nextRefreshAtUtc']), + lastCheckedAtUtc: serializer.fromJson(json['lastCheckedAtUtc']), + lastSuccessfulSyncAtUtc: serializer.fromJson( + json['lastSuccessfulSyncAtUtc'], + ), + lastChangedAtUtc: serializer.fromJson(json['lastChangedAtUtc']), + consecutiveFailureCount: serializer.fromJson( + json['consecutiveFailureCount'], + ), + lastFailureCode: serializer.fromJson(json['lastFailureCode']), + lastFailureHttpStatus: serializer.fromJson( + json['lastFailureHttpStatus'], + ), + generation: serializer.fromJson(json['generation']), + parserVersion: serializer.fromJson(json['parserVersion']), + projectionVersion: serializer.fromJson(json['projectionVersion']), + createdAtUtc: serializer.fromJson(json['createdAtUtc']), + updatedAtUtc: serializer.fromJson(json['updatedAtUtc']), ); } @override @@ -19789,177 +20145,509 @@ class CalendarEventReminder extends DataClass serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'calendarEventId': serializer.toJson(calendarEventId), - 'provider': serializer.toJson(provider), - 'method': serializer.toJson(method), - 'minutesBefore': serializer.toJson(minutesBefore), - 'absoluteTime': serializer.toJson(absoluteTime), - 'enabled': serializer.toJson(enabled), - 'rawJson': serializer.toJson(rawJson), + 'accountId': serializer.toJson(accountId), + 'calendarSourceId': serializer.toJson(calendarSourceId), + 'feedFingerprint': serializer.toJson(feedFingerprint), + 'safeOrigin': serializer.toJson(safeOrigin), + 'validatorTargetFingerprint': serializer.toJson( + validatorTargetFingerprint, + ), + 'etag': serializer.toJson(etag), + 'lastModified': serializer.toJson(lastModified), + 'contentType': serializer.toJson(contentType), + 'snapshotIcsBody': serializer.toJson(snapshotIcsBody), + 'rawBodyHash': serializer.toJson(rawBodyHash), + 'semanticHash': serializer.toJson(semanticHash), + 'refreshMode': serializer.toJson(refreshMode), + 'serverRefreshIntervalSeconds': serializer.toJson( + serverRefreshIntervalSeconds, + ), + 'nextRefreshAtUtc': serializer.toJson(nextRefreshAtUtc), + 'lastCheckedAtUtc': serializer.toJson(lastCheckedAtUtc), + 'lastSuccessfulSyncAtUtc': serializer.toJson( + lastSuccessfulSyncAtUtc, + ), + 'lastChangedAtUtc': serializer.toJson(lastChangedAtUtc), + 'consecutiveFailureCount': serializer.toJson( + consecutiveFailureCount, + ), + 'lastFailureCode': serializer.toJson(lastFailureCode), + 'lastFailureHttpStatus': serializer.toJson(lastFailureHttpStatus), + 'generation': serializer.toJson(generation), + 'parserVersion': serializer.toJson(parserVersion), + 'projectionVersion': serializer.toJson(projectionVersion), + 'createdAtUtc': serializer.toJson(createdAtUtc), + 'updatedAtUtc': serializer.toJson(updatedAtUtc), }; } - CalendarEventReminder copyWith({ + WebCalSubscription copyWith({ String? id, - String? calendarEventId, - String? provider, - Value method = const Value.absent(), - Value minutesBefore = const Value.absent(), - Value absoluteTime = const Value.absent(), - bool? enabled, - Value rawJson = const Value.absent(), - }) => CalendarEventReminder( + String? accountId, + String? calendarSourceId, + String? feedFingerprint, + String? safeOrigin, + Value validatorTargetFingerprint = const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + String? snapshotIcsBody, + String? rawBodyHash, + String? semanticHash, + String? refreshMode, + Value serverRefreshIntervalSeconds = const Value.absent(), + String? nextRefreshAtUtc, + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + int? consecutiveFailureCount, + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + int? generation, + int? parserVersion, + int? projectionVersion, + String? createdAtUtc, + String? updatedAtUtc, + }) => WebCalSubscription( id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - provider: provider ?? this.provider, - method: method.present ? method.value : this.method, - minutesBefore: minutesBefore.present - ? minutesBefore.value - : this.minutesBefore, - absoluteTime: absoluteTime.present ? absoluteTime.value : this.absoluteTime, - enabled: enabled ?? this.enabled, - rawJson: rawJson.present ? rawJson.value : this.rawJson, + accountId: accountId ?? this.accountId, + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + feedFingerprint: feedFingerprint ?? this.feedFingerprint, + safeOrigin: safeOrigin ?? this.safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint.present + ? validatorTargetFingerprint.value + : this.validatorTargetFingerprint, + etag: etag.present ? etag.value : this.etag, + lastModified: lastModified.present ? lastModified.value : this.lastModified, + contentType: contentType.present ? contentType.value : this.contentType, + snapshotIcsBody: snapshotIcsBody ?? this.snapshotIcsBody, + rawBodyHash: rawBodyHash ?? this.rawBodyHash, + semanticHash: semanticHash ?? this.semanticHash, + refreshMode: refreshMode ?? this.refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds.present + ? serverRefreshIntervalSeconds.value + : this.serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc ?? this.nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc.present + ? lastCheckedAtUtc.value + : this.lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc.present + ? lastSuccessfulSyncAtUtc.value + : this.lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc.present + ? lastChangedAtUtc.value + : this.lastChangedAtUtc, + consecutiveFailureCount: + consecutiveFailureCount ?? this.consecutiveFailureCount, + lastFailureCode: lastFailureCode.present + ? lastFailureCode.value + : this.lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus.present + ? lastFailureHttpStatus.value + : this.lastFailureHttpStatus, + generation: generation ?? this.generation, + parserVersion: parserVersion ?? this.parserVersion, + projectionVersion: projectionVersion ?? this.projectionVersion, + createdAtUtc: createdAtUtc ?? this.createdAtUtc, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, ); - CalendarEventReminder copyWithCompanion( - CalendarEventRemindersCompanion data, - ) { - return CalendarEventReminder( + WebCalSubscription copyWithCompanion(WebCalSubscriptionsCompanion data) { + return WebCalSubscription( id: data.id.present ? data.id.value : this.id, - calendarEventId: data.calendarEventId.present - ? data.calendarEventId.value - : this.calendarEventId, - provider: data.provider.present ? data.provider.value : this.provider, - method: data.method.present ? data.method.value : this.method, - minutesBefore: data.minutesBefore.present - ? data.minutesBefore.value - : this.minutesBefore, - absoluteTime: data.absoluteTime.present - ? data.absoluteTime.value - : this.absoluteTime, - enabled: data.enabled.present ? data.enabled.value : this.enabled, - rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, - ); - } - - @override - String toString() { - return (StringBuffer('CalendarEventReminder(') - ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('provider: $provider, ') - ..write('method: $method, ') - ..write('minutesBefore: $minutesBefore, ') - ..write('absoluteTime: $absoluteTime, ') - ..write('enabled: $enabled, ') - ..write('rawJson: $rawJson') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash( - id, - calendarEventId, - provider, - method, - minutesBefore, - absoluteTime, - enabled, - rawJson, - ); - @override - bool operator ==(Object other) => + accountId: data.accountId.present ? data.accountId.value : this.accountId, + calendarSourceId: data.calendarSourceId.present + ? data.calendarSourceId.value + : this.calendarSourceId, + feedFingerprint: data.feedFingerprint.present + ? data.feedFingerprint.value + : this.feedFingerprint, + safeOrigin: data.safeOrigin.present + ? data.safeOrigin.value + : this.safeOrigin, + validatorTargetFingerprint: data.validatorTargetFingerprint.present + ? data.validatorTargetFingerprint.value + : this.validatorTargetFingerprint, + etag: data.etag.present ? data.etag.value : this.etag, + lastModified: data.lastModified.present + ? data.lastModified.value + : this.lastModified, + contentType: data.contentType.present + ? data.contentType.value + : this.contentType, + snapshotIcsBody: data.snapshotIcsBody.present + ? data.snapshotIcsBody.value + : this.snapshotIcsBody, + rawBodyHash: data.rawBodyHash.present + ? data.rawBodyHash.value + : this.rawBodyHash, + semanticHash: data.semanticHash.present + ? data.semanticHash.value + : this.semanticHash, + refreshMode: data.refreshMode.present + ? data.refreshMode.value + : this.refreshMode, + serverRefreshIntervalSeconds: data.serverRefreshIntervalSeconds.present + ? data.serverRefreshIntervalSeconds.value + : this.serverRefreshIntervalSeconds, + nextRefreshAtUtc: data.nextRefreshAtUtc.present + ? data.nextRefreshAtUtc.value + : this.nextRefreshAtUtc, + lastCheckedAtUtc: data.lastCheckedAtUtc.present + ? data.lastCheckedAtUtc.value + : this.lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: data.lastSuccessfulSyncAtUtc.present + ? data.lastSuccessfulSyncAtUtc.value + : this.lastSuccessfulSyncAtUtc, + lastChangedAtUtc: data.lastChangedAtUtc.present + ? data.lastChangedAtUtc.value + : this.lastChangedAtUtc, + consecutiveFailureCount: data.consecutiveFailureCount.present + ? data.consecutiveFailureCount.value + : this.consecutiveFailureCount, + lastFailureCode: data.lastFailureCode.present + ? data.lastFailureCode.value + : this.lastFailureCode, + lastFailureHttpStatus: data.lastFailureHttpStatus.present + ? data.lastFailureHttpStatus.value + : this.lastFailureHttpStatus, + generation: data.generation.present + ? data.generation.value + : this.generation, + parserVersion: data.parserVersion.present + ? data.parserVersion.value + : this.parserVersion, + projectionVersion: data.projectionVersion.present + ? data.projectionVersion.value + : this.projectionVersion, + createdAtUtc: data.createdAtUtc.present + ? data.createdAtUtc.value + : this.createdAtUtc, + updatedAtUtc: data.updatedAtUtc.present + ? data.updatedAtUtc.value + : this.updatedAtUtc, + ); + } + + @override + String toString() { + return (StringBuffer('WebCalSubscription(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('feedFingerprint: $feedFingerprint, ') + ..write('safeOrigin: $safeOrigin, ') + ..write('validatorTargetFingerprint: $validatorTargetFingerprint, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('contentType: $contentType, ') + ..write('snapshotIcsBody: $snapshotIcsBody, ') + ..write('rawBodyHash: $rawBodyHash, ') + ..write('semanticHash: $semanticHash, ') + ..write('refreshMode: $refreshMode, ') + ..write( + 'serverRefreshIntervalSeconds: $serverRefreshIntervalSeconds, ', + ) + ..write('nextRefreshAtUtc: $nextRefreshAtUtc, ') + ..write('lastCheckedAtUtc: $lastCheckedAtUtc, ') + ..write('lastSuccessfulSyncAtUtc: $lastSuccessfulSyncAtUtc, ') + ..write('lastChangedAtUtc: $lastChangedAtUtc, ') + ..write('consecutiveFailureCount: $consecutiveFailureCount, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('lastFailureHttpStatus: $lastFailureHttpStatus, ') + ..write('generation: $generation, ') + ..write('parserVersion: $parserVersion, ') + ..write('projectionVersion: $projectionVersion, ') + ..write('createdAtUtc: $createdAtUtc, ') + ..write('updatedAtUtc: $updatedAtUtc') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + id, + accountId, + calendarSourceId, + feedFingerprint, + safeOrigin, + validatorTargetFingerprint, + etag, + lastModified, + contentType, + snapshotIcsBody, + rawBodyHash, + semanticHash, + refreshMode, + serverRefreshIntervalSeconds, + nextRefreshAtUtc, + lastCheckedAtUtc, + lastSuccessfulSyncAtUtc, + lastChangedAtUtc, + consecutiveFailureCount, + lastFailureCode, + lastFailureHttpStatus, + generation, + parserVersion, + projectionVersion, + createdAtUtc, + updatedAtUtc, + ]); + @override + bool operator ==(Object other) => identical(this, other) || - (other is CalendarEventReminder && + (other is WebCalSubscription && other.id == this.id && - other.calendarEventId == this.calendarEventId && - other.provider == this.provider && - other.method == this.method && - other.minutesBefore == this.minutesBefore && - other.absoluteTime == this.absoluteTime && - other.enabled == this.enabled && - other.rawJson == this.rawJson); + other.accountId == this.accountId && + other.calendarSourceId == this.calendarSourceId && + other.feedFingerprint == this.feedFingerprint && + other.safeOrigin == this.safeOrigin && + other.validatorTargetFingerprint == this.validatorTargetFingerprint && + other.etag == this.etag && + other.lastModified == this.lastModified && + other.contentType == this.contentType && + other.snapshotIcsBody == this.snapshotIcsBody && + other.rawBodyHash == this.rawBodyHash && + other.semanticHash == this.semanticHash && + other.refreshMode == this.refreshMode && + other.serverRefreshIntervalSeconds == + this.serverRefreshIntervalSeconds && + other.nextRefreshAtUtc == this.nextRefreshAtUtc && + other.lastCheckedAtUtc == this.lastCheckedAtUtc && + other.lastSuccessfulSyncAtUtc == this.lastSuccessfulSyncAtUtc && + other.lastChangedAtUtc == this.lastChangedAtUtc && + other.consecutiveFailureCount == this.consecutiveFailureCount && + other.lastFailureCode == this.lastFailureCode && + other.lastFailureHttpStatus == this.lastFailureHttpStatus && + other.generation == this.generation && + other.parserVersion == this.parserVersion && + other.projectionVersion == this.projectionVersion && + other.createdAtUtc == this.createdAtUtc && + other.updatedAtUtc == this.updatedAtUtc); } -class CalendarEventRemindersCompanion - extends UpdateCompanion { +class WebCalSubscriptionsCompanion extends UpdateCompanion { final Value id; - final Value calendarEventId; - final Value provider; - final Value method; - final Value minutesBefore; - final Value absoluteTime; - final Value enabled; - final Value rawJson; + final Value accountId; + final Value calendarSourceId; + final Value feedFingerprint; + final Value safeOrigin; + final Value validatorTargetFingerprint; + final Value etag; + final Value lastModified; + final Value contentType; + final Value snapshotIcsBody; + final Value rawBodyHash; + final Value semanticHash; + final Value refreshMode; + final Value serverRefreshIntervalSeconds; + final Value nextRefreshAtUtc; + final Value lastCheckedAtUtc; + final Value lastSuccessfulSyncAtUtc; + final Value lastChangedAtUtc; + final Value consecutiveFailureCount; + final Value lastFailureCode; + final Value lastFailureHttpStatus; + final Value generation; + final Value parserVersion; + final Value projectionVersion; + final Value createdAtUtc; + final Value updatedAtUtc; final Value rowid; - const CalendarEventRemindersCompanion({ + const WebCalSubscriptionsCompanion({ this.id = const Value.absent(), - this.calendarEventId = const Value.absent(), - this.provider = const Value.absent(), - this.method = const Value.absent(), - this.minutesBefore = const Value.absent(), - this.absoluteTime = const Value.absent(), - this.enabled = const Value.absent(), - this.rawJson = const Value.absent(), + this.accountId = const Value.absent(), + this.calendarSourceId = const Value.absent(), + this.feedFingerprint = const Value.absent(), + this.safeOrigin = const Value.absent(), + this.validatorTargetFingerprint = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.contentType = const Value.absent(), + this.snapshotIcsBody = const Value.absent(), + this.rawBodyHash = const Value.absent(), + this.semanticHash = const Value.absent(), + this.refreshMode = const Value.absent(), + this.serverRefreshIntervalSeconds = const Value.absent(), + this.nextRefreshAtUtc = const Value.absent(), + this.lastCheckedAtUtc = const Value.absent(), + this.lastSuccessfulSyncAtUtc = const Value.absent(), + this.lastChangedAtUtc = const Value.absent(), + this.consecutiveFailureCount = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.lastFailureHttpStatus = const Value.absent(), + this.generation = const Value.absent(), + this.parserVersion = const Value.absent(), + this.projectionVersion = const Value.absent(), + this.createdAtUtc = const Value.absent(), + this.updatedAtUtc = const Value.absent(), this.rowid = const Value.absent(), }); - CalendarEventRemindersCompanion.insert({ + WebCalSubscriptionsCompanion.insert({ required String id, - required String calendarEventId, - required String provider, - this.method = const Value.absent(), - this.minutesBefore = const Value.absent(), - this.absoluteTime = const Value.absent(), - this.enabled = const Value.absent(), - this.rawJson = const Value.absent(), + required String accountId, + required String calendarSourceId, + required String feedFingerprint, + required String safeOrigin, + this.validatorTargetFingerprint = const Value.absent(), + this.etag = const Value.absent(), + this.lastModified = const Value.absent(), + this.contentType = const Value.absent(), + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + this.serverRefreshIntervalSeconds = const Value.absent(), + required String nextRefreshAtUtc, + this.lastCheckedAtUtc = const Value.absent(), + this.lastSuccessfulSyncAtUtc = const Value.absent(), + this.lastChangedAtUtc = const Value.absent(), + this.consecutiveFailureCount = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.lastFailureHttpStatus = const Value.absent(), + this.generation = const Value.absent(), + this.parserVersion = const Value.absent(), + this.projectionVersion = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, this.rowid = const Value.absent(), }) : id = Value(id), - calendarEventId = Value(calendarEventId), - provider = Value(provider); - static Insertable custom({ + accountId = Value(accountId), + calendarSourceId = Value(calendarSourceId), + feedFingerprint = Value(feedFingerprint), + safeOrigin = Value(safeOrigin), + snapshotIcsBody = Value(snapshotIcsBody), + rawBodyHash = Value(rawBodyHash), + semanticHash = Value(semanticHash), + refreshMode = Value(refreshMode), + nextRefreshAtUtc = Value(nextRefreshAtUtc), + createdAtUtc = Value(createdAtUtc), + updatedAtUtc = Value(updatedAtUtc); + static Insertable custom({ Expression? id, - Expression? calendarEventId, - Expression? provider, - Expression? method, - Expression? minutesBefore, - Expression? absoluteTime, - Expression? enabled, - Expression? rawJson, + Expression? accountId, + Expression? calendarSourceId, + Expression? feedFingerprint, + Expression? safeOrigin, + Expression? validatorTargetFingerprint, + Expression? etag, + Expression? lastModified, + Expression? contentType, + Expression? snapshotIcsBody, + Expression? rawBodyHash, + Expression? semanticHash, + Expression? refreshMode, + Expression? serverRefreshIntervalSeconds, + Expression? nextRefreshAtUtc, + Expression? lastCheckedAtUtc, + Expression? lastSuccessfulSyncAtUtc, + Expression? lastChangedAtUtc, + Expression? consecutiveFailureCount, + Expression? lastFailureCode, + Expression? lastFailureHttpStatus, + Expression? generation, + Expression? parserVersion, + Expression? projectionVersion, + Expression? createdAtUtc, + Expression? updatedAtUtc, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, - if (calendarEventId != null) 'calendar_event_id': calendarEventId, - if (provider != null) 'provider': provider, - if (method != null) 'method': method, - if (minutesBefore != null) 'minutes_before': minutesBefore, - if (absoluteTime != null) 'absolute_time': absoluteTime, - if (enabled != null) 'enabled': enabled, - if (rawJson != null) 'raw_json': rawJson, + if (accountId != null) 'account_id': accountId, + if (calendarSourceId != null) 'calendar_source_id': calendarSourceId, + if (feedFingerprint != null) 'feed_fingerprint': feedFingerprint, + if (safeOrigin != null) 'safe_origin': safeOrigin, + if (validatorTargetFingerprint != null) + 'validator_target_fingerprint': validatorTargetFingerprint, + if (etag != null) 'etag': etag, + if (lastModified != null) 'last_modified': lastModified, + if (contentType != null) 'content_type': contentType, + if (snapshotIcsBody != null) 'snapshot_ics_body': snapshotIcsBody, + if (rawBodyHash != null) 'raw_body_hash': rawBodyHash, + if (semanticHash != null) 'semantic_hash': semanticHash, + if (refreshMode != null) 'refresh_mode': refreshMode, + if (serverRefreshIntervalSeconds != null) + 'server_refresh_interval_seconds': serverRefreshIntervalSeconds, + if (nextRefreshAtUtc != null) 'next_refresh_at_utc': nextRefreshAtUtc, + if (lastCheckedAtUtc != null) 'last_checked_at_utc': lastCheckedAtUtc, + if (lastSuccessfulSyncAtUtc != null) + 'last_successful_sync_at_utc': lastSuccessfulSyncAtUtc, + if (lastChangedAtUtc != null) 'last_changed_at_utc': lastChangedAtUtc, + if (consecutiveFailureCount != null) + 'consecutive_failure_count': consecutiveFailureCount, + if (lastFailureCode != null) 'last_failure_code': lastFailureCode, + if (lastFailureHttpStatus != null) + 'last_failure_http_status': lastFailureHttpStatus, + if (generation != null) 'generation': generation, + if (parserVersion != null) 'parser_version': parserVersion, + if (projectionVersion != null) 'projection_version': projectionVersion, + if (createdAtUtc != null) 'created_at_utc': createdAtUtc, + if (updatedAtUtc != null) 'updated_at_utc': updatedAtUtc, if (rowid != null) 'rowid': rowid, }); } - CalendarEventRemindersCompanion copyWith({ + WebCalSubscriptionsCompanion copyWith({ Value? id, - Value? calendarEventId, - Value? provider, - Value? method, - Value? minutesBefore, - Value? absoluteTime, - Value? enabled, - Value? rawJson, + Value? accountId, + Value? calendarSourceId, + Value? feedFingerprint, + Value? safeOrigin, + Value? validatorTargetFingerprint, + Value? etag, + Value? lastModified, + Value? contentType, + Value? snapshotIcsBody, + Value? rawBodyHash, + Value? semanticHash, + Value? refreshMode, + Value? serverRefreshIntervalSeconds, + Value? nextRefreshAtUtc, + Value? lastCheckedAtUtc, + Value? lastSuccessfulSyncAtUtc, + Value? lastChangedAtUtc, + Value? consecutiveFailureCount, + Value? lastFailureCode, + Value? lastFailureHttpStatus, + Value? generation, + Value? parserVersion, + Value? projectionVersion, + Value? createdAtUtc, + Value? updatedAtUtc, Value? rowid, }) { - return CalendarEventRemindersCompanion( + return WebCalSubscriptionsCompanion( id: id ?? this.id, - calendarEventId: calendarEventId ?? this.calendarEventId, - provider: provider ?? this.provider, - method: method ?? this.method, - minutesBefore: minutesBefore ?? this.minutesBefore, - absoluteTime: absoluteTime ?? this.absoluteTime, - enabled: enabled ?? this.enabled, - rawJson: rawJson ?? this.rawJson, + accountId: accountId ?? this.accountId, + calendarSourceId: calendarSourceId ?? this.calendarSourceId, + feedFingerprint: feedFingerprint ?? this.feedFingerprint, + safeOrigin: safeOrigin ?? this.safeOrigin, + validatorTargetFingerprint: + validatorTargetFingerprint ?? this.validatorTargetFingerprint, + etag: etag ?? this.etag, + lastModified: lastModified ?? this.lastModified, + contentType: contentType ?? this.contentType, + snapshotIcsBody: snapshotIcsBody ?? this.snapshotIcsBody, + rawBodyHash: rawBodyHash ?? this.rawBodyHash, + semanticHash: semanticHash ?? this.semanticHash, + refreshMode: refreshMode ?? this.refreshMode, + serverRefreshIntervalSeconds: + serverRefreshIntervalSeconds ?? this.serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc ?? this.nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc ?? this.lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: + lastSuccessfulSyncAtUtc ?? this.lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc ?? this.lastChangedAtUtc, + consecutiveFailureCount: + consecutiveFailureCount ?? this.consecutiveFailureCount, + lastFailureCode: lastFailureCode ?? this.lastFailureCode, + lastFailureHttpStatus: + lastFailureHttpStatus ?? this.lastFailureHttpStatus, + generation: generation ?? this.generation, + parserVersion: parserVersion ?? this.parserVersion, + projectionVersion: projectionVersion ?? this.projectionVersion, + createdAtUtc: createdAtUtc ?? this.createdAtUtc, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, rowid: rowid ?? this.rowid, ); } @@ -19970,26 +20658,90 @@ class CalendarEventRemindersCompanion if (id.present) { map['id'] = Variable(id.value); } - if (calendarEventId.present) { - map['calendar_event_id'] = Variable(calendarEventId.value); + if (accountId.present) { + map['account_id'] = Variable(accountId.value); } - if (provider.present) { - map['provider'] = Variable(provider.value); + if (calendarSourceId.present) { + map['calendar_source_id'] = Variable(calendarSourceId.value); } - if (method.present) { - map['method'] = Variable(method.value); + if (feedFingerprint.present) { + map['feed_fingerprint'] = Variable(feedFingerprint.value); } - if (minutesBefore.present) { - map['minutes_before'] = Variable(minutesBefore.value); + if (safeOrigin.present) { + map['safe_origin'] = Variable(safeOrigin.value); } - if (absoluteTime.present) { - map['absolute_time'] = Variable(absoluteTime.value); + if (validatorTargetFingerprint.present) { + map['validator_target_fingerprint'] = Variable( + validatorTargetFingerprint.value, + ); } - if (enabled.present) { - map['enabled'] = Variable(enabled.value); + if (etag.present) { + map['etag'] = Variable(etag.value); } - if (rawJson.present) { - map['raw_json'] = Variable(rawJson.value); + if (lastModified.present) { + map['last_modified'] = Variable(lastModified.value); + } + if (contentType.present) { + map['content_type'] = Variable(contentType.value); + } + if (snapshotIcsBody.present) { + map['snapshot_ics_body'] = Variable(snapshotIcsBody.value); + } + if (rawBodyHash.present) { + map['raw_body_hash'] = Variable(rawBodyHash.value); + } + if (semanticHash.present) { + map['semantic_hash'] = Variable(semanticHash.value); + } + if (refreshMode.present) { + map['refresh_mode'] = Variable(refreshMode.value); + } + if (serverRefreshIntervalSeconds.present) { + map['server_refresh_interval_seconds'] = Variable( + serverRefreshIntervalSeconds.value, + ); + } + if (nextRefreshAtUtc.present) { + map['next_refresh_at_utc'] = Variable(nextRefreshAtUtc.value); + } + if (lastCheckedAtUtc.present) { + map['last_checked_at_utc'] = Variable(lastCheckedAtUtc.value); + } + if (lastSuccessfulSyncAtUtc.present) { + map['last_successful_sync_at_utc'] = Variable( + lastSuccessfulSyncAtUtc.value, + ); + } + if (lastChangedAtUtc.present) { + map['last_changed_at_utc'] = Variable(lastChangedAtUtc.value); + } + if (consecutiveFailureCount.present) { + map['consecutive_failure_count'] = Variable( + consecutiveFailureCount.value, + ); + } + if (lastFailureCode.present) { + map['last_failure_code'] = Variable(lastFailureCode.value); + } + if (lastFailureHttpStatus.present) { + map['last_failure_http_status'] = Variable( + lastFailureHttpStatus.value, + ); + } + if (generation.present) { + map['generation'] = Variable(generation.value); + } + if (parserVersion.present) { + map['parser_version'] = Variable(parserVersion.value); + } + if (projectionVersion.present) { + map['projection_version'] = Variable(projectionVersion.value); + } + if (createdAtUtc.present) { + map['created_at_utc'] = Variable(createdAtUtc.value); + } + if (updatedAtUtc.present) { + map['updated_at_utc'] = Variable(updatedAtUtc.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -19999,27 +20751,47 @@ class CalendarEventRemindersCompanion @override String toString() { - return (StringBuffer('CalendarEventRemindersCompanion(') + return (StringBuffer('WebCalSubscriptionsCompanion(') ..write('id: $id, ') - ..write('calendarEventId: $calendarEventId, ') - ..write('provider: $provider, ') - ..write('method: $method, ') - ..write('minutesBefore: $minutesBefore, ') - ..write('absoluteTime: $absoluteTime, ') - ..write('enabled: $enabled, ') - ..write('rawJson: $rawJson, ') + ..write('accountId: $accountId, ') + ..write('calendarSourceId: $calendarSourceId, ') + ..write('feedFingerprint: $feedFingerprint, ') + ..write('safeOrigin: $safeOrigin, ') + ..write('validatorTargetFingerprint: $validatorTargetFingerprint, ') + ..write('etag: $etag, ') + ..write('lastModified: $lastModified, ') + ..write('contentType: $contentType, ') + ..write('snapshotIcsBody: $snapshotIcsBody, ') + ..write('rawBodyHash: $rawBodyHash, ') + ..write('semanticHash: $semanticHash, ') + ..write('refreshMode: $refreshMode, ') + ..write( + 'serverRefreshIntervalSeconds: $serverRefreshIntervalSeconds, ', + ) + ..write('nextRefreshAtUtc: $nextRefreshAtUtc, ') + ..write('lastCheckedAtUtc: $lastCheckedAtUtc, ') + ..write('lastSuccessfulSyncAtUtc: $lastSuccessfulSyncAtUtc, ') + ..write('lastChangedAtUtc: $lastChangedAtUtc, ') + ..write('consecutiveFailureCount: $consecutiveFailureCount, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('lastFailureHttpStatus: $lastFailureHttpStatus, ') + ..write('generation: $generation, ') + ..write('parserVersion: $parserVersion, ') + ..write('projectionVersion: $projectionVersion, ') + ..write('createdAtUtc: $createdAtUtc, ') + ..write('updatedAtUtc: $updatedAtUtc, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $SyncCursorsTable extends SyncCursors - with TableInfo<$SyncCursorsTable, SyncCursor> { +class $CalendarEventAttendeesTable extends CalendarEventAttendees + with TableInfo<$CalendarEventAttendeesTable, CalendarEventAttendee> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $SyncCursorsTable(this.attachedDatabase, [this._alias]); + $CalendarEventAttendeesTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( @@ -20029,229 +20801,125 @@ class $SyncCursorsTable extends SyncCursors type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _accountIdMeta = const VerificationMeta( - 'accountId', + static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( + 'calendarEventId', ); @override - late final GeneratedColumn accountId = GeneratedColumn( - 'account_id', + late final GeneratedColumn calendarEventId = GeneratedColumn( + 'calendar_event_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES accounts (id) ON DELETE CASCADE', + 'REFERENCES calendar_events (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _projectionSourceIdMeta = - const VerificationMeta('projectionSourceId'); - @override - late final GeneratedColumn projectionSourceId = - GeneratedColumn( - 'projection_source_id', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES calendar_sources (id) ON DELETE CASCADE', - ), - ); - static const VerificationMeta _providerMeta = const VerificationMeta( - 'provider', - ); + static const VerificationMeta _emailMeta = const VerificationMeta('email'); @override - late final GeneratedColumn provider = GeneratedColumn( - 'provider', + late final GeneratedColumn email = GeneratedColumn( + 'email', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _transportMeta = const VerificationMeta( - 'transport', + static const VerificationMeta _displayNameMeta = const VerificationMeta( + 'displayName', ); @override - late final GeneratedColumn transport = GeneratedColumn( - 'transport', + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', aliasedName, - false, + true, type: DriftSqlType.string, - requiredDuringInsert: true, + requiredDuringInsert: false, ); - static const VerificationMeta _syncScopeKindMeta = const VerificationMeta( - 'syncScopeKind', + static const VerificationMeta _responseStatusMeta = const VerificationMeta( + 'responseStatus', ); @override - late final GeneratedColumn syncScopeKind = GeneratedColumn( - 'sync_scope_kind', + late final GeneratedColumn responseStatus = GeneratedColumn( + 'response_status', aliasedName, - false, + true, type: DriftSqlType.string, - requiredDuringInsert: true, + requiredDuringInsert: false, ); - static const VerificationMeta _davCollectionIdMeta = const VerificationMeta( - 'davCollectionId', + static const VerificationMeta _optionalMeta = const VerificationMeta( + 'optional', ); @override - late final GeneratedColumn davCollectionId = GeneratedColumn( - 'dav_collection_id', + late final GeneratedColumn optional = GeneratedColumn( + 'optional', aliasedName, - true, - type: DriftSqlType.string, + false, + type: DriftSqlType.bool, requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES dav_collections (id) ON DELETE CASCADE', + 'CHECK ("optional" IN (0, 1))', ), + defaultValue: const Constant(false), ); - static const VerificationMeta _cursorKindMeta = const VerificationMeta( - 'cursorKind', + static const VerificationMeta _organizerMeta = const VerificationMeta( + 'organizer', ); @override - late final GeneratedColumn cursorKind = GeneratedColumn( - 'cursor_kind', + late final GeneratedColumn organizer = GeneratedColumn( + 'organizer', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _cursorValueMeta = const VerificationMeta( - 'cursorValue', + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("organizer" IN (0, 1))', + ), + defaultValue: const Constant(false), ); + static const VerificationMeta _selfMeta = const VerificationMeta('self'); @override - late final GeneratedColumn cursorValue = GeneratedColumn( - 'cursor_value', + late final GeneratedColumn self = GeneratedColumn( + 'self', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _rangeStartMeta = const VerificationMeta( - 'rangeStart', - ); - @override - late final GeneratedColumn rangeStart = GeneratedColumn( - 'range_start', - aliasedName, - true, - type: DriftSqlType.string, + type: DriftSqlType.bool, requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("self" IN (0, 1))', + ), + defaultValue: const Constant(false), ); - static const VerificationMeta _rangeEndMeta = const VerificationMeta( - 'rangeEnd', + static const VerificationMeta _rawJsonMeta = const VerificationMeta( + 'rawJson', ); @override - late final GeneratedColumn rangeEnd = GeneratedColumn( - 'range_end', + late final GeneratedColumn rawJson = GeneratedColumn( + 'raw_json', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _baselineGenerationMeta = - const VerificationMeta('baselineGeneration'); @override - late final GeneratedColumn baselineGeneration = GeneratedColumn( - 'baseline_generation', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), - ); - static const VerificationMeta _inProgressCursorMeta = const VerificationMeta( - 'inProgressCursor', - ); + List get $columns => [ + id, + calendarEventId, + email, + displayName, + responseStatus, + optional, + organizer, + self, + rawJson, + ]; @override - late final GeneratedColumn inProgressCursor = GeneratedColumn( - 'in_progress_cursor', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _inProgressGenerationMeta = - const VerificationMeta('inProgressGeneration'); + String get aliasedName => _alias ?? actualTableName; @override - late final GeneratedColumn inProgressGeneration = GeneratedColumn( - 'in_progress_generation', - aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _lastCompleteSyncAtMeta = - const VerificationMeta('lastCompleteSyncAt'); - @override - late final GeneratedColumn lastCompleteSyncAt = GeneratedColumn( - 'last_complete_sync_at', - aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _lastFailureCodeMeta = const VerificationMeta( - 'lastFailureCode', - ); - @override - late final GeneratedColumn lastFailureCode = GeneratedColumn( - 'last_failure_code', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _stateSchemaVersionMeta = - const VerificationMeta('stateSchemaVersion'); - @override - late final GeneratedColumn stateSchemaVersion = GeneratedColumn( - 'state_schema_version', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(1), - ); - static const VerificationMeta _stateJsonMeta = const VerificationMeta( - 'stateJson', - ); - @override - late final GeneratedColumn stateJson = GeneratedColumn( - 'state_json', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - @override - List get $columns => [ - id, - accountId, - projectionSourceId, - provider, - transport, - syncScopeKind, - davCollectionId, - cursorKind, - cursorValue, - rangeStart, - rangeEnd, - baselineGeneration, - inProgressCursor, - inProgressGeneration, - lastCompleteSyncAt, - lastFailureCode, - stateSchemaVersion, - stateJson, - ]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'sync_cursors'; + String get actualTableName => $name; + static const String $name = 'calendar_event_attendees'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -20261,148 +20929,65 @@ class $SyncCursorsTable extends SyncCursors } else if (isInserting) { context.missing(_idMeta); } - if (data.containsKey('account_id')) { - context.handle( - _accountIdMeta, - accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), - ); - } else if (isInserting) { - context.missing(_accountIdMeta); - } - if (data.containsKey('projection_source_id')) { - context.handle( - _projectionSourceIdMeta, - projectionSourceId.isAcceptableOrUnknown( - data['projection_source_id']!, - _projectionSourceIdMeta, - ), - ); - } - if (data.containsKey('provider')) { - context.handle( - _providerMeta, - provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), - ); - } else if (isInserting) { - context.missing(_providerMeta); - } - if (data.containsKey('transport')) { - context.handle( - _transportMeta, - transport.isAcceptableOrUnknown(data['transport']!, _transportMeta), - ); - } else if (isInserting) { - context.missing(_transportMeta); - } - if (data.containsKey('sync_scope_kind')) { - context.handle( - _syncScopeKindMeta, - syncScopeKind.isAcceptableOrUnknown( - data['sync_scope_kind']!, - _syncScopeKindMeta, - ), - ); - } else if (isInserting) { - context.missing(_syncScopeKindMeta); - } - if (data.containsKey('dav_collection_id')) { + if (data.containsKey('calendar_event_id')) { context.handle( - _davCollectionIdMeta, - davCollectionId.isAcceptableOrUnknown( - data['dav_collection_id']!, - _davCollectionIdMeta, + _calendarEventIdMeta, + calendarEventId.isAcceptableOrUnknown( + data['calendar_event_id']!, + _calendarEventIdMeta, ), ); - } - if (data.containsKey('cursor_kind')) { - context.handle( - _cursorKindMeta, - cursorKind.isAcceptableOrUnknown(data['cursor_kind']!, _cursorKindMeta), - ); } else if (isInserting) { - context.missing(_cursorKindMeta); + context.missing(_calendarEventIdMeta); } - if (data.containsKey('cursor_value')) { + if (data.containsKey('email')) { context.handle( - _cursorValueMeta, - cursorValue.isAcceptableOrUnknown( - data['cursor_value']!, - _cursorValueMeta, - ), + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), ); } else if (isInserting) { - context.missing(_cursorValueMeta); - } - if (data.containsKey('range_start')) { - context.handle( - _rangeStartMeta, - rangeStart.isAcceptableOrUnknown(data['range_start']!, _rangeStartMeta), - ); - } - if (data.containsKey('range_end')) { - context.handle( - _rangeEndMeta, - rangeEnd.isAcceptableOrUnknown(data['range_end']!, _rangeEndMeta), - ); - } - if (data.containsKey('baseline_generation')) { - context.handle( - _baselineGenerationMeta, - baselineGeneration.isAcceptableOrUnknown( - data['baseline_generation']!, - _baselineGenerationMeta, - ), - ); + context.missing(_emailMeta); } - if (data.containsKey('in_progress_cursor')) { + if (data.containsKey('display_name')) { context.handle( - _inProgressCursorMeta, - inProgressCursor.isAcceptableOrUnknown( - data['in_progress_cursor']!, - _inProgressCursorMeta, + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, + _displayNameMeta, ), ); } - if (data.containsKey('in_progress_generation')) { + if (data.containsKey('response_status')) { context.handle( - _inProgressGenerationMeta, - inProgressGeneration.isAcceptableOrUnknown( - data['in_progress_generation']!, - _inProgressGenerationMeta, + _responseStatusMeta, + responseStatus.isAcceptableOrUnknown( + data['response_status']!, + _responseStatusMeta, ), ); } - if (data.containsKey('last_complete_sync_at')) { + if (data.containsKey('optional')) { context.handle( - _lastCompleteSyncAtMeta, - lastCompleteSyncAt.isAcceptableOrUnknown( - data['last_complete_sync_at']!, - _lastCompleteSyncAtMeta, - ), + _optionalMeta, + optional.isAcceptableOrUnknown(data['optional']!, _optionalMeta), ); } - if (data.containsKey('last_failure_code')) { + if (data.containsKey('organizer')) { context.handle( - _lastFailureCodeMeta, - lastFailureCode.isAcceptableOrUnknown( - data['last_failure_code']!, - _lastFailureCodeMeta, - ), + _organizerMeta, + organizer.isAcceptableOrUnknown(data['organizer']!, _organizerMeta), ); } - if (data.containsKey('state_schema_version')) { + if (data.containsKey('self')) { context.handle( - _stateSchemaVersionMeta, - stateSchemaVersion.isAcceptableOrUnknown( - data['state_schema_version']!, - _stateSchemaVersionMeta, - ), + _selfMeta, + self.isAcceptableOrUnknown(data['self']!, _selfMeta), ); } - if (data.containsKey('state_json')) { + if (data.containsKey('raw_json')) { context.handle( - _stateJsonMeta, - stateJson.isAcceptableOrUnknown(data['state_json']!, _stateJsonMeta), + _rawJsonMeta, + rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), ); } return context; @@ -20411,240 +20996,132 @@ class $SyncCursorsTable extends SyncCursors @override Set get $primaryKey => {id}; @override - SyncCursor map(Map data, {String? tablePrefix}) { + CalendarEventAttendee map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return SyncCursor( + return CalendarEventAttendee( id: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}id'], )!, - accountId: attachedDatabase.typeMapping.read( + calendarEventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}account_id'], + data['${effectivePrefix}calendar_event_id'], )!, - projectionSourceId: attachedDatabase.typeMapping.read( + email: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}projection_source_id'], + data['${effectivePrefix}email'], + )!, + displayName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}display_name'], ), - provider: attachedDatabase.typeMapping.read( + responseStatus: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}provider'], + data['${effectivePrefix}response_status'], + ), + optional: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}optional'], )!, - transport: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}transport'], + organizer: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}organizer'], )!, - syncScopeKind: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}sync_scope_kind'], + self: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}self'], )!, - davCollectionId: attachedDatabase.typeMapping.read( + rawJson: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}dav_collection_id'], - ), - cursorKind: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}cursor_kind'], - )!, - cursorValue: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}cursor_value'], - )!, - rangeStart: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}range_start'], - ), - rangeEnd: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}range_end'], - ), - baselineGeneration: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}baseline_generation'], - )!, - inProgressCursor: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}in_progress_cursor'], - ), - inProgressGeneration: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}in_progress_generation'], - ), - lastCompleteSyncAt: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}last_complete_sync_at'], - ), - lastFailureCode: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}last_failure_code'], - ), - stateSchemaVersion: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}state_schema_version'], - )!, - stateJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}state_json'], + data['${effectivePrefix}raw_json'], ), ); } @override - $SyncCursorsTable createAlias(String alias) { - return $SyncCursorsTable(attachedDatabase, alias); + $CalendarEventAttendeesTable createAlias(String alias) { + return $CalendarEventAttendeesTable(attachedDatabase, alias); } } -class SyncCursor extends DataClass implements Insertable { +class CalendarEventAttendee extends DataClass + implements Insertable { final String id; - final String accountId; - final String? projectionSourceId; - final String provider; - final String transport; - final String syncScopeKind; - final String? davCollectionId; - final String cursorKind; - final String cursorValue; - final String? rangeStart; - final String? rangeEnd; - final int baselineGeneration; - final String? inProgressCursor; - final int? inProgressGeneration; - final int? lastCompleteSyncAt; - final String? lastFailureCode; - final int stateSchemaVersion; - final String? stateJson; - const SyncCursor({ + final String calendarEventId; + final String email; + final String? displayName; + final String? responseStatus; + final bool optional; + final bool organizer; + final bool self; + final String? rawJson; + const CalendarEventAttendee({ required this.id, - required this.accountId, - this.projectionSourceId, - required this.provider, - required this.transport, - required this.syncScopeKind, - this.davCollectionId, - required this.cursorKind, - required this.cursorValue, - this.rangeStart, - this.rangeEnd, - required this.baselineGeneration, - this.inProgressCursor, - this.inProgressGeneration, - this.lastCompleteSyncAt, - this.lastFailureCode, - required this.stateSchemaVersion, - this.stateJson, + required this.calendarEventId, + required this.email, + this.displayName, + this.responseStatus, + required this.optional, + required this.organizer, + required this.self, + this.rawJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); - map['account_id'] = Variable(accountId); - if (!nullToAbsent || projectionSourceId != null) { - map['projection_source_id'] = Variable(projectionSourceId); - } - map['provider'] = Variable(provider); - map['transport'] = Variable(transport); - map['sync_scope_kind'] = Variable(syncScopeKind); - if (!nullToAbsent || davCollectionId != null) { - map['dav_collection_id'] = Variable(davCollectionId); - } - map['cursor_kind'] = Variable(cursorKind); - map['cursor_value'] = Variable(cursorValue); - if (!nullToAbsent || rangeStart != null) { - map['range_start'] = Variable(rangeStart); - } - if (!nullToAbsent || rangeEnd != null) { - map['range_end'] = Variable(rangeEnd); - } - map['baseline_generation'] = Variable(baselineGeneration); - if (!nullToAbsent || inProgressCursor != null) { - map['in_progress_cursor'] = Variable(inProgressCursor); - } - if (!nullToAbsent || inProgressGeneration != null) { - map['in_progress_generation'] = Variable(inProgressGeneration); - } - if (!nullToAbsent || lastCompleteSyncAt != null) { - map['last_complete_sync_at'] = Variable(lastCompleteSyncAt); + map['calendar_event_id'] = Variable(calendarEventId); + map['email'] = Variable(email); + if (!nullToAbsent || displayName != null) { + map['display_name'] = Variable(displayName); } - if (!nullToAbsent || lastFailureCode != null) { - map['last_failure_code'] = Variable(lastFailureCode); + if (!nullToAbsent || responseStatus != null) { + map['response_status'] = Variable(responseStatus); } - map['state_schema_version'] = Variable(stateSchemaVersion); - if (!nullToAbsent || stateJson != null) { - map['state_json'] = Variable(stateJson); + map['optional'] = Variable(optional); + map['organizer'] = Variable(organizer); + map['self'] = Variable(self); + if (!nullToAbsent || rawJson != null) { + map['raw_json'] = Variable(rawJson); } return map; } - SyncCursorsCompanion toCompanion(bool nullToAbsent) { - return SyncCursorsCompanion( + CalendarEventAttendeesCompanion toCompanion(bool nullToAbsent) { + return CalendarEventAttendeesCompanion( id: Value(id), - accountId: Value(accountId), - projectionSourceId: projectionSourceId == null && nullToAbsent - ? const Value.absent() - : Value(projectionSourceId), - provider: Value(provider), - transport: Value(transport), - syncScopeKind: Value(syncScopeKind), - davCollectionId: davCollectionId == null && nullToAbsent - ? const Value.absent() - : Value(davCollectionId), - cursorKind: Value(cursorKind), - cursorValue: Value(cursorValue), - rangeStart: rangeStart == null && nullToAbsent - ? const Value.absent() - : Value(rangeStart), - rangeEnd: rangeEnd == null && nullToAbsent - ? const Value.absent() - : Value(rangeEnd), - baselineGeneration: Value(baselineGeneration), - inProgressCursor: inProgressCursor == null && nullToAbsent - ? const Value.absent() - : Value(inProgressCursor), - inProgressGeneration: inProgressGeneration == null && nullToAbsent - ? const Value.absent() - : Value(inProgressGeneration), - lastCompleteSyncAt: lastCompleteSyncAt == null && nullToAbsent + calendarEventId: Value(calendarEventId), + email: Value(email), + displayName: displayName == null && nullToAbsent ? const Value.absent() - : Value(lastCompleteSyncAt), - lastFailureCode: lastFailureCode == null && nullToAbsent + : Value(displayName), + responseStatus: responseStatus == null && nullToAbsent ? const Value.absent() - : Value(lastFailureCode), - stateSchemaVersion: Value(stateSchemaVersion), - stateJson: stateJson == null && nullToAbsent + : Value(responseStatus), + optional: Value(optional), + organizer: Value(organizer), + self: Value(self), + rawJson: rawJson == null && nullToAbsent ? const Value.absent() - : Value(stateJson), + : Value(rawJson), ); } - factory SyncCursor.fromJson( + factory CalendarEventAttendee.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return SyncCursor( + return CalendarEventAttendee( id: serializer.fromJson(json['id']), - accountId: serializer.fromJson(json['accountId']), - projectionSourceId: serializer.fromJson( - json['projectionSourceId'], - ), - provider: serializer.fromJson(json['provider']), - transport: serializer.fromJson(json['transport']), - syncScopeKind: serializer.fromJson(json['syncScopeKind']), - davCollectionId: serializer.fromJson(json['davCollectionId']), - cursorKind: serializer.fromJson(json['cursorKind']), - cursorValue: serializer.fromJson(json['cursorValue']), - rangeStart: serializer.fromJson(json['rangeStart']), - rangeEnd: serializer.fromJson(json['rangeEnd']), - baselineGeneration: serializer.fromJson(json['baselineGeneration']), - inProgressCursor: serializer.fromJson(json['inProgressCursor']), - inProgressGeneration: serializer.fromJson( - json['inProgressGeneration'], - ), - lastCompleteSyncAt: serializer.fromJson(json['lastCompleteSyncAt']), - lastFailureCode: serializer.fromJson(json['lastFailureCode']), - stateSchemaVersion: serializer.fromJson(json['stateSchemaVersion']), - stateJson: serializer.fromJson(json['stateJson']), + calendarEventId: serializer.fromJson(json['calendarEventId']), + email: serializer.fromJson(json['email']), + displayName: serializer.fromJson(json['displayName']), + responseStatus: serializer.fromJson(json['responseStatus']), + optional: serializer.fromJson(json['optional']), + organizer: serializer.fromJson(json['organizer']), + self: serializer.fromJson(json['self']), + rawJson: serializer.fromJson(json['rawJson']), ); } @override @@ -20652,145 +21129,74 @@ class SyncCursor extends DataClass implements Insertable { serializer ??= driftRuntimeOptions.defaultSerializer; return { 'id': serializer.toJson(id), - 'accountId': serializer.toJson(accountId), - 'projectionSourceId': serializer.toJson(projectionSourceId), - 'provider': serializer.toJson(provider), - 'transport': serializer.toJson(transport), - 'syncScopeKind': serializer.toJson(syncScopeKind), - 'davCollectionId': serializer.toJson(davCollectionId), - 'cursorKind': serializer.toJson(cursorKind), - 'cursorValue': serializer.toJson(cursorValue), - 'rangeStart': serializer.toJson(rangeStart), - 'rangeEnd': serializer.toJson(rangeEnd), - 'baselineGeneration': serializer.toJson(baselineGeneration), - 'inProgressCursor': serializer.toJson(inProgressCursor), - 'inProgressGeneration': serializer.toJson(inProgressGeneration), - 'lastCompleteSyncAt': serializer.toJson(lastCompleteSyncAt), - 'lastFailureCode': serializer.toJson(lastFailureCode), - 'stateSchemaVersion': serializer.toJson(stateSchemaVersion), - 'stateJson': serializer.toJson(stateJson), + 'calendarEventId': serializer.toJson(calendarEventId), + 'email': serializer.toJson(email), + 'displayName': serializer.toJson(displayName), + 'responseStatus': serializer.toJson(responseStatus), + 'optional': serializer.toJson(optional), + 'organizer': serializer.toJson(organizer), + 'self': serializer.toJson(self), + 'rawJson': serializer.toJson(rawJson), }; } - SyncCursor copyWith({ + CalendarEventAttendee copyWith({ String? id, - String? accountId, - Value projectionSourceId = const Value.absent(), - String? provider, - String? transport, - String? syncScopeKind, - Value davCollectionId = const Value.absent(), - String? cursorKind, - String? cursorValue, - Value rangeStart = const Value.absent(), - Value rangeEnd = const Value.absent(), - int? baselineGeneration, - Value inProgressCursor = const Value.absent(), - Value inProgressGeneration = const Value.absent(), - Value lastCompleteSyncAt = const Value.absent(), - Value lastFailureCode = const Value.absent(), - int? stateSchemaVersion, - Value stateJson = const Value.absent(), - }) => SyncCursor( + String? calendarEventId, + String? email, + Value displayName = const Value.absent(), + Value responseStatus = const Value.absent(), + bool? optional, + bool? organizer, + bool? self, + Value rawJson = const Value.absent(), + }) => CalendarEventAttendee( id: id ?? this.id, - accountId: accountId ?? this.accountId, - projectionSourceId: projectionSourceId.present - ? projectionSourceId.value - : this.projectionSourceId, - provider: provider ?? this.provider, - transport: transport ?? this.transport, - syncScopeKind: syncScopeKind ?? this.syncScopeKind, - davCollectionId: davCollectionId.present - ? davCollectionId.value - : this.davCollectionId, - cursorKind: cursorKind ?? this.cursorKind, - cursorValue: cursorValue ?? this.cursorValue, - rangeStart: rangeStart.present ? rangeStart.value : this.rangeStart, - rangeEnd: rangeEnd.present ? rangeEnd.value : this.rangeEnd, - baselineGeneration: baselineGeneration ?? this.baselineGeneration, - inProgressCursor: inProgressCursor.present - ? inProgressCursor.value - : this.inProgressCursor, - inProgressGeneration: inProgressGeneration.present - ? inProgressGeneration.value - : this.inProgressGeneration, - lastCompleteSyncAt: lastCompleteSyncAt.present - ? lastCompleteSyncAt.value - : this.lastCompleteSyncAt, - lastFailureCode: lastFailureCode.present - ? lastFailureCode.value - : this.lastFailureCode, - stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, - stateJson: stateJson.present ? stateJson.value : this.stateJson, + calendarEventId: calendarEventId ?? this.calendarEventId, + email: email ?? this.email, + displayName: displayName.present ? displayName.value : this.displayName, + responseStatus: responseStatus.present + ? responseStatus.value + : this.responseStatus, + optional: optional ?? this.optional, + organizer: organizer ?? this.organizer, + self: self ?? this.self, + rawJson: rawJson.present ? rawJson.value : this.rawJson, ); - SyncCursor copyWithCompanion(SyncCursorsCompanion data) { - return SyncCursor( + CalendarEventAttendee copyWithCompanion( + CalendarEventAttendeesCompanion data, + ) { + return CalendarEventAttendee( id: data.id.present ? data.id.value : this.id, - accountId: data.accountId.present ? data.accountId.value : this.accountId, - projectionSourceId: data.projectionSourceId.present - ? data.projectionSourceId.value - : this.projectionSourceId, - provider: data.provider.present ? data.provider.value : this.provider, - transport: data.transport.present ? data.transport.value : this.transport, - syncScopeKind: data.syncScopeKind.present - ? data.syncScopeKind.value - : this.syncScopeKind, - davCollectionId: data.davCollectionId.present - ? data.davCollectionId.value - : this.davCollectionId, - cursorKind: data.cursorKind.present - ? data.cursorKind.value - : this.cursorKind, - cursorValue: data.cursorValue.present - ? data.cursorValue.value - : this.cursorValue, - rangeStart: data.rangeStart.present - ? data.rangeStart.value - : this.rangeStart, - rangeEnd: data.rangeEnd.present ? data.rangeEnd.value : this.rangeEnd, - baselineGeneration: data.baselineGeneration.present - ? data.baselineGeneration.value - : this.baselineGeneration, - inProgressCursor: data.inProgressCursor.present - ? data.inProgressCursor.value - : this.inProgressCursor, - inProgressGeneration: data.inProgressGeneration.present - ? data.inProgressGeneration.value - : this.inProgressGeneration, - lastCompleteSyncAt: data.lastCompleteSyncAt.present - ? data.lastCompleteSyncAt.value - : this.lastCompleteSyncAt, - lastFailureCode: data.lastFailureCode.present - ? data.lastFailureCode.value - : this.lastFailureCode, - stateSchemaVersion: data.stateSchemaVersion.present - ? data.stateSchemaVersion.value - : this.stateSchemaVersion, - stateJson: data.stateJson.present ? data.stateJson.value : this.stateJson, + calendarEventId: data.calendarEventId.present + ? data.calendarEventId.value + : this.calendarEventId, + email: data.email.present ? data.email.value : this.email, + displayName: data.displayName.present + ? data.displayName.value + : this.displayName, + responseStatus: data.responseStatus.present + ? data.responseStatus.value + : this.responseStatus, + optional: data.optional.present ? data.optional.value : this.optional, + organizer: data.organizer.present ? data.organizer.value : this.organizer, + self: data.self.present ? data.self.value : this.self, + rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, ); } @override String toString() { - return (StringBuffer('SyncCursor(') + return (StringBuffer('CalendarEventAttendee(') ..write('id: $id, ') - ..write('accountId: $accountId, ') - ..write('projectionSourceId: $projectionSourceId, ') - ..write('provider: $provider, ') - ..write('transport: $transport, ') - ..write('syncScopeKind: $syncScopeKind, ') - ..write('davCollectionId: $davCollectionId, ') - ..write('cursorKind: $cursorKind, ') - ..write('cursorValue: $cursorValue, ') - ..write('rangeStart: $rangeStart, ') - ..write('rangeEnd: $rangeEnd, ') - ..write('baselineGeneration: $baselineGeneration, ') - ..write('inProgressCursor: $inProgressCursor, ') - ..write('inProgressGeneration: $inProgressGeneration, ') - ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') - ..write('lastFailureCode: $lastFailureCode, ') - ..write('stateSchemaVersion: $stateSchemaVersion, ') - ..write('stateJson: $stateJson') + ..write('calendarEventId: $calendarEventId, ') + ..write('email: $email, ') + ..write('displayName: $displayName, ') + ..write('responseStatus: $responseStatus, ') + ..write('optional: $optional, ') + ..write('organizer: $organizer, ') + ..write('self: $self, ') + ..write('rawJson: $rawJson') ..write(')')) .toString(); } @@ -20798,204 +21204,116 @@ class SyncCursor extends DataClass implements Insertable { @override int get hashCode => Object.hash( id, - accountId, - projectionSourceId, - provider, - transport, - syncScopeKind, - davCollectionId, - cursorKind, - cursorValue, - rangeStart, - rangeEnd, - baselineGeneration, - inProgressCursor, - inProgressGeneration, - lastCompleteSyncAt, - lastFailureCode, - stateSchemaVersion, - stateJson, + calendarEventId, + email, + displayName, + responseStatus, + optional, + organizer, + self, + rawJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is SyncCursor && + (other is CalendarEventAttendee && other.id == this.id && - other.accountId == this.accountId && - other.projectionSourceId == this.projectionSourceId && - other.provider == this.provider && - other.transport == this.transport && - other.syncScopeKind == this.syncScopeKind && - other.davCollectionId == this.davCollectionId && - other.cursorKind == this.cursorKind && - other.cursorValue == this.cursorValue && - other.rangeStart == this.rangeStart && - other.rangeEnd == this.rangeEnd && - other.baselineGeneration == this.baselineGeneration && - other.inProgressCursor == this.inProgressCursor && - other.inProgressGeneration == this.inProgressGeneration && - other.lastCompleteSyncAt == this.lastCompleteSyncAt && - other.lastFailureCode == this.lastFailureCode && - other.stateSchemaVersion == this.stateSchemaVersion && - other.stateJson == this.stateJson); + other.calendarEventId == this.calendarEventId && + other.email == this.email && + other.displayName == this.displayName && + other.responseStatus == this.responseStatus && + other.optional == this.optional && + other.organizer == this.organizer && + other.self == this.self && + other.rawJson == this.rawJson); } -class SyncCursorsCompanion extends UpdateCompanion { +class CalendarEventAttendeesCompanion + extends UpdateCompanion { final Value id; - final Value accountId; - final Value projectionSourceId; - final Value provider; - final Value transport; - final Value syncScopeKind; - final Value davCollectionId; - final Value cursorKind; - final Value cursorValue; - final Value rangeStart; - final Value rangeEnd; - final Value baselineGeneration; - final Value inProgressCursor; - final Value inProgressGeneration; - final Value lastCompleteSyncAt; - final Value lastFailureCode; - final Value stateSchemaVersion; - final Value stateJson; + final Value calendarEventId; + final Value email; + final Value displayName; + final Value responseStatus; + final Value optional; + final Value organizer; + final Value self; + final Value rawJson; final Value rowid; - const SyncCursorsCompanion({ + const CalendarEventAttendeesCompanion({ this.id = const Value.absent(), - this.accountId = const Value.absent(), - this.projectionSourceId = const Value.absent(), - this.provider = const Value.absent(), - this.transport = const Value.absent(), - this.syncScopeKind = const Value.absent(), - this.davCollectionId = const Value.absent(), - this.cursorKind = const Value.absent(), - this.cursorValue = const Value.absent(), - this.rangeStart = const Value.absent(), - this.rangeEnd = const Value.absent(), - this.baselineGeneration = const Value.absent(), - this.inProgressCursor = const Value.absent(), - this.inProgressGeneration = const Value.absent(), - this.lastCompleteSyncAt = const Value.absent(), - this.lastFailureCode = const Value.absent(), - this.stateSchemaVersion = const Value.absent(), - this.stateJson = const Value.absent(), - this.rowid = const Value.absent(), - }); - SyncCursorsCompanion.insert({ - required String id, - required String accountId, - this.projectionSourceId = const Value.absent(), - required String provider, - required String transport, - required String syncScopeKind, - this.davCollectionId = const Value.absent(), - required String cursorKind, - required String cursorValue, - this.rangeStart = const Value.absent(), - this.rangeEnd = const Value.absent(), - this.baselineGeneration = const Value.absent(), - this.inProgressCursor = const Value.absent(), - this.inProgressGeneration = const Value.absent(), - this.lastCompleteSyncAt = const Value.absent(), - this.lastFailureCode = const Value.absent(), - this.stateSchemaVersion = const Value.absent(), - this.stateJson = const Value.absent(), + this.calendarEventId = const Value.absent(), + this.email = const Value.absent(), + this.displayName = const Value.absent(), + this.responseStatus = const Value.absent(), + this.optional = const Value.absent(), + this.organizer = const Value.absent(), + this.self = const Value.absent(), + this.rawJson = const Value.absent(), + this.rowid = const Value.absent(), + }); + CalendarEventAttendeesCompanion.insert({ + required String id, + required String calendarEventId, + required String email, + this.displayName = const Value.absent(), + this.responseStatus = const Value.absent(), + this.optional = const Value.absent(), + this.organizer = const Value.absent(), + this.self = const Value.absent(), + this.rawJson = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), - accountId = Value(accountId), - provider = Value(provider), - transport = Value(transport), - syncScopeKind = Value(syncScopeKind), - cursorKind = Value(cursorKind), - cursorValue = Value(cursorValue); - static Insertable custom({ + calendarEventId = Value(calendarEventId), + email = Value(email); + static Insertable custom({ Expression? id, - Expression? accountId, - Expression? projectionSourceId, - Expression? provider, - Expression? transport, - Expression? syncScopeKind, - Expression? davCollectionId, - Expression? cursorKind, - Expression? cursorValue, - Expression? rangeStart, - Expression? rangeEnd, - Expression? baselineGeneration, - Expression? inProgressCursor, - Expression? inProgressGeneration, - Expression? lastCompleteSyncAt, - Expression? lastFailureCode, - Expression? stateSchemaVersion, - Expression? stateJson, + Expression? calendarEventId, + Expression? email, + Expression? displayName, + Expression? responseStatus, + Expression? optional, + Expression? organizer, + Expression? self, + Expression? rawJson, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, - if (accountId != null) 'account_id': accountId, - if (projectionSourceId != null) - 'projection_source_id': projectionSourceId, - if (provider != null) 'provider': provider, - if (transport != null) 'transport': transport, - if (syncScopeKind != null) 'sync_scope_kind': syncScopeKind, - if (davCollectionId != null) 'dav_collection_id': davCollectionId, - if (cursorKind != null) 'cursor_kind': cursorKind, - if (cursorValue != null) 'cursor_value': cursorValue, - if (rangeStart != null) 'range_start': rangeStart, - if (rangeEnd != null) 'range_end': rangeEnd, - if (baselineGeneration != null) 'baseline_generation': baselineGeneration, - if (inProgressCursor != null) 'in_progress_cursor': inProgressCursor, - if (inProgressGeneration != null) - 'in_progress_generation': inProgressGeneration, - if (lastCompleteSyncAt != null) - 'last_complete_sync_at': lastCompleteSyncAt, - if (lastFailureCode != null) 'last_failure_code': lastFailureCode, - if (stateSchemaVersion != null) - 'state_schema_version': stateSchemaVersion, - if (stateJson != null) 'state_json': stateJson, + if (calendarEventId != null) 'calendar_event_id': calendarEventId, + if (email != null) 'email': email, + if (displayName != null) 'display_name': displayName, + if (responseStatus != null) 'response_status': responseStatus, + if (optional != null) 'optional': optional, + if (organizer != null) 'organizer': organizer, + if (self != null) 'self': self, + if (rawJson != null) 'raw_json': rawJson, if (rowid != null) 'rowid': rowid, }); } - SyncCursorsCompanion copyWith({ + CalendarEventAttendeesCompanion copyWith({ Value? id, - Value? accountId, - Value? projectionSourceId, - Value? provider, - Value? transport, - Value? syncScopeKind, - Value? davCollectionId, - Value? cursorKind, - Value? cursorValue, - Value? rangeStart, - Value? rangeEnd, - Value? baselineGeneration, - Value? inProgressCursor, - Value? inProgressGeneration, - Value? lastCompleteSyncAt, - Value? lastFailureCode, - Value? stateSchemaVersion, - Value? stateJson, + Value? calendarEventId, + Value? email, + Value? displayName, + Value? responseStatus, + Value? optional, + Value? organizer, + Value? self, + Value? rawJson, Value? rowid, }) { - return SyncCursorsCompanion( + return CalendarEventAttendeesCompanion( id: id ?? this.id, - accountId: accountId ?? this.accountId, - projectionSourceId: projectionSourceId ?? this.projectionSourceId, - provider: provider ?? this.provider, - transport: transport ?? this.transport, - syncScopeKind: syncScopeKind ?? this.syncScopeKind, - davCollectionId: davCollectionId ?? this.davCollectionId, - cursorKind: cursorKind ?? this.cursorKind, - cursorValue: cursorValue ?? this.cursorValue, - rangeStart: rangeStart ?? this.rangeStart, - rangeEnd: rangeEnd ?? this.rangeEnd, - baselineGeneration: baselineGeneration ?? this.baselineGeneration, - inProgressCursor: inProgressCursor ?? this.inProgressCursor, - inProgressGeneration: inProgressGeneration ?? this.inProgressGeneration, - lastCompleteSyncAt: lastCompleteSyncAt ?? this.lastCompleteSyncAt, - lastFailureCode: lastFailureCode ?? this.lastFailureCode, - stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, - stateJson: stateJson ?? this.stateJson, + calendarEventId: calendarEventId ?? this.calendarEventId, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + responseStatus: responseStatus ?? this.responseStatus, + optional: optional ?? this.optional, + organizer: organizer ?? this.organizer, + self: self ?? this.self, + rawJson: rawJson ?? this.rawJson, rowid: rowid ?? this.rowid, ); } @@ -21006,56 +21324,29 @@ class SyncCursorsCompanion extends UpdateCompanion { if (id.present) { map['id'] = Variable(id.value); } - if (accountId.present) { - map['account_id'] = Variable(accountId.value); - } - if (projectionSourceId.present) { - map['projection_source_id'] = Variable(projectionSourceId.value); - } - if (provider.present) { - map['provider'] = Variable(provider.value); - } - if (transport.present) { - map['transport'] = Variable(transport.value); - } - if (syncScopeKind.present) { - map['sync_scope_kind'] = Variable(syncScopeKind.value); - } - if (davCollectionId.present) { - map['dav_collection_id'] = Variable(davCollectionId.value); - } - if (cursorKind.present) { - map['cursor_kind'] = Variable(cursorKind.value); - } - if (cursorValue.present) { - map['cursor_value'] = Variable(cursorValue.value); - } - if (rangeStart.present) { - map['range_start'] = Variable(rangeStart.value); - } - if (rangeEnd.present) { - map['range_end'] = Variable(rangeEnd.value); + if (calendarEventId.present) { + map['calendar_event_id'] = Variable(calendarEventId.value); } - if (baselineGeneration.present) { - map['baseline_generation'] = Variable(baselineGeneration.value); + if (email.present) { + map['email'] = Variable(email.value); } - if (inProgressCursor.present) { - map['in_progress_cursor'] = Variable(inProgressCursor.value); + if (displayName.present) { + map['display_name'] = Variable(displayName.value); } - if (inProgressGeneration.present) { - map['in_progress_generation'] = Variable(inProgressGeneration.value); + if (responseStatus.present) { + map['response_status'] = Variable(responseStatus.value); } - if (lastCompleteSyncAt.present) { - map['last_complete_sync_at'] = Variable(lastCompleteSyncAt.value); + if (optional.present) { + map['optional'] = Variable(optional.value); } - if (lastFailureCode.present) { - map['last_failure_code'] = Variable(lastFailureCode.value); + if (organizer.present) { + map['organizer'] = Variable(organizer.value); } - if (stateSchemaVersion.present) { - map['state_schema_version'] = Variable(stateSchemaVersion.value); + if (self.present) { + map['self'] = Variable(self.value); } - if (stateJson.present) { - map['state_json'] = Variable(stateJson.value); + if (rawJson.present) { + map['raw_json'] = Variable(rawJson.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -21065,92 +21356,108 @@ class SyncCursorsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('SyncCursorsCompanion(') + return (StringBuffer('CalendarEventAttendeesCompanion(') ..write('id: $id, ') - ..write('accountId: $accountId, ') - ..write('projectionSourceId: $projectionSourceId, ') - ..write('provider: $provider, ') - ..write('transport: $transport, ') - ..write('syncScopeKind: $syncScopeKind, ') - ..write('davCollectionId: $davCollectionId, ') - ..write('cursorKind: $cursorKind, ') - ..write('cursorValue: $cursorValue, ') - ..write('rangeStart: $rangeStart, ') - ..write('rangeEnd: $rangeEnd, ') - ..write('baselineGeneration: $baselineGeneration, ') - ..write('inProgressCursor: $inProgressCursor, ') - ..write('inProgressGeneration: $inProgressGeneration, ') - ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') - ..write('lastFailureCode: $lastFailureCode, ') - ..write('stateSchemaVersion: $stateSchemaVersion, ') - ..write('stateJson: $stateJson, ') + ..write('calendarEventId: $calendarEventId, ') + ..write('email: $email, ') + ..write('displayName: $displayName, ') + ..write('responseStatus: $responseStatus, ') + ..write('optional: $optional, ') + ..write('organizer: $organizer, ') + ..write('self: $self, ') + ..write('rawJson: $rawJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -class $CalendarColorsTable extends CalendarColors - with TableInfo<$CalendarColorsTable, CalendarColor> { +class $CalendarEventRemindersTable extends CalendarEventReminders + with TableInfo<$CalendarEventRemindersTable, CalendarEventReminder> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $CalendarColorsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _providerMeta = const VerificationMeta( - 'provider', - ); + $CalendarEventRemindersTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); @override - late final GeneratedColumn provider = GeneratedColumn( - 'provider', + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _colorTypeMeta = const VerificationMeta( - 'colorType', - ); + static const VerificationMeta _calendarEventIdMeta = const VerificationMeta( + 'calendarEventId', + ); @override - late final GeneratedColumn colorType = GeneratedColumn( - 'color_type', + late final GeneratedColumn calendarEventId = GeneratedColumn( + 'calendar_event_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES calendar_events (id) ON DELETE CASCADE', + ), ); - static const VerificationMeta _colorIdMeta = const VerificationMeta( - 'colorId', + static const VerificationMeta _providerMeta = const VerificationMeta( + 'provider', ); @override - late final GeneratedColumn colorId = GeneratedColumn( - 'color_id', + late final GeneratedColumn provider = GeneratedColumn( + 'provider', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _backgroundMeta = const VerificationMeta( - 'background', - ); + static const VerificationMeta _methodMeta = const VerificationMeta('method'); @override - late final GeneratedColumn background = GeneratedColumn( - 'background', + late final GeneratedColumn method = GeneratedColumn( + 'method', aliasedName, - false, + true, type: DriftSqlType.string, - requiredDuringInsert: true, + requiredDuringInsert: false, ); - static const VerificationMeta _foregroundMeta = const VerificationMeta( - 'foreground', + static const VerificationMeta _minutesBeforeMeta = const VerificationMeta( + 'minutesBefore', ); @override - late final GeneratedColumn foreground = GeneratedColumn( - 'foreground', + late final GeneratedColumn minutesBefore = GeneratedColumn( + 'minutes_before', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _absoluteTimeMeta = const VerificationMeta( + 'absoluteTime', + ); + @override + late final GeneratedColumn absoluteTime = GeneratedColumn( + 'absolute_time', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _enabledMeta = const VerificationMeta( + 'enabled', + ); + @override + late final GeneratedColumn enabled = GeneratedColumn( + 'enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("enabled" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); static const VerificationMeta _rawJsonMeta = const VerificationMeta( 'rawJson', ); @@ -21164,25 +21471,43 @@ class $CalendarColorsTable extends CalendarColors ); @override List get $columns => [ + id, + calendarEventId, provider, - colorType, - colorId, - background, - foreground, + method, + minutesBefore, + absoluteTime, + enabled, rawJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'calendar_colors'; + static const String $name = 'calendar_event_reminders'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('calendar_event_id')) { + context.handle( + _calendarEventIdMeta, + calendarEventId.isAcceptableOrUnknown( + data['calendar_event_id']!, + _calendarEventIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_calendarEventIdMeta); + } if (data.containsKey('provider')) { context.handle( _providerMeta, @@ -21191,34 +21516,34 @@ class $CalendarColorsTable extends CalendarColors } else if (isInserting) { context.missing(_providerMeta); } - if (data.containsKey('color_type')) { + if (data.containsKey('method')) { context.handle( - _colorTypeMeta, - colorType.isAcceptableOrUnknown(data['color_type']!, _colorTypeMeta), + _methodMeta, + method.isAcceptableOrUnknown(data['method']!, _methodMeta), ); - } else if (isInserting) { - context.missing(_colorTypeMeta); } - if (data.containsKey('color_id')) { + if (data.containsKey('minutes_before')) { context.handle( - _colorIdMeta, - colorId.isAcceptableOrUnknown(data['color_id']!, _colorIdMeta), + _minutesBeforeMeta, + minutesBefore.isAcceptableOrUnknown( + data['minutes_before']!, + _minutesBeforeMeta, + ), ); - } else if (isInserting) { - context.missing(_colorIdMeta); } - if (data.containsKey('background')) { + if (data.containsKey('absolute_time')) { context.handle( - _backgroundMeta, - background.isAcceptableOrUnknown(data['background']!, _backgroundMeta), + _absoluteTimeMeta, + absoluteTime.isAcceptableOrUnknown( + data['absolute_time']!, + _absoluteTimeMeta, + ), ); - } else if (isInserting) { - context.missing(_backgroundMeta); } - if (data.containsKey('foreground')) { + if (data.containsKey('enabled')) { context.handle( - _foregroundMeta, - foreground.isAcceptableOrUnknown(data['foreground']!, _foregroundMeta), + _enabledMeta, + enabled.isAcceptableOrUnknown(data['enabled']!, _enabledMeta), ); } if (data.containsKey('raw_json')) { @@ -21231,31 +21556,39 @@ class $CalendarColorsTable extends CalendarColors } @override - Set get $primaryKey => {provider, colorType, colorId}; + Set get $primaryKey => {id}; @override - CalendarColor map(Map data, {String? tablePrefix}) { + CalendarEventReminder map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return CalendarColor( - provider: attachedDatabase.typeMapping.read( + return CalendarEventReminder( + id: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}provider'], + data['${effectivePrefix}id'], )!, - colorType: attachedDatabase.typeMapping.read( + calendarEventId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}color_type'], + data['${effectivePrefix}calendar_event_id'], )!, - colorId: attachedDatabase.typeMapping.read( + provider: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}color_id'], + data['${effectivePrefix}provider'], )!, - background: attachedDatabase.typeMapping.read( + method: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}background'], - )!, - foreground: attachedDatabase.typeMapping.read( + data['${effectivePrefix}method'], + ), + minutesBefore: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}minutes_before'], + ), + absoluteTime: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}foreground'], + data['${effectivePrefix}absolute_time'], ), + enabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}enabled'], + )!, rawJson: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}raw_json'], @@ -21264,68 +21597,87 @@ class $CalendarColorsTable extends CalendarColors } @override - $CalendarColorsTable createAlias(String alias) { - return $CalendarColorsTable(attachedDatabase, alias); + $CalendarEventRemindersTable createAlias(String alias) { + return $CalendarEventRemindersTable(attachedDatabase, alias); } } -class CalendarColor extends DataClass implements Insertable { +class CalendarEventReminder extends DataClass + implements Insertable { + final String id; + final String calendarEventId; final String provider; - final String colorType; - final String colorId; - final String background; - final String? foreground; + final String? method; + final int? minutesBefore; + final String? absoluteTime; + final bool enabled; final String? rawJson; - const CalendarColor({ + const CalendarEventReminder({ + required this.id, + required this.calendarEventId, required this.provider, - required this.colorType, - required this.colorId, - required this.background, - this.foreground, + this.method, + this.minutesBefore, + this.absoluteTime, + required this.enabled, this.rawJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; + map['id'] = Variable(id); + map['calendar_event_id'] = Variable(calendarEventId); map['provider'] = Variable(provider); - map['color_type'] = Variable(colorType); - map['color_id'] = Variable(colorId); - map['background'] = Variable(background); - if (!nullToAbsent || foreground != null) { - map['foreground'] = Variable(foreground); + if (!nullToAbsent || method != null) { + map['method'] = Variable(method); + } + if (!nullToAbsent || minutesBefore != null) { + map['minutes_before'] = Variable(minutesBefore); + } + if (!nullToAbsent || absoluteTime != null) { + map['absolute_time'] = Variable(absoluteTime); } + map['enabled'] = Variable(enabled); if (!nullToAbsent || rawJson != null) { map['raw_json'] = Variable(rawJson); } return map; } - CalendarColorsCompanion toCompanion(bool nullToAbsent) { - return CalendarColorsCompanion( + CalendarEventRemindersCompanion toCompanion(bool nullToAbsent) { + return CalendarEventRemindersCompanion( + id: Value(id), + calendarEventId: Value(calendarEventId), provider: Value(provider), - colorType: Value(colorType), - colorId: Value(colorId), - background: Value(background), - foreground: foreground == null && nullToAbsent + method: method == null && nullToAbsent ? const Value.absent() - : Value(foreground), + : Value(method), + minutesBefore: minutesBefore == null && nullToAbsent + ? const Value.absent() + : Value(minutesBefore), + absoluteTime: absoluteTime == null && nullToAbsent + ? const Value.absent() + : Value(absoluteTime), + enabled: Value(enabled), rawJson: rawJson == null && nullToAbsent ? const Value.absent() : Value(rawJson), ); } - factory CalendarColor.fromJson( + factory CalendarEventReminder.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return CalendarColor( + return CalendarEventReminder( + id: serializer.fromJson(json['id']), + calendarEventId: serializer.fromJson(json['calendarEventId']), provider: serializer.fromJson(json['provider']), - colorType: serializer.fromJson(json['colorType']), - colorId: serializer.fromJson(json['colorId']), - background: serializer.fromJson(json['background']), - foreground: serializer.fromJson(json['foreground']), + method: serializer.fromJson(json['method']), + minutesBefore: serializer.fromJson(json['minutesBefore']), + absoluteTime: serializer.fromJson(json['absoluteTime']), + enabled: serializer.fromJson(json['enabled']), rawJson: serializer.fromJson(json['rawJson']), ); } @@ -21333,53 +21685,69 @@ class CalendarColor extends DataClass implements Insertable { Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { + 'id': serializer.toJson(id), + 'calendarEventId': serializer.toJson(calendarEventId), 'provider': serializer.toJson(provider), - 'colorType': serializer.toJson(colorType), - 'colorId': serializer.toJson(colorId), - 'background': serializer.toJson(background), - 'foreground': serializer.toJson(foreground), + 'method': serializer.toJson(method), + 'minutesBefore': serializer.toJson(minutesBefore), + 'absoluteTime': serializer.toJson(absoluteTime), + 'enabled': serializer.toJson(enabled), 'rawJson': serializer.toJson(rawJson), }; } - CalendarColor copyWith({ + CalendarEventReminder copyWith({ + String? id, + String? calendarEventId, String? provider, - String? colorType, - String? colorId, - String? background, - Value foreground = const Value.absent(), + Value method = const Value.absent(), + Value minutesBefore = const Value.absent(), + Value absoluteTime = const Value.absent(), + bool? enabled, Value rawJson = const Value.absent(), - }) => CalendarColor( + }) => CalendarEventReminder( + id: id ?? this.id, + calendarEventId: calendarEventId ?? this.calendarEventId, provider: provider ?? this.provider, - colorType: colorType ?? this.colorType, - colorId: colorId ?? this.colorId, - background: background ?? this.background, - foreground: foreground.present ? foreground.value : this.foreground, + method: method.present ? method.value : this.method, + minutesBefore: minutesBefore.present + ? minutesBefore.value + : this.minutesBefore, + absoluteTime: absoluteTime.present ? absoluteTime.value : this.absoluteTime, + enabled: enabled ?? this.enabled, rawJson: rawJson.present ? rawJson.value : this.rawJson, ); - CalendarColor copyWithCompanion(CalendarColorsCompanion data) { - return CalendarColor( + CalendarEventReminder copyWithCompanion( + CalendarEventRemindersCompanion data, + ) { + return CalendarEventReminder( + id: data.id.present ? data.id.value : this.id, + calendarEventId: data.calendarEventId.present + ? data.calendarEventId.value + : this.calendarEventId, provider: data.provider.present ? data.provider.value : this.provider, - colorType: data.colorType.present ? data.colorType.value : this.colorType, - colorId: data.colorId.present ? data.colorId.value : this.colorId, - background: data.background.present - ? data.background.value - : this.background, - foreground: data.foreground.present - ? data.foreground.value - : this.foreground, + method: data.method.present ? data.method.value : this.method, + minutesBefore: data.minutesBefore.present + ? data.minutesBefore.value + : this.minutesBefore, + absoluteTime: data.absoluteTime.present + ? data.absoluteTime.value + : this.absoluteTime, + enabled: data.enabled.present ? data.enabled.value : this.enabled, rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, ); } @override String toString() { - return (StringBuffer('CalendarColor(') + return (StringBuffer('CalendarEventReminder(') + ..write('id: $id, ') + ..write('calendarEventId: $calendarEventId, ') ..write('provider: $provider, ') - ..write('colorType: $colorType, ') - ..write('colorId: $colorId, ') - ..write('background: $background, ') - ..write('foreground: $foreground, ') + ..write('method: $method, ') + ..write('minutesBefore: $minutesBefore, ') + ..write('absoluteTime: $absoluteTime, ') + ..write('enabled: $enabled, ') ..write('rawJson: $rawJson') ..write(')')) .toString(); @@ -21387,89 +21755,107 @@ class CalendarColor extends DataClass implements Insertable { @override int get hashCode => Object.hash( + id, + calendarEventId, provider, - colorType, - colorId, - background, - foreground, + method, + minutesBefore, + absoluteTime, + enabled, rawJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is CalendarColor && + (other is CalendarEventReminder && + other.id == this.id && + other.calendarEventId == this.calendarEventId && other.provider == this.provider && - other.colorType == this.colorType && - other.colorId == this.colorId && - other.background == this.background && - other.foreground == this.foreground && + other.method == this.method && + other.minutesBefore == this.minutesBefore && + other.absoluteTime == this.absoluteTime && + other.enabled == this.enabled && other.rawJson == this.rawJson); } -class CalendarColorsCompanion extends UpdateCompanion { +class CalendarEventRemindersCompanion + extends UpdateCompanion { + final Value id; + final Value calendarEventId; final Value provider; - final Value colorType; - final Value colorId; - final Value background; - final Value foreground; + final Value method; + final Value minutesBefore; + final Value absoluteTime; + final Value enabled; final Value rawJson; final Value rowid; - const CalendarColorsCompanion({ + const CalendarEventRemindersCompanion({ + this.id = const Value.absent(), + this.calendarEventId = const Value.absent(), this.provider = const Value.absent(), - this.colorType = const Value.absent(), - this.colorId = const Value.absent(), - this.background = const Value.absent(), - this.foreground = const Value.absent(), + this.method = const Value.absent(), + this.minutesBefore = const Value.absent(), + this.absoluteTime = const Value.absent(), + this.enabled = const Value.absent(), this.rawJson = const Value.absent(), this.rowid = const Value.absent(), }); - CalendarColorsCompanion.insert({ + CalendarEventRemindersCompanion.insert({ + required String id, + required String calendarEventId, required String provider, - required String colorType, - required String colorId, - required String background, - this.foreground = const Value.absent(), + this.method = const Value.absent(), + this.minutesBefore = const Value.absent(), + this.absoluteTime = const Value.absent(), + this.enabled = const Value.absent(), this.rawJson = const Value.absent(), this.rowid = const Value.absent(), - }) : provider = Value(provider), - colorType = Value(colorType), - colorId = Value(colorId), - background = Value(background); - static Insertable custom({ + }) : id = Value(id), + calendarEventId = Value(calendarEventId), + provider = Value(provider); + static Insertable custom({ + Expression? id, + Expression? calendarEventId, Expression? provider, - Expression? colorType, - Expression? colorId, - Expression? background, - Expression? foreground, + Expression? method, + Expression? minutesBefore, + Expression? absoluteTime, + Expression? enabled, Expression? rawJson, Expression? rowid, }) { return RawValuesInsertable({ + if (id != null) 'id': id, + if (calendarEventId != null) 'calendar_event_id': calendarEventId, if (provider != null) 'provider': provider, - if (colorType != null) 'color_type': colorType, - if (colorId != null) 'color_id': colorId, - if (background != null) 'background': background, - if (foreground != null) 'foreground': foreground, + if (method != null) 'method': method, + if (minutesBefore != null) 'minutes_before': minutesBefore, + if (absoluteTime != null) 'absolute_time': absoluteTime, + if (enabled != null) 'enabled': enabled, if (rawJson != null) 'raw_json': rawJson, if (rowid != null) 'rowid': rowid, }); } - CalendarColorsCompanion copyWith({ + CalendarEventRemindersCompanion copyWith({ + Value? id, + Value? calendarEventId, Value? provider, - Value? colorType, - Value? colorId, - Value? background, - Value? foreground, + Value? method, + Value? minutesBefore, + Value? absoluteTime, + Value? enabled, Value? rawJson, Value? rowid, }) { - return CalendarColorsCompanion( + return CalendarEventRemindersCompanion( + id: id ?? this.id, + calendarEventId: calendarEventId ?? this.calendarEventId, provider: provider ?? this.provider, - colorType: colorType ?? this.colorType, - colorId: colorId ?? this.colorId, - background: background ?? this.background, - foreground: foreground ?? this.foreground, + method: method ?? this.method, + minutesBefore: minutesBefore ?? this.minutesBefore, + absoluteTime: absoluteTime ?? this.absoluteTime, + enabled: enabled ?? this.enabled, rawJson: rawJson ?? this.rawJson, rowid: rowid ?? this.rowid, ); @@ -21478,20 +21864,26 @@ class CalendarColorsCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (calendarEventId.present) { + map['calendar_event_id'] = Variable(calendarEventId.value); + } if (provider.present) { map['provider'] = Variable(provider.value); } - if (colorType.present) { - map['color_type'] = Variable(colorType.value); + if (method.present) { + map['method'] = Variable(method.value); } - if (colorId.present) { - map['color_id'] = Variable(colorId.value); + if (minutesBefore.present) { + map['minutes_before'] = Variable(minutesBefore.value); } - if (background.present) { - map['background'] = Variable(background.value); + if (absoluteTime.present) { + map['absolute_time'] = Variable(absoluteTime.value); } - if (foreground.present) { - map['foreground'] = Variable(foreground.value); + if (enabled.present) { + map['enabled'] = Variable(enabled.value); } if (rawJson.present) { map['raw_json'] = Variable(rawJson.value); @@ -21504,12 +21896,14 @@ class CalendarColorsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('CalendarColorsCompanion(') + return (StringBuffer('CalendarEventRemindersCompanion(') + ..write('id: $id, ') + ..write('calendarEventId: $calendarEventId, ') ..write('provider: $provider, ') - ..write('colorType: $colorType, ') - ..write('colorId: $colorId, ') - ..write('background: $background, ') - ..write('foreground: $foreground, ') + ..write('method: $method, ') + ..write('minutesBefore: $minutesBefore, ') + ..write('absoluteTime: $absoluteTime, ') + ..write('enabled: $enabled, ') ..write('rawJson: $rawJson, ') ..write('rowid: $rowid') ..write(')')) @@ -21517,12 +21911,12 @@ class CalendarColorsCompanion extends UpdateCompanion { } } -class $ScheduleItemOverridesTable extends ScheduleItemOverrides - with TableInfo<$ScheduleItemOverridesTable, ScheduleItemOverride> { +class $SyncCursorsTable extends SyncCursors + with TableInfo<$SyncCursorsTable, SyncCursor> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $ScheduleItemOverridesTable(this.attachedDatabase, [this._alias]); + $SyncCursorsTable(this.attachedDatabase, [this._alias]); static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( @@ -21546,79 +21940,215 @@ class $ScheduleItemOverridesTable extends ScheduleItemOverrides 'REFERENCES accounts (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _sourceTypeMeta = const VerificationMeta( - 'sourceType', + static const VerificationMeta _projectionSourceIdMeta = + const VerificationMeta('projectionSourceId'); + @override + late final GeneratedColumn projectionSourceId = + GeneratedColumn( + 'projection_source_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES calendar_sources (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _providerMeta = const VerificationMeta( + 'provider', ); @override - late final GeneratedColumn sourceType = GeneratedColumn( - 'source_type', + late final GeneratedColumn provider = GeneratedColumn( + 'provider', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _sourceIdMeta = const VerificationMeta( - 'sourceId', + static const VerificationMeta _transportMeta = const VerificationMeta( + 'transport', ); @override - late final GeneratedColumn sourceId = GeneratedColumn( - 'source_id', + late final GeneratedColumn transport = GeneratedColumn( + 'transport', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _overrideJsonMeta = const VerificationMeta( - 'overrideJson', + static const VerificationMeta _syncScopeKindMeta = const VerificationMeta( + 'syncScopeKind', ); @override - late final GeneratedColumn overrideJson = GeneratedColumn( - 'override_json', + late final GeneratedColumn syncScopeKind = GeneratedColumn( + 'sync_scope_kind', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( - 'createdAtLocal', - ); + static const VerificationMeta _davCollectionIdMeta = const VerificationMeta( + 'davCollectionId', + ); @override - late final GeneratedColumn createdAtLocal = GeneratedColumn( - 'created_at_local', + late final GeneratedColumn davCollectionId = GeneratedColumn( + 'dav_collection_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES dav_collections (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _cursorKindMeta = const VerificationMeta( + 'cursorKind', + ); + @override + late final GeneratedColumn cursorKind = GeneratedColumn( + 'cursor_kind', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _updatedAtLocalMeta = const VerificationMeta( - 'updatedAtLocal', + static const VerificationMeta _cursorValueMeta = const VerificationMeta( + 'cursorValue', ); @override - late final GeneratedColumn updatedAtLocal = GeneratedColumn( - 'updated_at_local', + late final GeneratedColumn cursorValue = GeneratedColumn( + 'cursor_value', aliasedName, false, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _rangeStartMeta = const VerificationMeta( + 'rangeStart', + ); + @override + late final GeneratedColumn rangeStart = GeneratedColumn( + 'range_start', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _rangeEndMeta = const VerificationMeta( + 'rangeEnd', + ); + @override + late final GeneratedColumn rangeEnd = GeneratedColumn( + 'range_end', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _baselineGenerationMeta = + const VerificationMeta('baselineGeneration'); + @override + late final GeneratedColumn baselineGeneration = GeneratedColumn( + 'baseline_generation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _inProgressCursorMeta = const VerificationMeta( + 'inProgressCursor', + ); + @override + late final GeneratedColumn inProgressCursor = GeneratedColumn( + 'in_progress_cursor', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _inProgressGenerationMeta = + const VerificationMeta('inProgressGeneration'); + @override + late final GeneratedColumn inProgressGeneration = GeneratedColumn( + 'in_progress_generation', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastCompleteSyncAtMeta = + const VerificationMeta('lastCompleteSyncAt'); + @override + late final GeneratedColumn lastCompleteSyncAt = GeneratedColumn( + 'last_complete_sync_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastFailureCodeMeta = const VerificationMeta( + 'lastFailureCode', + ); + @override + late final GeneratedColumn lastFailureCode = GeneratedColumn( + 'last_failure_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _stateSchemaVersionMeta = + const VerificationMeta('stateSchemaVersion'); + @override + late final GeneratedColumn stateSchemaVersion = GeneratedColumn( + 'state_schema_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _stateJsonMeta = const VerificationMeta( + 'stateJson', + ); + @override + late final GeneratedColumn stateJson = GeneratedColumn( + 'state_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, accountId, - sourceType, - sourceId, - overrideJson, - createdAtLocal, - updatedAtLocal, + projectionSourceId, + provider, + transport, + syncScopeKind, + davCollectionId, + cursorKind, + cursorValue, + rangeStart, + rangeEnd, + baselineGeneration, + inProgressCursor, + inProgressGeneration, + lastCompleteSyncAt, + lastFailureCode, + stateSchemaVersion, + stateJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'schedule_item_overrides'; + static const String $name = 'sync_cursors'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); @@ -21636,157 +22166,382 @@ class $ScheduleItemOverridesTable extends ScheduleItemOverrides } else if (isInserting) { context.missing(_accountIdMeta); } - if (data.containsKey('source_type')) { + if (data.containsKey('projection_source_id')) { context.handle( - _sourceTypeMeta, - sourceType.isAcceptableOrUnknown(data['source_type']!, _sourceTypeMeta), + _projectionSourceIdMeta, + projectionSourceId.isAcceptableOrUnknown( + data['projection_source_id']!, + _projectionSourceIdMeta, + ), + ); + } + if (data.containsKey('provider')) { + context.handle( + _providerMeta, + provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), ); } else if (isInserting) { - context.missing(_sourceTypeMeta); + context.missing(_providerMeta); } - if (data.containsKey('source_id')) { + if (data.containsKey('transport')) { context.handle( - _sourceIdMeta, - sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + _transportMeta, + transport.isAcceptableOrUnknown(data['transport']!, _transportMeta), ); } else if (isInserting) { - context.missing(_sourceIdMeta); + context.missing(_transportMeta); } - if (data.containsKey('override_json')) { + if (data.containsKey('sync_scope_kind')) { context.handle( - _overrideJsonMeta, - overrideJson.isAcceptableOrUnknown( - data['override_json']!, - _overrideJsonMeta, + _syncScopeKindMeta, + syncScopeKind.isAcceptableOrUnknown( + data['sync_scope_kind']!, + _syncScopeKindMeta, ), ); } else if (isInserting) { - context.missing(_overrideJsonMeta); + context.missing(_syncScopeKindMeta); } - if (data.containsKey('created_at_local')) { + if (data.containsKey('dav_collection_id')) { context.handle( - _createdAtLocalMeta, - createdAtLocal.isAcceptableOrUnknown( - data['created_at_local']!, - _createdAtLocalMeta, + _davCollectionIdMeta, + davCollectionId.isAcceptableOrUnknown( + data['dav_collection_id']!, + _davCollectionIdMeta, ), ); + } + if (data.containsKey('cursor_kind')) { + context.handle( + _cursorKindMeta, + cursorKind.isAcceptableOrUnknown(data['cursor_kind']!, _cursorKindMeta), + ); } else if (isInserting) { - context.missing(_createdAtLocalMeta); + context.missing(_cursorKindMeta); } - if (data.containsKey('updated_at_local')) { + if (data.containsKey('cursor_value')) { context.handle( - _updatedAtLocalMeta, - updatedAtLocal.isAcceptableOrUnknown( - data['updated_at_local']!, - _updatedAtLocalMeta, + _cursorValueMeta, + cursorValue.isAcceptableOrUnknown( + data['cursor_value']!, + _cursorValueMeta, ), ); } else if (isInserting) { - context.missing(_updatedAtLocalMeta); + context.missing(_cursorValueMeta); } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - ScheduleItemOverride map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ScheduleItemOverride( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - accountId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}account_id'], - )!, - sourceType: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}source_type'], - )!, - sourceId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}source_id'], - )!, - overrideJson: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}override_json'], - )!, - createdAtLocal: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}created_at_local'], - )!, - updatedAtLocal: attachedDatabase.typeMapping.read( + if (data.containsKey('range_start')) { + context.handle( + _rangeStartMeta, + rangeStart.isAcceptableOrUnknown(data['range_start']!, _rangeStartMeta), + ); + } + if (data.containsKey('range_end')) { + context.handle( + _rangeEndMeta, + rangeEnd.isAcceptableOrUnknown(data['range_end']!, _rangeEndMeta), + ); + } + if (data.containsKey('baseline_generation')) { + context.handle( + _baselineGenerationMeta, + baselineGeneration.isAcceptableOrUnknown( + data['baseline_generation']!, + _baselineGenerationMeta, + ), + ); + } + if (data.containsKey('in_progress_cursor')) { + context.handle( + _inProgressCursorMeta, + inProgressCursor.isAcceptableOrUnknown( + data['in_progress_cursor']!, + _inProgressCursorMeta, + ), + ); + } + if (data.containsKey('in_progress_generation')) { + context.handle( + _inProgressGenerationMeta, + inProgressGeneration.isAcceptableOrUnknown( + data['in_progress_generation']!, + _inProgressGenerationMeta, + ), + ); + } + if (data.containsKey('last_complete_sync_at')) { + context.handle( + _lastCompleteSyncAtMeta, + lastCompleteSyncAt.isAcceptableOrUnknown( + data['last_complete_sync_at']!, + _lastCompleteSyncAtMeta, + ), + ); + } + if (data.containsKey('last_failure_code')) { + context.handle( + _lastFailureCodeMeta, + lastFailureCode.isAcceptableOrUnknown( + data['last_failure_code']!, + _lastFailureCodeMeta, + ), + ); + } + if (data.containsKey('state_schema_version')) { + context.handle( + _stateSchemaVersionMeta, + stateSchemaVersion.isAcceptableOrUnknown( + data['state_schema_version']!, + _stateSchemaVersionMeta, + ), + ); + } + if (data.containsKey('state_json')) { + context.handle( + _stateJsonMeta, + stateJson.isAcceptableOrUnknown(data['state_json']!, _stateJsonMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SyncCursor map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncCursor( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + accountId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_id'], + )!, + projectionSourceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_source_id'], + ), + provider: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}provider'], + )!, + transport: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}transport'], + )!, + syncScopeKind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sync_scope_kind'], + )!, + davCollectionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}dav_collection_id'], + ), + cursorKind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cursor_kind'], + )!, + cursorValue: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cursor_value'], + )!, + rangeStart: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}range_start'], + ), + rangeEnd: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}range_end'], + ), + baselineGeneration: attachedDatabase.typeMapping.read( DriftSqlType.int, - data['${effectivePrefix}updated_at_local'], + data['${effectivePrefix}baseline_generation'], )!, + inProgressCursor: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}in_progress_cursor'], + ), + inProgressGeneration: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}in_progress_generation'], + ), + lastCompleteSyncAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_complete_sync_at'], + ), + lastFailureCode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_failure_code'], + ), + stateSchemaVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}state_schema_version'], + )!, + stateJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state_json'], + ), ); } @override - $ScheduleItemOverridesTable createAlias(String alias) { - return $ScheduleItemOverridesTable(attachedDatabase, alias); + $SyncCursorsTable createAlias(String alias) { + return $SyncCursorsTable(attachedDatabase, alias); } } -class ScheduleItemOverride extends DataClass - implements Insertable { +class SyncCursor extends DataClass implements Insertable { final String id; final String accountId; - final String sourceType; - final String sourceId; - final String overrideJson; - final int createdAtLocal; - final int updatedAtLocal; - const ScheduleItemOverride({ + final String? projectionSourceId; + final String provider; + final String transport; + final String syncScopeKind; + final String? davCollectionId; + final String cursorKind; + final String cursorValue; + final String? rangeStart; + final String? rangeEnd; + final int baselineGeneration; + final String? inProgressCursor; + final int? inProgressGeneration; + final int? lastCompleteSyncAt; + final String? lastFailureCode; + final int stateSchemaVersion; + final String? stateJson; + const SyncCursor({ required this.id, required this.accountId, - required this.sourceType, - required this.sourceId, - required this.overrideJson, - required this.createdAtLocal, - required this.updatedAtLocal, + this.projectionSourceId, + required this.provider, + required this.transport, + required this.syncScopeKind, + this.davCollectionId, + required this.cursorKind, + required this.cursorValue, + this.rangeStart, + this.rangeEnd, + required this.baselineGeneration, + this.inProgressCursor, + this.inProgressGeneration, + this.lastCompleteSyncAt, + this.lastFailureCode, + required this.stateSchemaVersion, + this.stateJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); map['account_id'] = Variable(accountId); - map['source_type'] = Variable(sourceType); - map['source_id'] = Variable(sourceId); - map['override_json'] = Variable(overrideJson); - map['created_at_local'] = Variable(createdAtLocal); - map['updated_at_local'] = Variable(updatedAtLocal); + if (!nullToAbsent || projectionSourceId != null) { + map['projection_source_id'] = Variable(projectionSourceId); + } + map['provider'] = Variable(provider); + map['transport'] = Variable(transport); + map['sync_scope_kind'] = Variable(syncScopeKind); + if (!nullToAbsent || davCollectionId != null) { + map['dav_collection_id'] = Variable(davCollectionId); + } + map['cursor_kind'] = Variable(cursorKind); + map['cursor_value'] = Variable(cursorValue); + if (!nullToAbsent || rangeStart != null) { + map['range_start'] = Variable(rangeStart); + } + if (!nullToAbsent || rangeEnd != null) { + map['range_end'] = Variable(rangeEnd); + } + map['baseline_generation'] = Variable(baselineGeneration); + if (!nullToAbsent || inProgressCursor != null) { + map['in_progress_cursor'] = Variable(inProgressCursor); + } + if (!nullToAbsent || inProgressGeneration != null) { + map['in_progress_generation'] = Variable(inProgressGeneration); + } + if (!nullToAbsent || lastCompleteSyncAt != null) { + map['last_complete_sync_at'] = Variable(lastCompleteSyncAt); + } + if (!nullToAbsent || lastFailureCode != null) { + map['last_failure_code'] = Variable(lastFailureCode); + } + map['state_schema_version'] = Variable(stateSchemaVersion); + if (!nullToAbsent || stateJson != null) { + map['state_json'] = Variable(stateJson); + } return map; } - ScheduleItemOverridesCompanion toCompanion(bool nullToAbsent) { - return ScheduleItemOverridesCompanion( + SyncCursorsCompanion toCompanion(bool nullToAbsent) { + return SyncCursorsCompanion( id: Value(id), accountId: Value(accountId), - sourceType: Value(sourceType), - sourceId: Value(sourceId), - overrideJson: Value(overrideJson), - createdAtLocal: Value(createdAtLocal), - updatedAtLocal: Value(updatedAtLocal), - ); - } - - factory ScheduleItemOverride.fromJson( - Map json, { + projectionSourceId: projectionSourceId == null && nullToAbsent + ? const Value.absent() + : Value(projectionSourceId), + provider: Value(provider), + transport: Value(transport), + syncScopeKind: Value(syncScopeKind), + davCollectionId: davCollectionId == null && nullToAbsent + ? const Value.absent() + : Value(davCollectionId), + cursorKind: Value(cursorKind), + cursorValue: Value(cursorValue), + rangeStart: rangeStart == null && nullToAbsent + ? const Value.absent() + : Value(rangeStart), + rangeEnd: rangeEnd == null && nullToAbsent + ? const Value.absent() + : Value(rangeEnd), + baselineGeneration: Value(baselineGeneration), + inProgressCursor: inProgressCursor == null && nullToAbsent + ? const Value.absent() + : Value(inProgressCursor), + inProgressGeneration: inProgressGeneration == null && nullToAbsent + ? const Value.absent() + : Value(inProgressGeneration), + lastCompleteSyncAt: lastCompleteSyncAt == null && nullToAbsent + ? const Value.absent() + : Value(lastCompleteSyncAt), + lastFailureCode: lastFailureCode == null && nullToAbsent + ? const Value.absent() + : Value(lastFailureCode), + stateSchemaVersion: Value(stateSchemaVersion), + stateJson: stateJson == null && nullToAbsent + ? const Value.absent() + : Value(stateJson), + ); + } + + factory SyncCursor.fromJson( + Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return ScheduleItemOverride( + return SyncCursor( id: serializer.fromJson(json['id']), accountId: serializer.fromJson(json['accountId']), - sourceType: serializer.fromJson(json['sourceType']), - sourceId: serializer.fromJson(json['sourceId']), - overrideJson: serializer.fromJson(json['overrideJson']), - createdAtLocal: serializer.fromJson(json['createdAtLocal']), - updatedAtLocal: serializer.fromJson(json['updatedAtLocal']), + projectionSourceId: serializer.fromJson( + json['projectionSourceId'], + ), + provider: serializer.fromJson(json['provider']), + transport: serializer.fromJson(json['transport']), + syncScopeKind: serializer.fromJson(json['syncScopeKind']), + davCollectionId: serializer.fromJson(json['davCollectionId']), + cursorKind: serializer.fromJson(json['cursorKind']), + cursorValue: serializer.fromJson(json['cursorValue']), + rangeStart: serializer.fromJson(json['rangeStart']), + rangeEnd: serializer.fromJson(json['rangeEnd']), + baselineGeneration: serializer.fromJson(json['baselineGeneration']), + inProgressCursor: serializer.fromJson(json['inProgressCursor']), + inProgressGeneration: serializer.fromJson( + json['inProgressGeneration'], + ), + lastCompleteSyncAt: serializer.fromJson(json['lastCompleteSyncAt']), + lastFailureCode: serializer.fromJson(json['lastFailureCode']), + stateSchemaVersion: serializer.fromJson(json['stateSchemaVersion']), + stateJson: serializer.fromJson(json['stateJson']), ); } @override @@ -21795,61 +22550,144 @@ class ScheduleItemOverride extends DataClass return { 'id': serializer.toJson(id), 'accountId': serializer.toJson(accountId), - 'sourceType': serializer.toJson(sourceType), - 'sourceId': serializer.toJson(sourceId), - 'overrideJson': serializer.toJson(overrideJson), - 'createdAtLocal': serializer.toJson(createdAtLocal), - 'updatedAtLocal': serializer.toJson(updatedAtLocal), + 'projectionSourceId': serializer.toJson(projectionSourceId), + 'provider': serializer.toJson(provider), + 'transport': serializer.toJson(transport), + 'syncScopeKind': serializer.toJson(syncScopeKind), + 'davCollectionId': serializer.toJson(davCollectionId), + 'cursorKind': serializer.toJson(cursorKind), + 'cursorValue': serializer.toJson(cursorValue), + 'rangeStart': serializer.toJson(rangeStart), + 'rangeEnd': serializer.toJson(rangeEnd), + 'baselineGeneration': serializer.toJson(baselineGeneration), + 'inProgressCursor': serializer.toJson(inProgressCursor), + 'inProgressGeneration': serializer.toJson(inProgressGeneration), + 'lastCompleteSyncAt': serializer.toJson(lastCompleteSyncAt), + 'lastFailureCode': serializer.toJson(lastFailureCode), + 'stateSchemaVersion': serializer.toJson(stateSchemaVersion), + 'stateJson': serializer.toJson(stateJson), }; } - ScheduleItemOverride copyWith({ + SyncCursor copyWith({ String? id, String? accountId, - String? sourceType, - String? sourceId, - String? overrideJson, - int? createdAtLocal, - int? updatedAtLocal, - }) => ScheduleItemOverride( + Value projectionSourceId = const Value.absent(), + String? provider, + String? transport, + String? syncScopeKind, + Value davCollectionId = const Value.absent(), + String? cursorKind, + String? cursorValue, + Value rangeStart = const Value.absent(), + Value rangeEnd = const Value.absent(), + int? baselineGeneration, + Value inProgressCursor = const Value.absent(), + Value inProgressGeneration = const Value.absent(), + Value lastCompleteSyncAt = const Value.absent(), + Value lastFailureCode = const Value.absent(), + int? stateSchemaVersion, + Value stateJson = const Value.absent(), + }) => SyncCursor( id: id ?? this.id, accountId: accountId ?? this.accountId, - sourceType: sourceType ?? this.sourceType, - sourceId: sourceId ?? this.sourceId, - overrideJson: overrideJson ?? this.overrideJson, - createdAtLocal: createdAtLocal ?? this.createdAtLocal, - updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + projectionSourceId: projectionSourceId.present + ? projectionSourceId.value + : this.projectionSourceId, + provider: provider ?? this.provider, + transport: transport ?? this.transport, + syncScopeKind: syncScopeKind ?? this.syncScopeKind, + davCollectionId: davCollectionId.present + ? davCollectionId.value + : this.davCollectionId, + cursorKind: cursorKind ?? this.cursorKind, + cursorValue: cursorValue ?? this.cursorValue, + rangeStart: rangeStart.present ? rangeStart.value : this.rangeStart, + rangeEnd: rangeEnd.present ? rangeEnd.value : this.rangeEnd, + baselineGeneration: baselineGeneration ?? this.baselineGeneration, + inProgressCursor: inProgressCursor.present + ? inProgressCursor.value + : this.inProgressCursor, + inProgressGeneration: inProgressGeneration.present + ? inProgressGeneration.value + : this.inProgressGeneration, + lastCompleteSyncAt: lastCompleteSyncAt.present + ? lastCompleteSyncAt.value + : this.lastCompleteSyncAt, + lastFailureCode: lastFailureCode.present + ? lastFailureCode.value + : this.lastFailureCode, + stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, + stateJson: stateJson.present ? stateJson.value : this.stateJson, ); - ScheduleItemOverride copyWithCompanion(ScheduleItemOverridesCompanion data) { - return ScheduleItemOverride( + SyncCursor copyWithCompanion(SyncCursorsCompanion data) { + return SyncCursor( id: data.id.present ? data.id.value : this.id, accountId: data.accountId.present ? data.accountId.value : this.accountId, - sourceType: data.sourceType.present - ? data.sourceType.value - : this.sourceType, - sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, - overrideJson: data.overrideJson.present - ? data.overrideJson.value - : this.overrideJson, - createdAtLocal: data.createdAtLocal.present - ? data.createdAtLocal.value - : this.createdAtLocal, - updatedAtLocal: data.updatedAtLocal.present - ? data.updatedAtLocal.value - : this.updatedAtLocal, + projectionSourceId: data.projectionSourceId.present + ? data.projectionSourceId.value + : this.projectionSourceId, + provider: data.provider.present ? data.provider.value : this.provider, + transport: data.transport.present ? data.transport.value : this.transport, + syncScopeKind: data.syncScopeKind.present + ? data.syncScopeKind.value + : this.syncScopeKind, + davCollectionId: data.davCollectionId.present + ? data.davCollectionId.value + : this.davCollectionId, + cursorKind: data.cursorKind.present + ? data.cursorKind.value + : this.cursorKind, + cursorValue: data.cursorValue.present + ? data.cursorValue.value + : this.cursorValue, + rangeStart: data.rangeStart.present + ? data.rangeStart.value + : this.rangeStart, + rangeEnd: data.rangeEnd.present ? data.rangeEnd.value : this.rangeEnd, + baselineGeneration: data.baselineGeneration.present + ? data.baselineGeneration.value + : this.baselineGeneration, + inProgressCursor: data.inProgressCursor.present + ? data.inProgressCursor.value + : this.inProgressCursor, + inProgressGeneration: data.inProgressGeneration.present + ? data.inProgressGeneration.value + : this.inProgressGeneration, + lastCompleteSyncAt: data.lastCompleteSyncAt.present + ? data.lastCompleteSyncAt.value + : this.lastCompleteSyncAt, + lastFailureCode: data.lastFailureCode.present + ? data.lastFailureCode.value + : this.lastFailureCode, + stateSchemaVersion: data.stateSchemaVersion.present + ? data.stateSchemaVersion.value + : this.stateSchemaVersion, + stateJson: data.stateJson.present ? data.stateJson.value : this.stateJson, ); } @override String toString() { - return (StringBuffer('ScheduleItemOverride(') + return (StringBuffer('SyncCursor(') ..write('id: $id, ') ..write('accountId: $accountId, ') - ..write('sourceType: $sourceType, ') - ..write('sourceId: $sourceId, ') - ..write('overrideJson: $overrideJson, ') - ..write('createdAtLocal: $createdAtLocal, ') - ..write('updatedAtLocal: $updatedAtLocal') + ..write('projectionSourceId: $projectionSourceId, ') + ..write('provider: $provider, ') + ..write('transport: $transport, ') + ..write('syncScopeKind: $syncScopeKind, ') + ..write('davCollectionId: $davCollectionId, ') + ..write('cursorKind: $cursorKind, ') + ..write('cursorValue: $cursorValue, ') + ..write('rangeStart: $rangeStart, ') + ..write('rangeEnd: $rangeEnd, ') + ..write('baselineGeneration: $baselineGeneration, ') + ..write('inProgressCursor: $inProgressCursor, ') + ..write('inProgressGeneration: $inProgressGeneration, ') + ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('stateSchemaVersion: $stateSchemaVersion, ') + ..write('stateJson: $stateJson') ..write(')')) .toString(); } @@ -21858,101 +22696,203 @@ class ScheduleItemOverride extends DataClass int get hashCode => Object.hash( id, accountId, - sourceType, - sourceId, - overrideJson, - createdAtLocal, - updatedAtLocal, + projectionSourceId, + provider, + transport, + syncScopeKind, + davCollectionId, + cursorKind, + cursorValue, + rangeStart, + rangeEnd, + baselineGeneration, + inProgressCursor, + inProgressGeneration, + lastCompleteSyncAt, + lastFailureCode, + stateSchemaVersion, + stateJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is ScheduleItemOverride && + (other is SyncCursor && other.id == this.id && other.accountId == this.accountId && - other.sourceType == this.sourceType && - other.sourceId == this.sourceId && - other.overrideJson == this.overrideJson && - other.createdAtLocal == this.createdAtLocal && - other.updatedAtLocal == this.updatedAtLocal); -} - -class ScheduleItemOverridesCompanion - extends UpdateCompanion { + other.projectionSourceId == this.projectionSourceId && + other.provider == this.provider && + other.transport == this.transport && + other.syncScopeKind == this.syncScopeKind && + other.davCollectionId == this.davCollectionId && + other.cursorKind == this.cursorKind && + other.cursorValue == this.cursorValue && + other.rangeStart == this.rangeStart && + other.rangeEnd == this.rangeEnd && + other.baselineGeneration == this.baselineGeneration && + other.inProgressCursor == this.inProgressCursor && + other.inProgressGeneration == this.inProgressGeneration && + other.lastCompleteSyncAt == this.lastCompleteSyncAt && + other.lastFailureCode == this.lastFailureCode && + other.stateSchemaVersion == this.stateSchemaVersion && + other.stateJson == this.stateJson); +} + +class SyncCursorsCompanion extends UpdateCompanion { final Value id; final Value accountId; - final Value sourceType; - final Value sourceId; - final Value overrideJson; - final Value createdAtLocal; - final Value updatedAtLocal; + final Value projectionSourceId; + final Value provider; + final Value transport; + final Value syncScopeKind; + final Value davCollectionId; + final Value cursorKind; + final Value cursorValue; + final Value rangeStart; + final Value rangeEnd; + final Value baselineGeneration; + final Value inProgressCursor; + final Value inProgressGeneration; + final Value lastCompleteSyncAt; + final Value lastFailureCode; + final Value stateSchemaVersion; + final Value stateJson; final Value rowid; - const ScheduleItemOverridesCompanion({ + const SyncCursorsCompanion({ this.id = const Value.absent(), this.accountId = const Value.absent(), - this.sourceType = const Value.absent(), - this.sourceId = const Value.absent(), - this.overrideJson = const Value.absent(), - this.createdAtLocal = const Value.absent(), - this.updatedAtLocal = const Value.absent(), + this.projectionSourceId = const Value.absent(), + this.provider = const Value.absent(), + this.transport = const Value.absent(), + this.syncScopeKind = const Value.absent(), + this.davCollectionId = const Value.absent(), + this.cursorKind = const Value.absent(), + this.cursorValue = const Value.absent(), + this.rangeStart = const Value.absent(), + this.rangeEnd = const Value.absent(), + this.baselineGeneration = const Value.absent(), + this.inProgressCursor = const Value.absent(), + this.inProgressGeneration = const Value.absent(), + this.lastCompleteSyncAt = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.stateSchemaVersion = const Value.absent(), + this.stateJson = const Value.absent(), this.rowid = const Value.absent(), }); - ScheduleItemOverridesCompanion.insert({ + SyncCursorsCompanion.insert({ required String id, required String accountId, - required String sourceType, - required String sourceId, - required String overrideJson, - required int createdAtLocal, - required int updatedAtLocal, + this.projectionSourceId = const Value.absent(), + required String provider, + required String transport, + required String syncScopeKind, + this.davCollectionId = const Value.absent(), + required String cursorKind, + required String cursorValue, + this.rangeStart = const Value.absent(), + this.rangeEnd = const Value.absent(), + this.baselineGeneration = const Value.absent(), + this.inProgressCursor = const Value.absent(), + this.inProgressGeneration = const Value.absent(), + this.lastCompleteSyncAt = const Value.absent(), + this.lastFailureCode = const Value.absent(), + this.stateSchemaVersion = const Value.absent(), + this.stateJson = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), accountId = Value(accountId), - sourceType = Value(sourceType), - sourceId = Value(sourceId), - overrideJson = Value(overrideJson), - createdAtLocal = Value(createdAtLocal), - updatedAtLocal = Value(updatedAtLocal); - static Insertable custom({ + provider = Value(provider), + transport = Value(transport), + syncScopeKind = Value(syncScopeKind), + cursorKind = Value(cursorKind), + cursorValue = Value(cursorValue); + static Insertable custom({ Expression? id, Expression? accountId, - Expression? sourceType, - Expression? sourceId, - Expression? overrideJson, - Expression? createdAtLocal, - Expression? updatedAtLocal, + Expression? projectionSourceId, + Expression? provider, + Expression? transport, + Expression? syncScopeKind, + Expression? davCollectionId, + Expression? cursorKind, + Expression? cursorValue, + Expression? rangeStart, + Expression? rangeEnd, + Expression? baselineGeneration, + Expression? inProgressCursor, + Expression? inProgressGeneration, + Expression? lastCompleteSyncAt, + Expression? lastFailureCode, + Expression? stateSchemaVersion, + Expression? stateJson, Expression? rowid, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (accountId != null) 'account_id': accountId, - if (sourceType != null) 'source_type': sourceType, - if (sourceId != null) 'source_id': sourceId, - if (overrideJson != null) 'override_json': overrideJson, - if (createdAtLocal != null) 'created_at_local': createdAtLocal, - if (updatedAtLocal != null) 'updated_at_local': updatedAtLocal, + if (projectionSourceId != null) + 'projection_source_id': projectionSourceId, + if (provider != null) 'provider': provider, + if (transport != null) 'transport': transport, + if (syncScopeKind != null) 'sync_scope_kind': syncScopeKind, + if (davCollectionId != null) 'dav_collection_id': davCollectionId, + if (cursorKind != null) 'cursor_kind': cursorKind, + if (cursorValue != null) 'cursor_value': cursorValue, + if (rangeStart != null) 'range_start': rangeStart, + if (rangeEnd != null) 'range_end': rangeEnd, + if (baselineGeneration != null) 'baseline_generation': baselineGeneration, + if (inProgressCursor != null) 'in_progress_cursor': inProgressCursor, + if (inProgressGeneration != null) + 'in_progress_generation': inProgressGeneration, + if (lastCompleteSyncAt != null) + 'last_complete_sync_at': lastCompleteSyncAt, + if (lastFailureCode != null) 'last_failure_code': lastFailureCode, + if (stateSchemaVersion != null) + 'state_schema_version': stateSchemaVersion, + if (stateJson != null) 'state_json': stateJson, if (rowid != null) 'rowid': rowid, }); } - ScheduleItemOverridesCompanion copyWith({ + SyncCursorsCompanion copyWith({ Value? id, Value? accountId, - Value? sourceType, - Value? sourceId, - Value? overrideJson, - Value? createdAtLocal, - Value? updatedAtLocal, + Value? projectionSourceId, + Value? provider, + Value? transport, + Value? syncScopeKind, + Value? davCollectionId, + Value? cursorKind, + Value? cursorValue, + Value? rangeStart, + Value? rangeEnd, + Value? baselineGeneration, + Value? inProgressCursor, + Value? inProgressGeneration, + Value? lastCompleteSyncAt, + Value? lastFailureCode, + Value? stateSchemaVersion, + Value? stateJson, Value? rowid, }) { - return ScheduleItemOverridesCompanion( + return SyncCursorsCompanion( id: id ?? this.id, accountId: accountId ?? this.accountId, - sourceType: sourceType ?? this.sourceType, - sourceId: sourceId ?? this.sourceId, - overrideJson: overrideJson ?? this.overrideJson, - createdAtLocal: createdAtLocal ?? this.createdAtLocal, - updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + projectionSourceId: projectionSourceId ?? this.projectionSourceId, + provider: provider ?? this.provider, + transport: transport ?? this.transport, + syncScopeKind: syncScopeKind ?? this.syncScopeKind, + davCollectionId: davCollectionId ?? this.davCollectionId, + cursorKind: cursorKind ?? this.cursorKind, + cursorValue: cursorValue ?? this.cursorValue, + rangeStart: rangeStart ?? this.rangeStart, + rangeEnd: rangeEnd ?? this.rangeEnd, + baselineGeneration: baselineGeneration ?? this.baselineGeneration, + inProgressCursor: inProgressCursor ?? this.inProgressCursor, + inProgressGeneration: inProgressGeneration ?? this.inProgressGeneration, + lastCompleteSyncAt: lastCompleteSyncAt ?? this.lastCompleteSyncAt, + lastFailureCode: lastFailureCode ?? this.lastFailureCode, + stateSchemaVersion: stateSchemaVersion ?? this.stateSchemaVersion, + stateJson: stateJson ?? this.stateJson, rowid: rowid ?? this.rowid, ); } @@ -21966,20 +22906,53 @@ class ScheduleItemOverridesCompanion if (accountId.present) { map['account_id'] = Variable(accountId.value); } - if (sourceType.present) { - map['source_type'] = Variable(sourceType.value); + if (projectionSourceId.present) { + map['projection_source_id'] = Variable(projectionSourceId.value); } - if (sourceId.present) { - map['source_id'] = Variable(sourceId.value); + if (provider.present) { + map['provider'] = Variable(provider.value); } - if (overrideJson.present) { - map['override_json'] = Variable(overrideJson.value); + if (transport.present) { + map['transport'] = Variable(transport.value); } - if (createdAtLocal.present) { - map['created_at_local'] = Variable(createdAtLocal.value); + if (syncScopeKind.present) { + map['sync_scope_kind'] = Variable(syncScopeKind.value); } - if (updatedAtLocal.present) { - map['updated_at_local'] = Variable(updatedAtLocal.value); + if (davCollectionId.present) { + map['dav_collection_id'] = Variable(davCollectionId.value); + } + if (cursorKind.present) { + map['cursor_kind'] = Variable(cursorKind.value); + } + if (cursorValue.present) { + map['cursor_value'] = Variable(cursorValue.value); + } + if (rangeStart.present) { + map['range_start'] = Variable(rangeStart.value); + } + if (rangeEnd.present) { + map['range_end'] = Variable(rangeEnd.value); + } + if (baselineGeneration.present) { + map['baseline_generation'] = Variable(baselineGeneration.value); + } + if (inProgressCursor.present) { + map['in_progress_cursor'] = Variable(inProgressCursor.value); + } + if (inProgressGeneration.present) { + map['in_progress_generation'] = Variable(inProgressGeneration.value); + } + if (lastCompleteSyncAt.present) { + map['last_complete_sync_at'] = Variable(lastCompleteSyncAt.value); + } + if (lastFailureCode.present) { + map['last_failure_code'] = Variable(lastFailureCode.value); + } + if (stateSchemaVersion.present) { + map['state_schema_version'] = Variable(stateSchemaVersion.value); + } + if (stateJson.present) { + map['state_json'] = Variable(stateJson.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -21989,693 +22962,412 @@ class ScheduleItemOverridesCompanion @override String toString() { - return (StringBuffer('ScheduleItemOverridesCompanion(') + return (StringBuffer('SyncCursorsCompanion(') ..write('id: $id, ') ..write('accountId: $accountId, ') - ..write('sourceType: $sourceType, ') - ..write('sourceId: $sourceId, ') - ..write('overrideJson: $overrideJson, ') - ..write('createdAtLocal: $createdAtLocal, ') - ..write('updatedAtLocal: $updatedAtLocal, ') - ..write('rowid: $rowid') - ..write(')')) - .toString(); - } -} + ..write('projectionSourceId: $projectionSourceId, ') + ..write('provider: $provider, ') + ..write('transport: $transport, ') + ..write('syncScopeKind: $syncScopeKind, ') + ..write('davCollectionId: $davCollectionId, ') + ..write('cursorKind: $cursorKind, ') + ..write('cursorValue: $cursorValue, ') + ..write('rangeStart: $rangeStart, ') + ..write('rangeEnd: $rangeEnd, ') + ..write('baselineGeneration: $baselineGeneration, ') + ..write('inProgressCursor: $inProgressCursor, ') + ..write('inProgressGeneration: $inProgressGeneration, ') + ..write('lastCompleteSyncAt: $lastCompleteSyncAt, ') + ..write('lastFailureCode: $lastFailureCode, ') + ..write('stateSchemaVersion: $stateSchemaVersion, ') + ..write('stateJson: $stateJson, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} -class $NotificationScheduleTable extends NotificationSchedule - with TableInfo<$NotificationScheduleTable, NotificationScheduleData> { +class $CalendarColorsTable extends CalendarColors + with TableInfo<$CalendarColorsTable, CalendarColor> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $NotificationScheduleTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _accountIdMeta = const VerificationMeta( - 'accountId', + $CalendarColorsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _providerMeta = const VerificationMeta( + 'provider', ); @override - late final GeneratedColumn accountId = GeneratedColumn( - 'account_id', + late final GeneratedColumn provider = GeneratedColumn( + 'provider', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES accounts (id) ON DELETE CASCADE', - ), ); - static const VerificationMeta _sourceTypeMeta = const VerificationMeta( - 'sourceType', + static const VerificationMeta _colorTypeMeta = const VerificationMeta( + 'colorType', ); @override - late final GeneratedColumn sourceType = GeneratedColumn( - 'source_type', + late final GeneratedColumn colorType = GeneratedColumn( + 'color_type', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _sourceIdMeta = const VerificationMeta( - 'sourceId', + static const VerificationMeta _colorIdMeta = const VerificationMeta( + 'colorId', ); @override - late final GeneratedColumn sourceId = GeneratedColumn( - 'source_id', + late final GeneratedColumn colorId = GeneratedColumn( + 'color_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _scheduledAtUtcMeta = const VerificationMeta( - 'scheduledAtUtc', - ); - @override - late final GeneratedColumn scheduledAtUtc = GeneratedColumn( - 'scheduled_at_utc', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, + static const VerificationMeta _backgroundMeta = const VerificationMeta( + 'background', ); - static const VerificationMeta _titleMeta = const VerificationMeta('title'); @override - late final GeneratedColumn title = GeneratedColumn( - 'title', + late final GeneratedColumn background = GeneratedColumn( + 'background', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _bodyMeta = const VerificationMeta('body'); - @override - late final GeneratedColumn body = GeneratedColumn( - 'body', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _sentAtUtcMeta = const VerificationMeta( - 'sentAtUtc', - ); - @override - late final GeneratedColumn sentAtUtc = GeneratedColumn( - 'sent_at_utc', - aliasedName, - true, - type: DriftSqlType.int, - requiredDuringInsert: false, - ); - static const VerificationMeta _dismissedAtUtcMeta = const VerificationMeta( - 'dismissedAtUtc', + static const VerificationMeta _foregroundMeta = const VerificationMeta( + 'foreground', ); @override - late final GeneratedColumn dismissedAtUtc = GeneratedColumn( - 'dismissed_at_utc', + late final GeneratedColumn foreground = GeneratedColumn( + 'foreground', aliasedName, true, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _snoozedUntilUtcMeta = const VerificationMeta( - 'snoozedUntilUtc', + static const VerificationMeta _rawJsonMeta = const VerificationMeta( + 'rawJson', ); @override - late final GeneratedColumn snoozedUntilUtc = GeneratedColumn( - 'snoozed_until_utc', + late final GeneratedColumn rawJson = GeneratedColumn( + 'raw_json', aliasedName, true, - type: DriftSqlType.int, + type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( - 'createdAtLocal', - ); - @override - late final GeneratedColumn createdAtLocal = GeneratedColumn( - 'created_at_local', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _updatedAtLocalMeta = const VerificationMeta( - 'updatedAtLocal', - ); - @override - late final GeneratedColumn updatedAtLocal = GeneratedColumn( - 'updated_at_local', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); @override List get $columns => [ - id, - accountId, - sourceType, - sourceId, - scheduledAtUtc, - title, - body, - sentAtUtc, - dismissedAtUtc, - snoozedUntilUtc, - createdAtLocal, - updatedAtLocal, + provider, + colorType, + colorId, + background, + foreground, + rawJson, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'notification_schedule'; + static const String $name = 'calendar_colors'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('account_id')) { - context.handle( - _accountIdMeta, - accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), - ); - } else if (isInserting) { - context.missing(_accountIdMeta); - } - if (data.containsKey('source_type')) { + if (data.containsKey('provider')) { context.handle( - _sourceTypeMeta, - sourceType.isAcceptableOrUnknown(data['source_type']!, _sourceTypeMeta), + _providerMeta, + provider.isAcceptableOrUnknown(data['provider']!, _providerMeta), ); } else if (isInserting) { - context.missing(_sourceTypeMeta); + context.missing(_providerMeta); } - if (data.containsKey('source_id')) { + if (data.containsKey('color_type')) { context.handle( - _sourceIdMeta, - sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + _colorTypeMeta, + colorType.isAcceptableOrUnknown(data['color_type']!, _colorTypeMeta), ); } else if (isInserting) { - context.missing(_sourceIdMeta); + context.missing(_colorTypeMeta); } - if (data.containsKey('scheduled_at_utc')) { + if (data.containsKey('color_id')) { context.handle( - _scheduledAtUtcMeta, - scheduledAtUtc.isAcceptableOrUnknown( - data['scheduled_at_utc']!, - _scheduledAtUtcMeta, - ), + _colorIdMeta, + colorId.isAcceptableOrUnknown(data['color_id']!, _colorIdMeta), ); } else if (isInserting) { - context.missing(_scheduledAtUtcMeta); + context.missing(_colorIdMeta); } - if (data.containsKey('title')) { + if (data.containsKey('background')) { context.handle( - _titleMeta, - title.isAcceptableOrUnknown(data['title']!, _titleMeta), + _backgroundMeta, + background.isAcceptableOrUnknown(data['background']!, _backgroundMeta), ); } else if (isInserting) { - context.missing(_titleMeta); - } - if (data.containsKey('body')) { - context.handle( - _bodyMeta, - body.isAcceptableOrUnknown(data['body']!, _bodyMeta), - ); - } - if (data.containsKey('sent_at_utc')) { - context.handle( - _sentAtUtcMeta, - sentAtUtc.isAcceptableOrUnknown(data['sent_at_utc']!, _sentAtUtcMeta), - ); - } - if (data.containsKey('dismissed_at_utc')) { - context.handle( - _dismissedAtUtcMeta, - dismissedAtUtc.isAcceptableOrUnknown( - data['dismissed_at_utc']!, - _dismissedAtUtcMeta, - ), - ); + context.missing(_backgroundMeta); } - if (data.containsKey('snoozed_until_utc')) { + if (data.containsKey('foreground')) { context.handle( - _snoozedUntilUtcMeta, - snoozedUntilUtc.isAcceptableOrUnknown( - data['snoozed_until_utc']!, - _snoozedUntilUtcMeta, - ), + _foregroundMeta, + foreground.isAcceptableOrUnknown(data['foreground']!, _foregroundMeta), ); } - if (data.containsKey('created_at_local')) { + if (data.containsKey('raw_json')) { context.handle( - _createdAtLocalMeta, - createdAtLocal.isAcceptableOrUnknown( - data['created_at_local']!, - _createdAtLocalMeta, - ), + _rawJsonMeta, + rawJson.isAcceptableOrUnknown(data['raw_json']!, _rawJsonMeta), ); - } else if (isInserting) { - context.missing(_createdAtLocalMeta); - } - if (data.containsKey('updated_at_local')) { - context.handle( - _updatedAtLocalMeta, - updatedAtLocal.isAcceptableOrUnknown( - data['updated_at_local']!, - _updatedAtLocalMeta, - ), - ); - } else if (isInserting) { - context.missing(_updatedAtLocalMeta); } return context; } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {provider, colorType, colorId}; @override - NotificationScheduleData map( - Map data, { - String? tablePrefix, - }) { + CalendarColor map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return NotificationScheduleData( - id: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}id'], - )!, - accountId: attachedDatabase.typeMapping.read( + return CalendarColor( + provider: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}account_id'], + data['${effectivePrefix}provider'], )!, - sourceType: attachedDatabase.typeMapping.read( + colorType: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}source_type'], + data['${effectivePrefix}color_type'], )!, - sourceId: attachedDatabase.typeMapping.read( + colorId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}source_id'], - )!, - scheduledAtUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}scheduled_at_utc'], + data['${effectivePrefix}color_id'], )!, - title: attachedDatabase.typeMapping.read( + background: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}title'], + data['${effectivePrefix}background'], )!, - body: attachedDatabase.typeMapping.read( + foreground: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}body'], - ), - sentAtUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}sent_at_utc'], - ), - dismissedAtUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}dismissed_at_utc'], + data['${effectivePrefix}foreground'], ), - snoozedUntilUtc: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}snoozed_until_utc'], + rawJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}raw_json'], ), - createdAtLocal: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}created_at_local'], - )!, - updatedAtLocal: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}updated_at_local'], - )!, ); } @override - $NotificationScheduleTable createAlias(String alias) { - return $NotificationScheduleTable(attachedDatabase, alias); + $CalendarColorsTable createAlias(String alias) { + return $CalendarColorsTable(attachedDatabase, alias); } } -class NotificationScheduleData extends DataClass - implements Insertable { - final String id; - final String accountId; - final String sourceType; - final String sourceId; - final int scheduledAtUtc; - final String title; - final String? body; - final int? sentAtUtc; - final int? dismissedAtUtc; - final int? snoozedUntilUtc; - final int createdAtLocal; - final int updatedAtLocal; - const NotificationScheduleData({ - required this.id, - required this.accountId, - required this.sourceType, - required this.sourceId, - required this.scheduledAtUtc, - required this.title, - this.body, - this.sentAtUtc, - this.dismissedAtUtc, - this.snoozedUntilUtc, - required this.createdAtLocal, - required this.updatedAtLocal, +class CalendarColor extends DataClass implements Insertable { + final String provider; + final String colorType; + final String colorId; + final String background; + final String? foreground; + final String? rawJson; + const CalendarColor({ + required this.provider, + required this.colorType, + required this.colorId, + required this.background, + this.foreground, + this.rawJson, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); - map['account_id'] = Variable(accountId); - map['source_type'] = Variable(sourceType); - map['source_id'] = Variable(sourceId); - map['scheduled_at_utc'] = Variable(scheduledAtUtc); - map['title'] = Variable(title); - if (!nullToAbsent || body != null) { - map['body'] = Variable(body); - } - if (!nullToAbsent || sentAtUtc != null) { - map['sent_at_utc'] = Variable(sentAtUtc); - } - if (!nullToAbsent || dismissedAtUtc != null) { - map['dismissed_at_utc'] = Variable(dismissedAtUtc); + map['provider'] = Variable(provider); + map['color_type'] = Variable(colorType); + map['color_id'] = Variable(colorId); + map['background'] = Variable(background); + if (!nullToAbsent || foreground != null) { + map['foreground'] = Variable(foreground); } - if (!nullToAbsent || snoozedUntilUtc != null) { - map['snoozed_until_utc'] = Variable(snoozedUntilUtc); + if (!nullToAbsent || rawJson != null) { + map['raw_json'] = Variable(rawJson); } - map['created_at_local'] = Variable(createdAtLocal); - map['updated_at_local'] = Variable(updatedAtLocal); return map; } - NotificationScheduleCompanion toCompanion(bool nullToAbsent) { - return NotificationScheduleCompanion( - id: Value(id), - accountId: Value(accountId), - sourceType: Value(sourceType), - sourceId: Value(sourceId), - scheduledAtUtc: Value(scheduledAtUtc), - title: Value(title), - body: body == null && nullToAbsent ? const Value.absent() : Value(body), - sentAtUtc: sentAtUtc == null && nullToAbsent - ? const Value.absent() - : Value(sentAtUtc), - dismissedAtUtc: dismissedAtUtc == null && nullToAbsent + CalendarColorsCompanion toCompanion(bool nullToAbsent) { + return CalendarColorsCompanion( + provider: Value(provider), + colorType: Value(colorType), + colorId: Value(colorId), + background: Value(background), + foreground: foreground == null && nullToAbsent ? const Value.absent() - : Value(dismissedAtUtc), - snoozedUntilUtc: snoozedUntilUtc == null && nullToAbsent + : Value(foreground), + rawJson: rawJson == null && nullToAbsent ? const Value.absent() - : Value(snoozedUntilUtc), - createdAtLocal: Value(createdAtLocal), - updatedAtLocal: Value(updatedAtLocal), + : Value(rawJson), ); } - factory NotificationScheduleData.fromJson( + factory CalendarColor.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return NotificationScheduleData( - id: serializer.fromJson(json['id']), - accountId: serializer.fromJson(json['accountId']), - sourceType: serializer.fromJson(json['sourceType']), - sourceId: serializer.fromJson(json['sourceId']), - scheduledAtUtc: serializer.fromJson(json['scheduledAtUtc']), - title: serializer.fromJson(json['title']), - body: serializer.fromJson(json['body']), - sentAtUtc: serializer.fromJson(json['sentAtUtc']), - dismissedAtUtc: serializer.fromJson(json['dismissedAtUtc']), - snoozedUntilUtc: serializer.fromJson(json['snoozedUntilUtc']), - createdAtLocal: serializer.fromJson(json['createdAtLocal']), - updatedAtLocal: serializer.fromJson(json['updatedAtLocal']), + return CalendarColor( + provider: serializer.fromJson(json['provider']), + colorType: serializer.fromJson(json['colorType']), + colorId: serializer.fromJson(json['colorId']), + background: serializer.fromJson(json['background']), + foreground: serializer.fromJson(json['foreground']), + rawJson: serializer.fromJson(json['rawJson']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), - 'accountId': serializer.toJson(accountId), - 'sourceType': serializer.toJson(sourceType), - 'sourceId': serializer.toJson(sourceId), - 'scheduledAtUtc': serializer.toJson(scheduledAtUtc), - 'title': serializer.toJson(title), - 'body': serializer.toJson(body), - 'sentAtUtc': serializer.toJson(sentAtUtc), - 'dismissedAtUtc': serializer.toJson(dismissedAtUtc), - 'snoozedUntilUtc': serializer.toJson(snoozedUntilUtc), - 'createdAtLocal': serializer.toJson(createdAtLocal), - 'updatedAtLocal': serializer.toJson(updatedAtLocal), + 'provider': serializer.toJson(provider), + 'colorType': serializer.toJson(colorType), + 'colorId': serializer.toJson(colorId), + 'background': serializer.toJson(background), + 'foreground': serializer.toJson(foreground), + 'rawJson': serializer.toJson(rawJson), }; } - NotificationScheduleData copyWith({ - String? id, - String? accountId, - String? sourceType, - String? sourceId, - int? scheduledAtUtc, - String? title, - Value body = const Value.absent(), - Value sentAtUtc = const Value.absent(), - Value dismissedAtUtc = const Value.absent(), - Value snoozedUntilUtc = const Value.absent(), - int? createdAtLocal, - int? updatedAtLocal, - }) => NotificationScheduleData( - id: id ?? this.id, - accountId: accountId ?? this.accountId, - sourceType: sourceType ?? this.sourceType, - sourceId: sourceId ?? this.sourceId, - scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, - title: title ?? this.title, - body: body.present ? body.value : this.body, - sentAtUtc: sentAtUtc.present ? sentAtUtc.value : this.sentAtUtc, - dismissedAtUtc: dismissedAtUtc.present - ? dismissedAtUtc.value - : this.dismissedAtUtc, - snoozedUntilUtc: snoozedUntilUtc.present - ? snoozedUntilUtc.value - : this.snoozedUntilUtc, - createdAtLocal: createdAtLocal ?? this.createdAtLocal, - updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + CalendarColor copyWith({ + String? provider, + String? colorType, + String? colorId, + String? background, + Value foreground = const Value.absent(), + Value rawJson = const Value.absent(), + }) => CalendarColor( + provider: provider ?? this.provider, + colorType: colorType ?? this.colorType, + colorId: colorId ?? this.colorId, + background: background ?? this.background, + foreground: foreground.present ? foreground.value : this.foreground, + rawJson: rawJson.present ? rawJson.value : this.rawJson, ); - NotificationScheduleData copyWithCompanion( - NotificationScheduleCompanion data, - ) { - return NotificationScheduleData( - id: data.id.present ? data.id.value : this.id, - accountId: data.accountId.present ? data.accountId.value : this.accountId, - sourceType: data.sourceType.present - ? data.sourceType.value - : this.sourceType, - sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, - scheduledAtUtc: data.scheduledAtUtc.present - ? data.scheduledAtUtc.value - : this.scheduledAtUtc, - title: data.title.present ? data.title.value : this.title, - body: data.body.present ? data.body.value : this.body, - sentAtUtc: data.sentAtUtc.present ? data.sentAtUtc.value : this.sentAtUtc, - dismissedAtUtc: data.dismissedAtUtc.present - ? data.dismissedAtUtc.value - : this.dismissedAtUtc, - snoozedUntilUtc: data.snoozedUntilUtc.present - ? data.snoozedUntilUtc.value - : this.snoozedUntilUtc, - createdAtLocal: data.createdAtLocal.present - ? data.createdAtLocal.value - : this.createdAtLocal, - updatedAtLocal: data.updatedAtLocal.present - ? data.updatedAtLocal.value - : this.updatedAtLocal, + CalendarColor copyWithCompanion(CalendarColorsCompanion data) { + return CalendarColor( + provider: data.provider.present ? data.provider.value : this.provider, + colorType: data.colorType.present ? data.colorType.value : this.colorType, + colorId: data.colorId.present ? data.colorId.value : this.colorId, + background: data.background.present + ? data.background.value + : this.background, + foreground: data.foreground.present + ? data.foreground.value + : this.foreground, + rawJson: data.rawJson.present ? data.rawJson.value : this.rawJson, ); } @override String toString() { - return (StringBuffer('NotificationScheduleData(') - ..write('id: $id, ') - ..write('accountId: $accountId, ') - ..write('sourceType: $sourceType, ') - ..write('sourceId: $sourceId, ') - ..write('scheduledAtUtc: $scheduledAtUtc, ') - ..write('title: $title, ') - ..write('body: $body, ') - ..write('sentAtUtc: $sentAtUtc, ') - ..write('dismissedAtUtc: $dismissedAtUtc, ') - ..write('snoozedUntilUtc: $snoozedUntilUtc, ') - ..write('createdAtLocal: $createdAtLocal, ') - ..write('updatedAtLocal: $updatedAtLocal') + return (StringBuffer('CalendarColor(') + ..write('provider: $provider, ') + ..write('colorType: $colorType, ') + ..write('colorId: $colorId, ') + ..write('background: $background, ') + ..write('foreground: $foreground, ') + ..write('rawJson: $rawJson') ..write(')')) .toString(); } @override int get hashCode => Object.hash( - id, - accountId, - sourceType, - sourceId, - scheduledAtUtc, - title, - body, - sentAtUtc, - dismissedAtUtc, - snoozedUntilUtc, - createdAtLocal, - updatedAtLocal, + provider, + colorType, + colorId, + background, + foreground, + rawJson, ); @override bool operator ==(Object other) => identical(this, other) || - (other is NotificationScheduleData && - other.id == this.id && - other.accountId == this.accountId && - other.sourceType == this.sourceType && - other.sourceId == this.sourceId && - other.scheduledAtUtc == this.scheduledAtUtc && - other.title == this.title && - other.body == this.body && - other.sentAtUtc == this.sentAtUtc && - other.dismissedAtUtc == this.dismissedAtUtc && - other.snoozedUntilUtc == this.snoozedUntilUtc && - other.createdAtLocal == this.createdAtLocal && - other.updatedAtLocal == this.updatedAtLocal); + (other is CalendarColor && + other.provider == this.provider && + other.colorType == this.colorType && + other.colorId == this.colorId && + other.background == this.background && + other.foreground == this.foreground && + other.rawJson == this.rawJson); } -class NotificationScheduleCompanion - extends UpdateCompanion { - final Value id; - final Value accountId; - final Value sourceType; - final Value sourceId; - final Value scheduledAtUtc; - final Value title; - final Value body; - final Value sentAtUtc; - final Value dismissedAtUtc; - final Value snoozedUntilUtc; - final Value createdAtLocal; - final Value updatedAtLocal; +class CalendarColorsCompanion extends UpdateCompanion { + final Value provider; + final Value colorType; + final Value colorId; + final Value background; + final Value foreground; + final Value rawJson; final Value rowid; - const NotificationScheduleCompanion({ - this.id = const Value.absent(), - this.accountId = const Value.absent(), - this.sourceType = const Value.absent(), - this.sourceId = const Value.absent(), - this.scheduledAtUtc = const Value.absent(), - this.title = const Value.absent(), - this.body = const Value.absent(), - this.sentAtUtc = const Value.absent(), - this.dismissedAtUtc = const Value.absent(), - this.snoozedUntilUtc = const Value.absent(), - this.createdAtLocal = const Value.absent(), - this.updatedAtLocal = const Value.absent(), + const CalendarColorsCompanion({ + this.provider = const Value.absent(), + this.colorType = const Value.absent(), + this.colorId = const Value.absent(), + this.background = const Value.absent(), + this.foreground = const Value.absent(), + this.rawJson = const Value.absent(), this.rowid = const Value.absent(), }); - NotificationScheduleCompanion.insert({ - required String id, - required String accountId, - required String sourceType, - required String sourceId, - required int scheduledAtUtc, - required String title, - this.body = const Value.absent(), - this.sentAtUtc = const Value.absent(), - this.dismissedAtUtc = const Value.absent(), - this.snoozedUntilUtc = const Value.absent(), - required int createdAtLocal, - required int updatedAtLocal, + CalendarColorsCompanion.insert({ + required String provider, + required String colorType, + required String colorId, + required String background, + this.foreground = const Value.absent(), + this.rawJson = const Value.absent(), this.rowid = const Value.absent(), - }) : id = Value(id), - accountId = Value(accountId), - sourceType = Value(sourceType), - sourceId = Value(sourceId), - scheduledAtUtc = Value(scheduledAtUtc), - title = Value(title), - createdAtLocal = Value(createdAtLocal), - updatedAtLocal = Value(updatedAtLocal); - static Insertable custom({ - Expression? id, - Expression? accountId, - Expression? sourceType, - Expression? sourceId, - Expression? scheduledAtUtc, - Expression? title, - Expression? body, - Expression? sentAtUtc, - Expression? dismissedAtUtc, - Expression? snoozedUntilUtc, - Expression? createdAtLocal, - Expression? updatedAtLocal, + }) : provider = Value(provider), + colorType = Value(colorType), + colorId = Value(colorId), + background = Value(background); + static Insertable custom({ + Expression? provider, + Expression? colorType, + Expression? colorId, + Expression? background, + Expression? foreground, + Expression? rawJson, Expression? rowid, }) { return RawValuesInsertable({ - if (id != null) 'id': id, - if (accountId != null) 'account_id': accountId, - if (sourceType != null) 'source_type': sourceType, - if (sourceId != null) 'source_id': sourceId, - if (scheduledAtUtc != null) 'scheduled_at_utc': scheduledAtUtc, - if (title != null) 'title': title, - if (body != null) 'body': body, - if (sentAtUtc != null) 'sent_at_utc': sentAtUtc, - if (dismissedAtUtc != null) 'dismissed_at_utc': dismissedAtUtc, - if (snoozedUntilUtc != null) 'snoozed_until_utc': snoozedUntilUtc, - if (createdAtLocal != null) 'created_at_local': createdAtLocal, - if (updatedAtLocal != null) 'updated_at_local': updatedAtLocal, + if (provider != null) 'provider': provider, + if (colorType != null) 'color_type': colorType, + if (colorId != null) 'color_id': colorId, + if (background != null) 'background': background, + if (foreground != null) 'foreground': foreground, + if (rawJson != null) 'raw_json': rawJson, if (rowid != null) 'rowid': rowid, }); } - NotificationScheduleCompanion copyWith({ - Value? id, - Value? accountId, - Value? sourceType, - Value? sourceId, - Value? scheduledAtUtc, - Value? title, - Value? body, - Value? sentAtUtc, - Value? dismissedAtUtc, - Value? snoozedUntilUtc, - Value? createdAtLocal, - Value? updatedAtLocal, + CalendarColorsCompanion copyWith({ + Value? provider, + Value? colorType, + Value? colorId, + Value? background, + Value? foreground, + Value? rawJson, Value? rowid, }) { - return NotificationScheduleCompanion( - id: id ?? this.id, - accountId: accountId ?? this.accountId, - sourceType: sourceType ?? this.sourceType, - sourceId: sourceId ?? this.sourceId, - scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, - title: title ?? this.title, - body: body ?? this.body, - sentAtUtc: sentAtUtc ?? this.sentAtUtc, - dismissedAtUtc: dismissedAtUtc ?? this.dismissedAtUtc, - snoozedUntilUtc: snoozedUntilUtc ?? this.snoozedUntilUtc, - createdAtLocal: createdAtLocal ?? this.createdAtLocal, - updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + return CalendarColorsCompanion( + provider: provider ?? this.provider, + colorType: colorType ?? this.colorType, + colorId: colorId ?? this.colorId, + background: background ?? this.background, + foreground: foreground ?? this.foreground, + rawJson: rawJson ?? this.rawJson, rowid: rowid ?? this.rowid, ); } @@ -22683,41 +23375,23 @@ class NotificationScheduleCompanion @override Map toColumns(bool nullToAbsent) { final map = {}; - if (id.present) { - map['id'] = Variable(id.value); + if (provider.present) { + map['provider'] = Variable(provider.value); } - if (accountId.present) { - map['account_id'] = Variable(accountId.value); + if (colorType.present) { + map['color_type'] = Variable(colorType.value); } - if (sourceType.present) { - map['source_type'] = Variable(sourceType.value); + if (colorId.present) { + map['color_id'] = Variable(colorId.value); } - if (sourceId.present) { - map['source_id'] = Variable(sourceId.value); - } - if (scheduledAtUtc.present) { - map['scheduled_at_utc'] = Variable(scheduledAtUtc.value); - } - if (title.present) { - map['title'] = Variable(title.value); - } - if (body.present) { - map['body'] = Variable(body.value); - } - if (sentAtUtc.present) { - map['sent_at_utc'] = Variable(sentAtUtc.value); - } - if (dismissedAtUtc.present) { - map['dismissed_at_utc'] = Variable(dismissedAtUtc.value); - } - if (snoozedUntilUtc.present) { - map['snoozed_until_utc'] = Variable(snoozedUntilUtc.value); + if (background.present) { + map['background'] = Variable(background.value); } - if (createdAtLocal.present) { - map['created_at_local'] = Variable(createdAtLocal.value); + if (foreground.present) { + map['foreground'] = Variable(foreground.value); } - if (updatedAtLocal.present) { - map['updated_at_local'] = Variable(updatedAtLocal.value); + if (rawJson.present) { + map['raw_json'] = Variable(rawJson.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -22727,147 +23401,1376 @@ class NotificationScheduleCompanion @override String toString() { - return (StringBuffer('NotificationScheduleCompanion(') - ..write('id: $id, ') - ..write('accountId: $accountId, ') - ..write('sourceType: $sourceType, ') - ..write('sourceId: $sourceId, ') - ..write('scheduledAtUtc: $scheduledAtUtc, ') - ..write('title: $title, ') - ..write('body: $body, ') - ..write('sentAtUtc: $sentAtUtc, ') - ..write('dismissedAtUtc: $dismissedAtUtc, ') - ..write('snoozedUntilUtc: $snoozedUntilUtc, ') - ..write('createdAtLocal: $createdAtLocal, ') - ..write('updatedAtLocal: $updatedAtLocal, ') + return (StringBuffer('CalendarColorsCompanion(') + ..write('provider: $provider, ') + ..write('colorType: $colorType, ') + ..write('colorId: $colorId, ') + ..write('background: $background, ') + ..write('foreground: $foreground, ') + ..write('rawJson: $rawJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -abstract class _$AppDatabase extends GeneratedDatabase { - _$AppDatabase(QueryExecutor e) : super(e); - $AppDatabaseManager get managers => $AppDatabaseManager(this); - late final $AccountsTable accounts = $AccountsTable(this); - late final $DavAccountServicesTable davAccountServices = - $DavAccountServicesTable(this); - late final $DavCollectionsTable davCollections = $DavCollectionsTable(this); - late final $DavObjectsTable davObjects = $DavObjectsTable(this); - late final $DavObjectComponentsTable davObjectComponents = - $DavObjectComponentsTable(this); - late final $DavConflictSnapshotsTable davConflictSnapshots = - $DavConflictSnapshotsTable(this); - late final $TaskListsTable taskLists = $TaskListsTable(this); - late final $TasksTable tasks = $TasksTable(this); - late final $PendingOpsTable pendingOps = $PendingOpsTable(this); - late final $SyncRunsTable syncRuns = $SyncRunsTable(this); - late final $CalendarSourcesTable calendarSources = $CalendarSourcesTable( - this, +class $ScheduleItemOverridesTable extends ScheduleItemOverrides + with TableInfo<$ScheduleItemOverridesTable, ScheduleItemOverride> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ScheduleItemOverridesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', ); - late final $CalendarEventsTable calendarEvents = $CalendarEventsTable(this); - late final $CalendarEventAttendeesTable calendarEventAttendees = - $CalendarEventAttendeesTable(this); - late final $CalendarEventRemindersTable calendarEventReminders = - $CalendarEventRemindersTable(this); - late final $SyncCursorsTable syncCursors = $SyncCursorsTable(this); - late final $CalendarColorsTable calendarColors = $CalendarColorsTable(this); - late final $ScheduleItemOverridesTable scheduleItemOverrides = - $ScheduleItemOverridesTable(this); - late final $NotificationScheduleTable notificationSchedule = - $NotificationScheduleTable(this); - late final TaskListsDao taskListsDao = TaskListsDao(this as AppDatabase); - late final TasksDao tasksDao = TasksDao(this as AppDatabase); - late final PendingOpsDao pendingOpsDao = PendingOpsDao(this as AppDatabase); - late final SyncRunsDao syncRunsDao = SyncRunsDao(this as AppDatabase); @override - Iterable> get allTables => - allSchemaEntities.whereType>(); + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES accounts (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _sourceTypeMeta = const VerificationMeta( + 'sourceType', + ); @override - List get allSchemaEntities => [ - accounts, - davAccountServices, - davCollections, - davObjects, - davObjectComponents, - davConflictSnapshots, - taskLists, - tasks, - pendingOps, - syncRuns, - calendarSources, - calendarEvents, - calendarEventAttendees, - calendarEventReminders, - syncCursors, - calendarColors, - scheduleItemOverrides, - notificationSchedule, + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceIdMeta = const VerificationMeta( + 'sourceId', + ); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _overrideJsonMeta = const VerificationMeta( + 'overrideJson', + ); + @override + late final GeneratedColumn overrideJson = GeneratedColumn( + 'override_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( + 'createdAtLocal', + ); + @override + late final GeneratedColumn createdAtLocal = GeneratedColumn( + 'created_at_local', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtLocalMeta = const VerificationMeta( + 'updatedAtLocal', + ); + @override + late final GeneratedColumn updatedAtLocal = GeneratedColumn( + 'updated_at_local', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + accountId, + sourceType, + sourceId, + overrideJson, + createdAtLocal, + updatedAtLocal, ]; @override - StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_account_services', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_collections', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_object_components', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('task_lists', kind: UpdateKind.delete)], + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'schedule_item_overrides'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('account_id')) { + context.handle( + _accountIdMeta, + accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + ); + } else if (isInserting) { + context.missing(_accountIdMeta); + } + if (data.containsKey('source_type')) { + context.handle( + _sourceTypeMeta, + sourceType.isAcceptableOrUnknown(data['source_type']!, _sourceTypeMeta), + ); + } else if (isInserting) { + context.missing(_sourceTypeMeta); + } + if (data.containsKey('source_id')) { + context.handle( + _sourceIdMeta, + sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + ); + } else if (isInserting) { + context.missing(_sourceIdMeta); + } + if (data.containsKey('override_json')) { + context.handle( + _overrideJsonMeta, + overrideJson.isAcceptableOrUnknown( + data['override_json']!, + _overrideJsonMeta, + ), + ); + } else if (isInserting) { + context.missing(_overrideJsonMeta); + } + if (data.containsKey('created_at_local')) { + context.handle( + _createdAtLocalMeta, + createdAtLocal.isAcceptableOrUnknown( + data['created_at_local']!, + _createdAtLocalMeta, + ), + ); + } else if (isInserting) { + context.missing(_createdAtLocalMeta); + } + if (data.containsKey('updated_at_local')) { + context.handle( + _updatedAtLocalMeta, + updatedAtLocal.isAcceptableOrUnknown( + data['updated_at_local']!, + _updatedAtLocalMeta, + ), + ); + } else if (isInserting) { + context.missing(_updatedAtLocalMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ScheduleItemOverride map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ScheduleItemOverride( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + accountId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_id'], + )!, + sourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_type'], + )!, + sourceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_id'], + )!, + overrideJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}override_json'], + )!, + createdAtLocal: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at_local'], + )!, + updatedAtLocal: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at_local'], + )!, + ); + } + + @override + $ScheduleItemOverridesTable createAlias(String alias) { + return $ScheduleItemOverridesTable(attachedDatabase, alias); + } +} + +class ScheduleItemOverride extends DataClass + implements Insertable { + final String id; + final String accountId; + final String sourceType; + final String sourceId; + final String overrideJson; + final int createdAtLocal; + final int updatedAtLocal; + const ScheduleItemOverride({ + required this.id, + required this.accountId, + required this.sourceType, + required this.sourceId, + required this.overrideJson, + required this.createdAtLocal, + required this.updatedAtLocal, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_id'] = Variable(accountId); + map['source_type'] = Variable(sourceType); + map['source_id'] = Variable(sourceId); + map['override_json'] = Variable(overrideJson); + map['created_at_local'] = Variable(createdAtLocal); + map['updated_at_local'] = Variable(updatedAtLocal); + return map; + } + + ScheduleItemOverridesCompanion toCompanion(bool nullToAbsent) { + return ScheduleItemOverridesCompanion( + id: Value(id), + accountId: Value(accountId), + sourceType: Value(sourceType), + sourceId: Value(sourceId), + overrideJson: Value(overrideJson), + createdAtLocal: Value(createdAtLocal), + updatedAtLocal: Value(updatedAtLocal), + ); + } + + factory ScheduleItemOverride.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ScheduleItemOverride( + id: serializer.fromJson(json['id']), + accountId: serializer.fromJson(json['accountId']), + sourceType: serializer.fromJson(json['sourceType']), + sourceId: serializer.fromJson(json['sourceId']), + overrideJson: serializer.fromJson(json['overrideJson']), + createdAtLocal: serializer.fromJson(json['createdAtLocal']), + updatedAtLocal: serializer.fromJson(json['updatedAtLocal']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountId': serializer.toJson(accountId), + 'sourceType': serializer.toJson(sourceType), + 'sourceId': serializer.toJson(sourceId), + 'overrideJson': serializer.toJson(overrideJson), + 'createdAtLocal': serializer.toJson(createdAtLocal), + 'updatedAtLocal': serializer.toJson(updatedAtLocal), + }; + } + + ScheduleItemOverride copyWith({ + String? id, + String? accountId, + String? sourceType, + String? sourceId, + String? overrideJson, + int? createdAtLocal, + int? updatedAtLocal, + }) => ScheduleItemOverride( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + sourceType: sourceType ?? this.sourceType, + sourceId: sourceId ?? this.sourceId, + overrideJson: overrideJson ?? this.overrideJson, + createdAtLocal: createdAtLocal ?? this.createdAtLocal, + updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + ); + ScheduleItemOverride copyWithCompanion(ScheduleItemOverridesCompanion data) { + return ScheduleItemOverride( + id: data.id.present ? data.id.value : this.id, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + sourceType: data.sourceType.present + ? data.sourceType.value + : this.sourceType, + sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, + overrideJson: data.overrideJson.present + ? data.overrideJson.value + : this.overrideJson, + createdAtLocal: data.createdAtLocal.present + ? data.createdAtLocal.value + : this.createdAtLocal, + updatedAtLocal: data.updatedAtLocal.present + ? data.updatedAtLocal.value + : this.updatedAtLocal, + ); + } + + @override + String toString() { + return (StringBuffer('ScheduleItemOverride(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('sourceType: $sourceType, ') + ..write('sourceId: $sourceId, ') + ..write('overrideJson: $overrideJson, ') + ..write('createdAtLocal: $createdAtLocal, ') + ..write('updatedAtLocal: $updatedAtLocal') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + accountId, + sourceType, + sourceId, + overrideJson, + createdAtLocal, + updatedAtLocal, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ScheduleItemOverride && + other.id == this.id && + other.accountId == this.accountId && + other.sourceType == this.sourceType && + other.sourceId == this.sourceId && + other.overrideJson == this.overrideJson && + other.createdAtLocal == this.createdAtLocal && + other.updatedAtLocal == this.updatedAtLocal); +} + +class ScheduleItemOverridesCompanion + extends UpdateCompanion { + final Value id; + final Value accountId; + final Value sourceType; + final Value sourceId; + final Value overrideJson; + final Value createdAtLocal; + final Value updatedAtLocal; + final Value rowid; + const ScheduleItemOverridesCompanion({ + this.id = const Value.absent(), + this.accountId = const Value.absent(), + this.sourceType = const Value.absent(), + this.sourceId = const Value.absent(), + this.overrideJson = const Value.absent(), + this.createdAtLocal = const Value.absent(), + this.updatedAtLocal = const Value.absent(), + this.rowid = const Value.absent(), + }); + ScheduleItemOverridesCompanion.insert({ + required String id, + required String accountId, + required String sourceType, + required String sourceId, + required String overrideJson, + required int createdAtLocal, + required int updatedAtLocal, + this.rowid = const Value.absent(), + }) : id = Value(id), + accountId = Value(accountId), + sourceType = Value(sourceType), + sourceId = Value(sourceId), + overrideJson = Value(overrideJson), + createdAtLocal = Value(createdAtLocal), + updatedAtLocal = Value(updatedAtLocal); + static Insertable custom({ + Expression? id, + Expression? accountId, + Expression? sourceType, + Expression? sourceId, + Expression? overrideJson, + Expression? createdAtLocal, + Expression? updatedAtLocal, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountId != null) 'account_id': accountId, + if (sourceType != null) 'source_type': sourceType, + if (sourceId != null) 'source_id': sourceId, + if (overrideJson != null) 'override_json': overrideJson, + if (createdAtLocal != null) 'created_at_local': createdAtLocal, + if (updatedAtLocal != null) 'updated_at_local': updatedAtLocal, + if (rowid != null) 'rowid': rowid, + }); + } + + ScheduleItemOverridesCompanion copyWith({ + Value? id, + Value? accountId, + Value? sourceType, + Value? sourceId, + Value? overrideJson, + Value? createdAtLocal, + Value? updatedAtLocal, + Value? rowid, + }) { + return ScheduleItemOverridesCompanion( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + sourceType: sourceType ?? this.sourceType, + sourceId: sourceId ?? this.sourceId, + overrideJson: overrideJson ?? this.overrideJson, + createdAtLocal: createdAtLocal ?? this.createdAtLocal, + updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } + if (sourceType.present) { + map['source_type'] = Variable(sourceType.value); + } + if (sourceId.present) { + map['source_id'] = Variable(sourceId.value); + } + if (overrideJson.present) { + map['override_json'] = Variable(overrideJson.value); + } + if (createdAtLocal.present) { + map['created_at_local'] = Variable(createdAtLocal.value); + } + if (updatedAtLocal.present) { + map['updated_at_local'] = Variable(updatedAtLocal.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ScheduleItemOverridesCompanion(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('sourceType: $sourceType, ') + ..write('sourceId: $sourceId, ') + ..write('overrideJson: $overrideJson, ') + ..write('createdAtLocal: $createdAtLocal, ') + ..write('updatedAtLocal: $updatedAtLocal, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $NotificationScheduleTable extends NotificationSchedule + with TableInfo<$NotificationScheduleTable, NotificationScheduleData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $NotificationScheduleTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', + ); + @override + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES accounts (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _sourceTypeMeta = const VerificationMeta( + 'sourceType', + ); + @override + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceIdMeta = const VerificationMeta( + 'sourceId', + ); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scheduledAtUtcMeta = const VerificationMeta( + 'scheduledAtUtc', + ); + @override + late final GeneratedColumn scheduledAtUtc = GeneratedColumn( + 'scheduled_at_utc', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + @override + late final GeneratedColumn body = GeneratedColumn( + 'body', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _sentAtUtcMeta = const VerificationMeta( + 'sentAtUtc', + ); + @override + late final GeneratedColumn sentAtUtc = GeneratedColumn( + 'sent_at_utc', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _dismissedAtUtcMeta = const VerificationMeta( + 'dismissedAtUtc', + ); + @override + late final GeneratedColumn dismissedAtUtc = GeneratedColumn( + 'dismissed_at_utc', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _snoozedUntilUtcMeta = const VerificationMeta( + 'snoozedUntilUtc', + ); + @override + late final GeneratedColumn snoozedUntilUtc = GeneratedColumn( + 'snoozed_until_utc', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtLocalMeta = const VerificationMeta( + 'createdAtLocal', + ); + @override + late final GeneratedColumn createdAtLocal = GeneratedColumn( + 'created_at_local', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtLocalMeta = const VerificationMeta( + 'updatedAtLocal', + ); + @override + late final GeneratedColumn updatedAtLocal = GeneratedColumn( + 'updated_at_local', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + accountId, + sourceType, + sourceId, + scheduledAtUtc, + title, + body, + sentAtUtc, + dismissedAtUtc, + snoozedUntilUtc, + createdAtLocal, + updatedAtLocal, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'notification_schedule'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('account_id')) { + context.handle( + _accountIdMeta, + accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + ); + } else if (isInserting) { + context.missing(_accountIdMeta); + } + if (data.containsKey('source_type')) { + context.handle( + _sourceTypeMeta, + sourceType.isAcceptableOrUnknown(data['source_type']!, _sourceTypeMeta), + ); + } else if (isInserting) { + context.missing(_sourceTypeMeta); + } + if (data.containsKey('source_id')) { + context.handle( + _sourceIdMeta, + sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + ); + } else if (isInserting) { + context.missing(_sourceIdMeta); + } + if (data.containsKey('scheduled_at_utc')) { + context.handle( + _scheduledAtUtcMeta, + scheduledAtUtc.isAcceptableOrUnknown( + data['scheduled_at_utc']!, + _scheduledAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_scheduledAtUtcMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, + body.isAcceptableOrUnknown(data['body']!, _bodyMeta), + ); + } + if (data.containsKey('sent_at_utc')) { + context.handle( + _sentAtUtcMeta, + sentAtUtc.isAcceptableOrUnknown(data['sent_at_utc']!, _sentAtUtcMeta), + ); + } + if (data.containsKey('dismissed_at_utc')) { + context.handle( + _dismissedAtUtcMeta, + dismissedAtUtc.isAcceptableOrUnknown( + data['dismissed_at_utc']!, + _dismissedAtUtcMeta, + ), + ); + } + if (data.containsKey('snoozed_until_utc')) { + context.handle( + _snoozedUntilUtcMeta, + snoozedUntilUtc.isAcceptableOrUnknown( + data['snoozed_until_utc']!, + _snoozedUntilUtcMeta, + ), + ); + } + if (data.containsKey('created_at_local')) { + context.handle( + _createdAtLocalMeta, + createdAtLocal.isAcceptableOrUnknown( + data['created_at_local']!, + _createdAtLocalMeta, + ), + ); + } else if (isInserting) { + context.missing(_createdAtLocalMeta); + } + if (data.containsKey('updated_at_local')) { + context.handle( + _updatedAtLocalMeta, + updatedAtLocal.isAcceptableOrUnknown( + data['updated_at_local']!, + _updatedAtLocalMeta, + ), + ); + } else if (isInserting) { + context.missing(_updatedAtLocalMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + NotificationScheduleData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return NotificationScheduleData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + accountId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_id'], + )!, + sourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_type'], + )!, + sourceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_id'], + )!, + scheduledAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}scheduled_at_utc'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + body: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}body'], + ), + sentAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sent_at_utc'], + ), + dismissedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}dismissed_at_utc'], + ), + snoozedUntilUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}snoozed_until_utc'], + ), + createdAtLocal: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at_local'], + )!, + updatedAtLocal: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at_local'], + )!, + ); + } + + @override + $NotificationScheduleTable createAlias(String alias) { + return $NotificationScheduleTable(attachedDatabase, alias); + } +} + +class NotificationScheduleData extends DataClass + implements Insertable { + final String id; + final String accountId; + final String sourceType; + final String sourceId; + final int scheduledAtUtc; + final String title; + final String? body; + final int? sentAtUtc; + final int? dismissedAtUtc; + final int? snoozedUntilUtc; + final int createdAtLocal; + final int updatedAtLocal; + const NotificationScheduleData({ + required this.id, + required this.accountId, + required this.sourceType, + required this.sourceId, + required this.scheduledAtUtc, + required this.title, + this.body, + this.sentAtUtc, + this.dismissedAtUtc, + this.snoozedUntilUtc, + required this.createdAtLocal, + required this.updatedAtLocal, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_id'] = Variable(accountId); + map['source_type'] = Variable(sourceType); + map['source_id'] = Variable(sourceId); + map['scheduled_at_utc'] = Variable(scheduledAtUtc); + map['title'] = Variable(title); + if (!nullToAbsent || body != null) { + map['body'] = Variable(body); + } + if (!nullToAbsent || sentAtUtc != null) { + map['sent_at_utc'] = Variable(sentAtUtc); + } + if (!nullToAbsent || dismissedAtUtc != null) { + map['dismissed_at_utc'] = Variable(dismissedAtUtc); + } + if (!nullToAbsent || snoozedUntilUtc != null) { + map['snoozed_until_utc'] = Variable(snoozedUntilUtc); + } + map['created_at_local'] = Variable(createdAtLocal); + map['updated_at_local'] = Variable(updatedAtLocal); + return map; + } + + NotificationScheduleCompanion toCompanion(bool nullToAbsent) { + return NotificationScheduleCompanion( + id: Value(id), + accountId: Value(accountId), + sourceType: Value(sourceType), + sourceId: Value(sourceId), + scheduledAtUtc: Value(scheduledAtUtc), + title: Value(title), + body: body == null && nullToAbsent ? const Value.absent() : Value(body), + sentAtUtc: sentAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(sentAtUtc), + dismissedAtUtc: dismissedAtUtc == null && nullToAbsent + ? const Value.absent() + : Value(dismissedAtUtc), + snoozedUntilUtc: snoozedUntilUtc == null && nullToAbsent + ? const Value.absent() + : Value(snoozedUntilUtc), + createdAtLocal: Value(createdAtLocal), + updatedAtLocal: Value(updatedAtLocal), + ); + } + + factory NotificationScheduleData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return NotificationScheduleData( + id: serializer.fromJson(json['id']), + accountId: serializer.fromJson(json['accountId']), + sourceType: serializer.fromJson(json['sourceType']), + sourceId: serializer.fromJson(json['sourceId']), + scheduledAtUtc: serializer.fromJson(json['scheduledAtUtc']), + title: serializer.fromJson(json['title']), + body: serializer.fromJson(json['body']), + sentAtUtc: serializer.fromJson(json['sentAtUtc']), + dismissedAtUtc: serializer.fromJson(json['dismissedAtUtc']), + snoozedUntilUtc: serializer.fromJson(json['snoozedUntilUtc']), + createdAtLocal: serializer.fromJson(json['createdAtLocal']), + updatedAtLocal: serializer.fromJson(json['updatedAtLocal']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountId': serializer.toJson(accountId), + 'sourceType': serializer.toJson(sourceType), + 'sourceId': serializer.toJson(sourceId), + 'scheduledAtUtc': serializer.toJson(scheduledAtUtc), + 'title': serializer.toJson(title), + 'body': serializer.toJson(body), + 'sentAtUtc': serializer.toJson(sentAtUtc), + 'dismissedAtUtc': serializer.toJson(dismissedAtUtc), + 'snoozedUntilUtc': serializer.toJson(snoozedUntilUtc), + 'createdAtLocal': serializer.toJson(createdAtLocal), + 'updatedAtLocal': serializer.toJson(updatedAtLocal), + }; + } + + NotificationScheduleData copyWith({ + String? id, + String? accountId, + String? sourceType, + String? sourceId, + int? scheduledAtUtc, + String? title, + Value body = const Value.absent(), + Value sentAtUtc = const Value.absent(), + Value dismissedAtUtc = const Value.absent(), + Value snoozedUntilUtc = const Value.absent(), + int? createdAtLocal, + int? updatedAtLocal, + }) => NotificationScheduleData( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + sourceType: sourceType ?? this.sourceType, + sourceId: sourceId ?? this.sourceId, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + title: title ?? this.title, + body: body.present ? body.value : this.body, + sentAtUtc: sentAtUtc.present ? sentAtUtc.value : this.sentAtUtc, + dismissedAtUtc: dismissedAtUtc.present + ? dismissedAtUtc.value + : this.dismissedAtUtc, + snoozedUntilUtc: snoozedUntilUtc.present + ? snoozedUntilUtc.value + : this.snoozedUntilUtc, + createdAtLocal: createdAtLocal ?? this.createdAtLocal, + updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + ); + NotificationScheduleData copyWithCompanion( + NotificationScheduleCompanion data, + ) { + return NotificationScheduleData( + id: data.id.present ? data.id.value : this.id, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + sourceType: data.sourceType.present + ? data.sourceType.value + : this.sourceType, + sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, + scheduledAtUtc: data.scheduledAtUtc.present + ? data.scheduledAtUtc.value + : this.scheduledAtUtc, + title: data.title.present ? data.title.value : this.title, + body: data.body.present ? data.body.value : this.body, + sentAtUtc: data.sentAtUtc.present ? data.sentAtUtc.value : this.sentAtUtc, + dismissedAtUtc: data.dismissedAtUtc.present + ? data.dismissedAtUtc.value + : this.dismissedAtUtc, + snoozedUntilUtc: data.snoozedUntilUtc.present + ? data.snoozedUntilUtc.value + : this.snoozedUntilUtc, + createdAtLocal: data.createdAtLocal.present + ? data.createdAtLocal.value + : this.createdAtLocal, + updatedAtLocal: data.updatedAtLocal.present + ? data.updatedAtLocal.value + : this.updatedAtLocal, + ); + } + + @override + String toString() { + return (StringBuffer('NotificationScheduleData(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('sourceType: $sourceType, ') + ..write('sourceId: $sourceId, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('sentAtUtc: $sentAtUtc, ') + ..write('dismissedAtUtc: $dismissedAtUtc, ') + ..write('snoozedUntilUtc: $snoozedUntilUtc, ') + ..write('createdAtLocal: $createdAtLocal, ') + ..write('updatedAtLocal: $updatedAtLocal') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + accountId, + sourceType, + sourceId, + scheduledAtUtc, + title, + body, + sentAtUtc, + dismissedAtUtc, + snoozedUntilUtc, + createdAtLocal, + updatedAtLocal, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is NotificationScheduleData && + other.id == this.id && + other.accountId == this.accountId && + other.sourceType == this.sourceType && + other.sourceId == this.sourceId && + other.scheduledAtUtc == this.scheduledAtUtc && + other.title == this.title && + other.body == this.body && + other.sentAtUtc == this.sentAtUtc && + other.dismissedAtUtc == this.dismissedAtUtc && + other.snoozedUntilUtc == this.snoozedUntilUtc && + other.createdAtLocal == this.createdAtLocal && + other.updatedAtLocal == this.updatedAtLocal); +} + +class NotificationScheduleCompanion + extends UpdateCompanion { + final Value id; + final Value accountId; + final Value sourceType; + final Value sourceId; + final Value scheduledAtUtc; + final Value title; + final Value body; + final Value sentAtUtc; + final Value dismissedAtUtc; + final Value snoozedUntilUtc; + final Value createdAtLocal; + final Value updatedAtLocal; + final Value rowid; + const NotificationScheduleCompanion({ + this.id = const Value.absent(), + this.accountId = const Value.absent(), + this.sourceType = const Value.absent(), + this.sourceId = const Value.absent(), + this.scheduledAtUtc = const Value.absent(), + this.title = const Value.absent(), + this.body = const Value.absent(), + this.sentAtUtc = const Value.absent(), + this.dismissedAtUtc = const Value.absent(), + this.snoozedUntilUtc = const Value.absent(), + this.createdAtLocal = const Value.absent(), + this.updatedAtLocal = const Value.absent(), + this.rowid = const Value.absent(), + }); + NotificationScheduleCompanion.insert({ + required String id, + required String accountId, + required String sourceType, + required String sourceId, + required int scheduledAtUtc, + required String title, + this.body = const Value.absent(), + this.sentAtUtc = const Value.absent(), + this.dismissedAtUtc = const Value.absent(), + this.snoozedUntilUtc = const Value.absent(), + required int createdAtLocal, + required int updatedAtLocal, + this.rowid = const Value.absent(), + }) : id = Value(id), + accountId = Value(accountId), + sourceType = Value(sourceType), + sourceId = Value(sourceId), + scheduledAtUtc = Value(scheduledAtUtc), + title = Value(title), + createdAtLocal = Value(createdAtLocal), + updatedAtLocal = Value(updatedAtLocal); + static Insertable custom({ + Expression? id, + Expression? accountId, + Expression? sourceType, + Expression? sourceId, + Expression? scheduledAtUtc, + Expression? title, + Expression? body, + Expression? sentAtUtc, + Expression? dismissedAtUtc, + Expression? snoozedUntilUtc, + Expression? createdAtLocal, + Expression? updatedAtLocal, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountId != null) 'account_id': accountId, + if (sourceType != null) 'source_type': sourceType, + if (sourceId != null) 'source_id': sourceId, + if (scheduledAtUtc != null) 'scheduled_at_utc': scheduledAtUtc, + if (title != null) 'title': title, + if (body != null) 'body': body, + if (sentAtUtc != null) 'sent_at_utc': sentAtUtc, + if (dismissedAtUtc != null) 'dismissed_at_utc': dismissedAtUtc, + if (snoozedUntilUtc != null) 'snoozed_until_utc': snoozedUntilUtc, + if (createdAtLocal != null) 'created_at_local': createdAtLocal, + if (updatedAtLocal != null) 'updated_at_local': updatedAtLocal, + if (rowid != null) 'rowid': rowid, + }); + } + + NotificationScheduleCompanion copyWith({ + Value? id, + Value? accountId, + Value? sourceType, + Value? sourceId, + Value? scheduledAtUtc, + Value? title, + Value? body, + Value? sentAtUtc, + Value? dismissedAtUtc, + Value? snoozedUntilUtc, + Value? createdAtLocal, + Value? updatedAtLocal, + Value? rowid, + }) { + return NotificationScheduleCompanion( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + sourceType: sourceType ?? this.sourceType, + sourceId: sourceId ?? this.sourceId, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + title: title ?? this.title, + body: body ?? this.body, + sentAtUtc: sentAtUtc ?? this.sentAtUtc, + dismissedAtUtc: dismissedAtUtc ?? this.dismissedAtUtc, + snoozedUntilUtc: snoozedUntilUtc ?? this.snoozedUntilUtc, + createdAtLocal: createdAtLocal ?? this.createdAtLocal, + updatedAtLocal: updatedAtLocal ?? this.updatedAtLocal, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } + if (sourceType.present) { + map['source_type'] = Variable(sourceType.value); + } + if (sourceId.present) { + map['source_id'] = Variable(sourceId.value); + } + if (scheduledAtUtc.present) { + map['scheduled_at_utc'] = Variable(scheduledAtUtc.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + if (sentAtUtc.present) { + map['sent_at_utc'] = Variable(sentAtUtc.value); + } + if (dismissedAtUtc.present) { + map['dismissed_at_utc'] = Variable(dismissedAtUtc.value); + } + if (snoozedUntilUtc.present) { + map['snoozed_until_utc'] = Variable(snoozedUntilUtc.value); + } + if (createdAtLocal.present) { + map['created_at_local'] = Variable(createdAtLocal.value); + } + if (updatedAtLocal.present) { + map['updated_at_local'] = Variable(updatedAtLocal.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('NotificationScheduleCompanion(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('sourceType: $sourceType, ') + ..write('sourceId: $sourceId, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('sentAtUtc: $sentAtUtc, ') + ..write('dismissedAtUtc: $dismissedAtUtc, ') + ..write('snoozedUntilUtc: $snoozedUntilUtc, ') + ..write('createdAtLocal: $createdAtLocal, ') + ..write('updatedAtLocal: $updatedAtLocal, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $AccountsTable accounts = $AccountsTable(this); + late final $DavAccountServicesTable davAccountServices = + $DavAccountServicesTable(this); + late final $DavCollectionsTable davCollections = $DavCollectionsTable(this); + late final $DavObjectsTable davObjects = $DavObjectsTable(this); + late final $DavObjectComponentsTable davObjectComponents = + $DavObjectComponentsTable(this); + late final $DavConflictSnapshotsTable davConflictSnapshots = + $DavConflictSnapshotsTable(this); + late final $TaskListsTable taskLists = $TaskListsTable(this); + late final $TasksTable tasks = $TasksTable(this); + late final $PendingOpsTable pendingOps = $PendingOpsTable(this); + late final $SyncRunsTable syncRuns = $SyncRunsTable(this); + late final $CalendarSourcesTable calendarSources = $CalendarSourcesTable( + this, + ); + late final $CalendarEventsTable calendarEvents = $CalendarEventsTable(this); + late final $IcalImportReceiptsTable icalImportReceipts = + $IcalImportReceiptsTable(this); + late final $WebCalSubscriptionsTable webCalSubscriptions = + $WebCalSubscriptionsTable(this); + late final $CalendarEventAttendeesTable calendarEventAttendees = + $CalendarEventAttendeesTable(this); + late final $CalendarEventRemindersTable calendarEventReminders = + $CalendarEventRemindersTable(this); + late final $SyncCursorsTable syncCursors = $SyncCursorsTable(this); + late final $CalendarColorsTable calendarColors = $CalendarColorsTable(this); + late final $ScheduleItemOverridesTable scheduleItemOverrides = + $ScheduleItemOverridesTable(this); + late final $NotificationScheduleTable notificationSchedule = + $NotificationScheduleTable(this); + late final TaskListsDao taskListsDao = TaskListsDao(this as AppDatabase); + late final TasksDao tasksDao = TasksDao(this as AppDatabase); + late final PendingOpsDao pendingOpsDao = PendingOpsDao(this as AppDatabase); + late final SyncRunsDao syncRunsDao = SyncRunsDao(this as AppDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + accounts, + davAccountServices, + davCollections, + davObjects, + davObjectComponents, + davConflictSnapshots, + taskLists, + tasks, + pendingOps, + syncRuns, + calendarSources, + calendarEvents, + icalImportReceipts, + webCalSubscriptions, + calendarEventAttendees, + calendarEventReminders, + syncCursors, + calendarColors, + scheduleItemOverrides, + notificationSchedule, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_account_services', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_collections', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_object_components', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('task_lists', kind: UpdateKind.delete)], ), WritePropagation( on: TableUpdateQuery.onTableName( @@ -22995,6 +24898,27 @@ abstract class _$AppDatabase extends GeneratedDatabase { ), result: [TableUpdate('calendar_events', kind: UpdateKind.update)], ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_sources', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('ical_import_receipts', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('web_cal_subscriptions', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'calendar_sources', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('web_cal_subscriptions', kind: UpdateKind.delete)], + ), WritePropagation( on: TableUpdateQuery.onTableName( 'calendar_events', @@ -23311,6 +25235,33 @@ final class $$AccountsTableReferences ); } + static MultiTypedResultKey< + $WebCalSubscriptionsTable, + List + > + _webCalSubscriptionsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.webCalSubscriptions, + aliasName: $_aliasNameGenerator( + db.accounts.id, + db.webCalSubscriptions.accountId, + ), + ); + + $$WebCalSubscriptionsTableProcessedTableManager get webCalSubscriptionsRefs { + final manager = $$WebCalSubscriptionsTableTableManager( + $_db, + $_db.webCalSubscriptions, + ).filter((f) => f.accountId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _webCalSubscriptionsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + static MultiTypedResultKey<$SyncCursorsTable, List> _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.syncCursors, @@ -23740,6 +25691,31 @@ class $$AccountsTableFilterComposer return f(composer); } + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableFilterComposer f) f, + ) { + final $$WebCalSubscriptionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.accountId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableFilterComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression syncCursorsRefs( Expression Function($$SyncCursorsTableFilterComposer f) f, ) { @@ -24266,6 +26242,32 @@ class $$AccountsTableAnnotationComposer return f(composer); } + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableAnnotationComposer a) f, + ) { + final $$WebCalSubscriptionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.accountId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableAnnotationComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression syncCursorsRefs( Expression Function($$SyncCursorsTableAnnotationComposer a) f, ) { @@ -24368,6 +26370,7 @@ class $$AccountsTableTableManager bool syncRunsRefs, bool calendarSourcesRefs, bool calendarEventsRefs, + bool webCalSubscriptionsRefs, bool syncCursorsRefs, bool scheduleItemOverridesRefs, bool notificationScheduleRefs, @@ -24492,6 +26495,7 @@ class $$AccountsTableTableManager syncRunsRefs = false, calendarSourcesRefs = false, calendarEventsRefs = false, + webCalSubscriptionsRefs = false, syncCursorsRefs = false, scheduleItemOverridesRefs = false, notificationScheduleRefs = false, @@ -24509,6 +26513,7 @@ class $$AccountsTableTableManager if (syncRunsRefs) db.syncRuns, if (calendarSourcesRefs) db.calendarSources, if (calendarEventsRefs) db.calendarEvents, + if (webCalSubscriptionsRefs) db.webCalSubscriptions, if (syncCursorsRefs) db.syncCursors, if (scheduleItemOverridesRefs) db.scheduleItemOverrides, if (notificationScheduleRefs) db.notificationSchedule, @@ -24726,6 +26731,27 @@ class $$AccountsTableTableManager ), typedResults: items, ), + if (webCalSubscriptionsRefs) + await $_getPrefetchedData< + Account, + $AccountsTable, + WebCalSubscription + >( + currentTable: table, + referencedTable: $$AccountsTableReferences + ._webCalSubscriptionsRefsTable(db), + managerFromTypedResult: (p0) => + $$AccountsTableReferences( + db, + table, + p0, + ).webCalSubscriptionsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.accountId == item.id, + ), + typedResults: items, + ), if (syncCursorsRefs) await $_getPrefetchedData< Account, @@ -24820,6 +26846,7 @@ typedef $$AccountsTableProcessedTableManager = bool syncRunsRefs, bool calendarSourcesRefs, bool calendarEventsRefs, + bool webCalSubscriptionsRefs, bool syncCursorsRefs, bool scheduleItemOverridesRefs, bool notificationScheduleRefs, @@ -32221,423 +34248,723 @@ class $$TasksTableTableManager as T; } - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; - }, - ); - }, - ), - ); -} + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$TasksTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TasksTable, + Task, + $$TasksTableFilterComposer, + $$TasksTableOrderingComposer, + $$TasksTableAnnotationComposer, + $$TasksTableCreateCompanionBuilder, + $$TasksTableUpdateCompanionBuilder, + (Task, $$TasksTableReferences), + Task, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + }) + >; +typedef $$PendingOpsTableCreateCompanionBuilder = + PendingOpsCompanion Function({ + required String id, + required String accountId, + Value provider, + required String entityType, + required String operation, + Value operationType, + Value taskListId, + Value taskId, + Value calendarSourceId, + Value providerCalendarId, + Value eventId, + Value davCollectionId, + Value davCollectionHref, + Value davObjectId, + Value davMemberHref, + Value baselineEtag, + Value baselineRawIcs, + Value mutationPatchJson, + Value mutationPatchSchemaVersion, + Value targetComponentKey, + Value mutationScope, + Value destinationCollectionId, + Value destinationCollectionHref, + Value destinationMemberHref, + Value conflictState, + Value conflictSnapshotId, + Value retryClassification, + Value localTempId, + Value dependsOnOpId, + required String requestJson, + Value baselineUpdatedUtc, + Value baselineRawJson, + Value attemptCount, + Value nextAttemptAtUtc, + Value lastErrorCode, + Value lastErrorMessage, + Value state, + Value lastError, + required String createdAtUtc, + required String updatedAtUtc, + Value rowid, + }); +typedef $$PendingOpsTableUpdateCompanionBuilder = + PendingOpsCompanion Function({ + Value id, + Value accountId, + Value provider, + Value entityType, + Value operation, + Value operationType, + Value taskListId, + Value taskId, + Value calendarSourceId, + Value providerCalendarId, + Value eventId, + Value davCollectionId, + Value davCollectionHref, + Value davObjectId, + Value davMemberHref, + Value baselineEtag, + Value baselineRawIcs, + Value mutationPatchJson, + Value mutationPatchSchemaVersion, + Value targetComponentKey, + Value mutationScope, + Value destinationCollectionId, + Value destinationCollectionHref, + Value destinationMemberHref, + Value conflictState, + Value conflictSnapshotId, + Value retryClassification, + Value localTempId, + Value dependsOnOpId, + Value requestJson, + Value baselineUpdatedUtc, + Value baselineRawJson, + Value attemptCount, + Value nextAttemptAtUtc, + Value lastErrorCode, + Value lastErrorMessage, + Value state, + Value lastError, + Value createdAtUtc, + Value updatedAtUtc, + Value rowid, + }); + +final class $$PendingOpsTableReferences + extends BaseReferences<_$AppDatabase, $PendingOpsTable, PendingOp> { + $$PendingOpsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.pendingOps.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator( + db.pendingOps.davCollectionId, + db.davCollections.id, + ), + ); + + $$DavCollectionsTableProcessedTableManager? get davCollectionId { + final $_column = $_itemColumn('dav_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => + db.davObjects.createAlias( + $_aliasNameGenerator(db.pendingOps.davObjectId, db.davObjects.id), + ); + + $$DavObjectsTableProcessedTableManager? get davObjectId { + final $_column = $_itemColumn('dav_object_id'); + if ($_column == null) return null; + final manager = $$DavObjectsTableTableManager( + $_db, + $_db.davObjects, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavCollectionsTable _destinationCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator( + db.pendingOps.destinationCollectionId, + db.davCollections.id, + ), + ); + + $$DavCollectionsTableProcessedTableManager? get destinationCollectionId { + final $_column = $_itemColumn('destination_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull( + _destinationCollectionIdTable($_db), + ); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavConflictSnapshotsTable _conflictSnapshotIdTable( + _$AppDatabase db, + ) => db.davConflictSnapshots.createAlias( + $_aliasNameGenerator( + db.pendingOps.conflictSnapshotId, + db.davConflictSnapshots.id, + ), + ); + + $$DavConflictSnapshotsTableProcessedTableManager? get conflictSnapshotId { + final $_column = $_itemColumn('conflict_snapshot_id'); + if ($_column == null) return null; + final manager = $$DavConflictSnapshotsTableTableManager( + $_db, + $_db.davConflictSnapshots, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_conflictSnapshotIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$PendingOpsTableFilterComposer + extends Composer<_$AppDatabase, $PendingOpsTable> { + $$PendingOpsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get entityType => $composableBuilder( + column: $table.entityType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get operation => $composableBuilder( + column: $table.operation, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get operationType => $composableBuilder( + column: $table.operationType, + builder: (column) => ColumnFilters(column), + ); -typedef $$TasksTableProcessedTableManager = - ProcessedTableManager< - _$AppDatabase, - $TasksTable, - Task, - $$TasksTableFilterComposer, - $$TasksTableOrderingComposer, - $$TasksTableAnnotationComposer, - $$TasksTableCreateCompanionBuilder, - $$TasksTableUpdateCompanionBuilder, - (Task, $$TasksTableReferences), - Task, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool davObjectId, - bool davComponentId, - }) - >; -typedef $$PendingOpsTableCreateCompanionBuilder = - PendingOpsCompanion Function({ - required String id, - required String accountId, - Value provider, - required String entityType, - required String operation, - Value operationType, - Value taskListId, - Value taskId, - Value calendarSourceId, - Value providerCalendarId, - Value eventId, - Value davCollectionId, - Value davCollectionHref, - Value davObjectId, - Value davMemberHref, - Value baselineEtag, - Value baselineRawIcs, - Value mutationPatchJson, - Value mutationPatchSchemaVersion, - Value targetComponentKey, - Value mutationScope, - Value destinationCollectionId, - Value destinationCollectionHref, - Value destinationMemberHref, - Value conflictState, - Value conflictSnapshotId, - Value retryClassification, - Value localTempId, - Value dependsOnOpId, - required String requestJson, - Value baselineUpdatedUtc, - Value baselineRawJson, - Value attemptCount, - Value nextAttemptAtUtc, - Value lastErrorCode, - Value lastErrorMessage, - Value state, - Value lastError, - required String createdAtUtc, - required String updatedAtUtc, - Value rowid, - }); -typedef $$PendingOpsTableUpdateCompanionBuilder = - PendingOpsCompanion Function({ - Value id, - Value accountId, - Value provider, - Value entityType, - Value operation, - Value operationType, - Value taskListId, - Value taskId, - Value calendarSourceId, - Value providerCalendarId, - Value eventId, - Value davCollectionId, - Value davCollectionHref, - Value davObjectId, - Value davMemberHref, - Value baselineEtag, - Value baselineRawIcs, - Value mutationPatchJson, - Value mutationPatchSchemaVersion, - Value targetComponentKey, - Value mutationScope, - Value destinationCollectionId, - Value destinationCollectionHref, - Value destinationMemberHref, - Value conflictState, - Value conflictSnapshotId, - Value retryClassification, - Value localTempId, - Value dependsOnOpId, - Value requestJson, - Value baselineUpdatedUtc, - Value baselineRawJson, - Value attemptCount, - Value nextAttemptAtUtc, - Value lastErrorCode, - Value lastErrorMessage, - Value state, - Value lastError, - Value createdAtUtc, - Value updatedAtUtc, - Value rowid, - }); + ColumnFilters get taskListId => $composableBuilder( + column: $table.taskListId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get taskId => $composableBuilder( + column: $table.taskId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get calendarSourceId => $composableBuilder( + column: $table.calendarSourceId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get davCollectionHref => $composableBuilder( + column: $table.davCollectionHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get davMemberHref => $composableBuilder( + column: $table.davMemberHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineEtag => $composableBuilder( + column: $table.baselineEtag, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineRawIcs => $composableBuilder( + column: $table.baselineRawIcs, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mutationPatchJson => $composableBuilder( + column: $table.mutationPatchJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mutationPatchSchemaVersion => $composableBuilder( + column: $table.mutationPatchSchemaVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get targetComponentKey => $composableBuilder( + column: $table.targetComponentKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mutationScope => $composableBuilder( + column: $table.mutationScope, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get destinationCollectionHref => $composableBuilder( + column: $table.destinationCollectionHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get destinationMemberHref => $composableBuilder( + column: $table.destinationMemberHref, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conflictState => $composableBuilder( + column: $table.conflictState, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get retryClassification => $composableBuilder( + column: $table.retryClassification, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localTempId => $composableBuilder( + column: $table.localTempId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dependsOnOpId => $composableBuilder( + column: $table.dependsOnOpId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get requestJson => $composableBuilder( + column: $table.requestJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineUpdatedUtc => $composableBuilder( + column: $table.baselineUpdatedUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get attemptCount => $composableBuilder( + column: $table.attemptCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get nextAttemptAtUtc => $composableBuilder( + column: $table.nextAttemptAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastErrorCode => $composableBuilder( + column: $table.lastErrorCode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastErrorMessage => $composableBuilder( + column: $table.lastErrorMessage, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get state => $composableBuilder( + column: $table.state, + builder: (column) => ColumnFilters(column), + ); -final class $$PendingOpsTableReferences - extends BaseReferences<_$AppDatabase, $PendingOpsTable, PendingOp> { - $$PendingOpsTableReferences(super.$_db, super.$_table, super.$_typedResult); + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnFilters(column), + ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.pendingOps.accountId, db.accounts.id), - ); + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnFilters(column), + ); - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnFilters(column), + ); - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.pendingOps.davCollectionId, - db.davCollections.id, - ), - ); - - $$DavCollectionsTableProcessedTableManager? get davCollectionId { - final $_column = $_itemColumn('dav_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + $$DavCollectionsTableFilterComposer get davCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableFilterComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator(db.pendingOps.davObjectId, db.davObjects.id), - ); - - $$DavObjectsTableProcessedTableManager? get davObjectId { - final $_column = $_itemColumn('dav_object_id'); - if ($_column == null) return null; - final manager = $$DavObjectsTableTableManager( - $_db, - $_db.davObjects, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + $$DavObjectsTableFilterComposer get davObjectId { + final $$DavObjectsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableFilterComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } - static $DavCollectionsTable _destinationCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.pendingOps.destinationCollectionId, - db.davCollections.id, - ), - ); - - $$DavCollectionsTableProcessedTableManager? get destinationCollectionId { - final $_column = $_itemColumn('destination_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull( - _destinationCollectionIdTable($_db), - ); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + $$DavCollectionsTableFilterComposer get destinationCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.destinationCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableFilterComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } - static $DavConflictSnapshotsTable _conflictSnapshotIdTable( - _$AppDatabase db, - ) => db.davConflictSnapshots.createAlias( - $_aliasNameGenerator( - db.pendingOps.conflictSnapshotId, - db.davConflictSnapshots.id, - ), - ); - - $$DavConflictSnapshotsTableProcessedTableManager? get conflictSnapshotId { - final $_column = $_itemColumn('conflict_snapshot_id'); - if ($_column == null) return null; - final manager = $$DavConflictSnapshotsTableTableManager( - $_db, - $_db.davConflictSnapshots, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_conflictSnapshotIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + $$DavConflictSnapshotsTableFilterComposer get conflictSnapshotId { + final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.conflictSnapshotId, + referencedTable: $db.davConflictSnapshots, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavConflictSnapshotsTableFilterComposer( + $db: $db, + $table: $db.davConflictSnapshots, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } } -class $$PendingOpsTableFilterComposer +class $$PendingOpsTableOrderingComposer extends Composer<_$AppDatabase, $PendingOpsTable> { - $$PendingOpsTableFilterComposer({ + $$PendingOpsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( + ColumnOrderings get id => $composableBuilder( column: $table.id, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get provider => $composableBuilder( + ColumnOrderings get provider => $composableBuilder( column: $table.provider, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get entityType => $composableBuilder( + ColumnOrderings get entityType => $composableBuilder( column: $table.entityType, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get operation => $composableBuilder( + ColumnOrderings get operation => $composableBuilder( column: $table.operation, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get operationType => $composableBuilder( + ColumnOrderings get operationType => $composableBuilder( column: $table.operationType, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get taskListId => $composableBuilder( + ColumnOrderings get taskListId => $composableBuilder( column: $table.taskListId, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get taskId => $composableBuilder( + ColumnOrderings get taskId => $composableBuilder( column: $table.taskId, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get calendarSourceId => $composableBuilder( + ColumnOrderings get calendarSourceId => $composableBuilder( column: $table.calendarSourceId, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get providerCalendarId => $composableBuilder( + ColumnOrderings get providerCalendarId => $composableBuilder( column: $table.providerCalendarId, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get eventId => $composableBuilder( + ColumnOrderings get eventId => $composableBuilder( column: $table.eventId, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get davCollectionHref => $composableBuilder( + ColumnOrderings get davCollectionHref => $composableBuilder( column: $table.davCollectionHref, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get davMemberHref => $composableBuilder( + ColumnOrderings get davMemberHref => $composableBuilder( column: $table.davMemberHref, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get baselineEtag => $composableBuilder( + ColumnOrderings get baselineEtag => $composableBuilder( column: $table.baselineEtag, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get baselineRawIcs => $composableBuilder( + ColumnOrderings get baselineRawIcs => $composableBuilder( column: $table.baselineRawIcs, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get mutationPatchJson => $composableBuilder( + ColumnOrderings get mutationPatchJson => $composableBuilder( column: $table.mutationPatchJson, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get mutationPatchSchemaVersion => $composableBuilder( + ColumnOrderings get mutationPatchSchemaVersion => $composableBuilder( column: $table.mutationPatchSchemaVersion, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get targetComponentKey => $composableBuilder( + ColumnOrderings get targetComponentKey => $composableBuilder( column: $table.targetComponentKey, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get mutationScope => $composableBuilder( + ColumnOrderings get mutationScope => $composableBuilder( column: $table.mutationScope, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get destinationCollectionHref => $composableBuilder( + ColumnOrderings get destinationCollectionHref => $composableBuilder( column: $table.destinationCollectionHref, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get destinationMemberHref => $composableBuilder( + ColumnOrderings get destinationMemberHref => $composableBuilder( column: $table.destinationMemberHref, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get conflictState => $composableBuilder( + ColumnOrderings get conflictState => $composableBuilder( column: $table.conflictState, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get retryClassification => $composableBuilder( + ColumnOrderings get retryClassification => $composableBuilder( column: $table.retryClassification, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get localTempId => $composableBuilder( + ColumnOrderings get localTempId => $composableBuilder( column: $table.localTempId, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get dependsOnOpId => $composableBuilder( + ColumnOrderings get dependsOnOpId => $composableBuilder( column: $table.dependsOnOpId, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get requestJson => $composableBuilder( + ColumnOrderings get requestJson => $composableBuilder( column: $table.requestJson, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get baselineUpdatedUtc => $composableBuilder( + ColumnOrderings get baselineUpdatedUtc => $composableBuilder( column: $table.baselineUpdatedUtc, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get baselineRawJson => $composableBuilder( + ColumnOrderings get baselineRawJson => $composableBuilder( column: $table.baselineRawJson, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get attemptCount => $composableBuilder( + ColumnOrderings get attemptCount => $composableBuilder( column: $table.attemptCount, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get nextAttemptAtUtc => $composableBuilder( + ColumnOrderings get nextAttemptAtUtc => $composableBuilder( column: $table.nextAttemptAtUtc, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get lastErrorCode => $composableBuilder( + ColumnOrderings get lastErrorCode => $composableBuilder( column: $table.lastErrorCode, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get lastErrorMessage => $composableBuilder( + ColumnOrderings get lastErrorMessage => $composableBuilder( column: $table.lastErrorMessage, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get state => $composableBuilder( + ColumnOrderings get state => $composableBuilder( column: $table.state, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get lastError => $composableBuilder( + ColumnOrderings get lastError => $composableBuilder( column: $table.lastError, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get createdAtUtc => $composableBuilder( + ColumnOrderings get createdAtUtc => $composableBuilder( column: $table.createdAtUtc, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get updatedAtUtc => $composableBuilder( + ColumnOrderings get updatedAtUtc => $composableBuilder( column: $table.updatedAtUtc, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.accountId, referencedTable: $db.accounts, @@ -32647,7 +34974,7 @@ class $$PendingOpsTableFilterComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( + }) => $$AccountsTableOrderingComposer( $db: $db, $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -32659,8 +34986,8 @@ class $$PendingOpsTableFilterComposer return composer; } - $$DavCollectionsTableFilterComposer get davCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + $$DavCollectionsTableOrderingComposer get davCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.davCollectionId, referencedTable: $db.davCollections, @@ -32670,7 +34997,7 @@ class $$PendingOpsTableFilterComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( + }) => $$DavCollectionsTableOrderingComposer( $db: $db, $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -32682,8 +35009,8 @@ class $$PendingOpsTableFilterComposer return composer; } - $$DavObjectsTableFilterComposer get davObjectId { - final $$DavObjectsTableFilterComposer composer = $composerBuilder( + $$DavObjectsTableOrderingComposer get davObjectId { + final $$DavObjectsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.davObjectId, referencedTable: $db.davObjects, @@ -32693,7 +35020,7 @@ class $$PendingOpsTableFilterComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableFilterComposer( + }) => $$DavObjectsTableOrderingComposer( $db: $db, $table: $db.davObjects, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -32705,8 +35032,8 @@ class $$PendingOpsTableFilterComposer return composer; } - $$DavCollectionsTableFilterComposer get destinationCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + $$DavCollectionsTableOrderingComposer get destinationCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.destinationCollectionId, referencedTable: $db.davCollections, @@ -32716,7 +35043,7 @@ class $$PendingOpsTableFilterComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( + }) => $$DavCollectionsTableOrderingComposer( $db: $db, $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -32728,216 +35055,203 @@ class $$PendingOpsTableFilterComposer return composer; } - $$DavConflictSnapshotsTableFilterComposer get conflictSnapshotId { - final $$DavConflictSnapshotsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.conflictSnapshotId, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableFilterComposer( - $db: $db, - $table: $db.davConflictSnapshots, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: + $$DavConflictSnapshotsTableOrderingComposer get conflictSnapshotId { + final $$DavConflictSnapshotsTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.conflictSnapshotId, + referencedTable: $db.davConflictSnapshots, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - ), - ); + }) => $$DavConflictSnapshotsTableOrderingComposer( + $db: $db, + $table: $db.davConflictSnapshots, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$PendingOpsTableOrderingComposer +class $$PendingOpsTableAnnotationComposer extends Composer<_$AppDatabase, $PendingOpsTable> { - $$PendingOpsTableOrderingComposer({ + $$PendingOpsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - ColumnOrderings get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get provider => + $composableBuilder(column: $table.provider, builder: (column) => column); - ColumnOrderings get entityType => $composableBuilder( + GeneratedColumn get entityType => $composableBuilder( column: $table.entityType, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get operation => $composableBuilder( - column: $table.operation, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get operation => + $composableBuilder(column: $table.operation, builder: (column) => column); - ColumnOrderings get operationType => $composableBuilder( + GeneratedColumn get operationType => $composableBuilder( column: $table.operationType, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get taskListId => $composableBuilder( + GeneratedColumn get taskListId => $composableBuilder( column: $table.taskListId, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get taskId => $composableBuilder( - column: $table.taskId, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get taskId => + $composableBuilder(column: $table.taskId, builder: (column) => column); - ColumnOrderings get calendarSourceId => $composableBuilder( + GeneratedColumn get calendarSourceId => $composableBuilder( column: $table.calendarSourceId, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get providerCalendarId => $composableBuilder( + GeneratedColumn get providerCalendarId => $composableBuilder( column: $table.providerCalendarId, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get eventId => $composableBuilder( - column: $table.eventId, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); - ColumnOrderings get davCollectionHref => $composableBuilder( + GeneratedColumn get davCollectionHref => $composableBuilder( column: $table.davCollectionHref, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get davMemberHref => $composableBuilder( + GeneratedColumn get davMemberHref => $composableBuilder( column: $table.davMemberHref, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get baselineEtag => $composableBuilder( + GeneratedColumn get baselineEtag => $composableBuilder( column: $table.baselineEtag, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get baselineRawIcs => $composableBuilder( + GeneratedColumn get baselineRawIcs => $composableBuilder( column: $table.baselineRawIcs, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get mutationPatchJson => $composableBuilder( + GeneratedColumn get mutationPatchJson => $composableBuilder( column: $table.mutationPatchJson, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get mutationPatchSchemaVersion => $composableBuilder( + GeneratedColumn get mutationPatchSchemaVersion => $composableBuilder( column: $table.mutationPatchSchemaVersion, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get targetComponentKey => $composableBuilder( + GeneratedColumn get targetComponentKey => $composableBuilder( column: $table.targetComponentKey, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get mutationScope => $composableBuilder( + GeneratedColumn get mutationScope => $composableBuilder( column: $table.mutationScope, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get destinationCollectionHref => $composableBuilder( + GeneratedColumn get destinationCollectionHref => $composableBuilder( column: $table.destinationCollectionHref, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get destinationMemberHref => $composableBuilder( + GeneratedColumn get destinationMemberHref => $composableBuilder( column: $table.destinationMemberHref, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get conflictState => $composableBuilder( + GeneratedColumn get conflictState => $composableBuilder( column: $table.conflictState, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get retryClassification => $composableBuilder( + GeneratedColumn get retryClassification => $composableBuilder( column: $table.retryClassification, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get localTempId => $composableBuilder( + GeneratedColumn get localTempId => $composableBuilder( column: $table.localTempId, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get dependsOnOpId => $composableBuilder( + GeneratedColumn get dependsOnOpId => $composableBuilder( column: $table.dependsOnOpId, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get requestJson => $composableBuilder( + GeneratedColumn get requestJson => $composableBuilder( column: $table.requestJson, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get baselineUpdatedUtc => $composableBuilder( + GeneratedColumn get baselineUpdatedUtc => $composableBuilder( column: $table.baselineUpdatedUtc, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get baselineRawJson => $composableBuilder( + GeneratedColumn get baselineRawJson => $composableBuilder( column: $table.baselineRawJson, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get attemptCount => $composableBuilder( + GeneratedColumn get attemptCount => $composableBuilder( column: $table.attemptCount, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get nextAttemptAtUtc => $composableBuilder( + GeneratedColumn get nextAttemptAtUtc => $composableBuilder( column: $table.nextAttemptAtUtc, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get lastErrorCode => $composableBuilder( + GeneratedColumn get lastErrorCode => $composableBuilder( column: $table.lastErrorCode, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get lastErrorMessage => $composableBuilder( + GeneratedColumn get lastErrorMessage => $composableBuilder( column: $table.lastErrorMessage, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get state => $composableBuilder( - column: $table.state, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get state => + $composableBuilder(column: $table.state, builder: (column) => column); - ColumnOrderings get lastError => $composableBuilder( - column: $table.lastError, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); - ColumnOrderings get createdAtUtc => $composableBuilder( + GeneratedColumn get createdAtUtc => $composableBuilder( column: $table.createdAtUtc, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - ColumnOrderings get updatedAtUtc => $composableBuilder( + GeneratedColumn get updatedAtUtc => $composableBuilder( column: $table.updatedAtUtc, - builder: (column) => ColumnOrderings(column), + builder: (column) => column, ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.accountId, referencedTable: $db.accounts, @@ -32947,7 +35261,7 @@ class $$PendingOpsTableOrderingComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( + }) => $$AccountsTableAnnotationComposer( $db: $db, $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -32959,8 +35273,8 @@ class $$PendingOpsTableOrderingComposer return composer; } - $$DavCollectionsTableOrderingComposer get davCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.davCollectionId, referencedTable: $db.davCollections, @@ -32970,7 +35284,7 @@ class $$PendingOpsTableOrderingComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( + }) => $$DavCollectionsTableAnnotationComposer( $db: $db, $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -32982,8 +35296,8 @@ class $$PendingOpsTableOrderingComposer return composer; } - $$DavObjectsTableOrderingComposer get davObjectId { - final $$DavObjectsTableOrderingComposer composer = $composerBuilder( + $$DavObjectsTableAnnotationComposer get davObjectId { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.davObjectId, referencedTable: $db.davObjects, @@ -32993,7 +35307,7 @@ class $$PendingOpsTableOrderingComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableOrderingComposer( + }) => $$DavObjectsTableAnnotationComposer( $db: $db, $table: $db.davObjects, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -33005,8 +35319,8 @@ class $$PendingOpsTableOrderingComposer return composer; } - $$DavCollectionsTableOrderingComposer get destinationCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + $$DavCollectionsTableAnnotationComposer get destinationCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.destinationCollectionId, referencedTable: $db.davCollections, @@ -33016,7 +35330,7 @@ class $$PendingOpsTableOrderingComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( + }) => $$DavCollectionsTableAnnotationComposer( $db: $db, $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -33028,8 +35342,8 @@ class $$PendingOpsTableOrderingComposer return composer; } - $$DavConflictSnapshotsTableOrderingComposer get conflictSnapshotId { - final $$DavConflictSnapshotsTableOrderingComposer composer = + $$DavConflictSnapshotsTableAnnotationComposer get conflictSnapshotId { + final $$DavConflictSnapshotsTableAnnotationComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.conflictSnapshotId, @@ -33040,7 +35354,7 @@ class $$PendingOpsTableOrderingComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableOrderingComposer( + }) => $$DavConflictSnapshotsTableAnnotationComposer( $db: $db, $table: $db.davConflictSnapshots, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -33048,183 +35362,562 @@ class $$PendingOpsTableOrderingComposer $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), - ); - return composer; - } -} - -class $$PendingOpsTableAnnotationComposer - extends Composer<_$AppDatabase, $PendingOpsTable> { - $$PendingOpsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get provider => - $composableBuilder(column: $table.provider, builder: (column) => column); - - GeneratedColumn get entityType => $composableBuilder( - column: $table.entityType, - builder: (column) => column, - ); - - GeneratedColumn get operation => - $composableBuilder(column: $table.operation, builder: (column) => column); + ); + return composer; + } +} + +class $$PendingOpsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $PendingOpsTable, + PendingOp, + $$PendingOpsTableFilterComposer, + $$PendingOpsTableOrderingComposer, + $$PendingOpsTableAnnotationComposer, + $$PendingOpsTableCreateCompanionBuilder, + $$PendingOpsTableUpdateCompanionBuilder, + (PendingOp, $$PendingOpsTableReferences), + PendingOp, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool davObjectId, + bool destinationCollectionId, + bool conflictSnapshotId, + }) + > { + $$PendingOpsTableTableManager(_$AppDatabase db, $PendingOpsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PendingOpsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PendingOpsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PendingOpsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountId = const Value.absent(), + Value provider = const Value.absent(), + Value entityType = const Value.absent(), + Value operation = const Value.absent(), + Value operationType = const Value.absent(), + Value taskListId = const Value.absent(), + Value taskId = const Value.absent(), + Value calendarSourceId = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value eventId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value davCollectionHref = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davMemberHref = const Value.absent(), + Value baselineEtag = const Value.absent(), + Value baselineRawIcs = const Value.absent(), + Value mutationPatchJson = const Value.absent(), + Value mutationPatchSchemaVersion = const Value.absent(), + Value targetComponentKey = const Value.absent(), + Value mutationScope = const Value.absent(), + Value destinationCollectionId = const Value.absent(), + Value destinationCollectionHref = const Value.absent(), + Value destinationMemberHref = const Value.absent(), + Value conflictState = const Value.absent(), + Value conflictSnapshotId = const Value.absent(), + Value retryClassification = const Value.absent(), + Value localTempId = const Value.absent(), + Value dependsOnOpId = const Value.absent(), + Value requestJson = const Value.absent(), + Value baselineUpdatedUtc = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value attemptCount = const Value.absent(), + Value nextAttemptAtUtc = const Value.absent(), + Value lastErrorCode = const Value.absent(), + Value lastErrorMessage = const Value.absent(), + Value state = const Value.absent(), + Value lastError = const Value.absent(), + Value createdAtUtc = const Value.absent(), + Value updatedAtUtc = const Value.absent(), + Value rowid = const Value.absent(), + }) => PendingOpsCompanion( + id: id, + accountId: accountId, + provider: provider, + entityType: entityType, + operation: operation, + operationType: operationType, + taskListId: taskListId, + taskId: taskId, + calendarSourceId: calendarSourceId, + providerCalendarId: providerCalendarId, + eventId: eventId, + davCollectionId: davCollectionId, + davCollectionHref: davCollectionHref, + davObjectId: davObjectId, + davMemberHref: davMemberHref, + baselineEtag: baselineEtag, + baselineRawIcs: baselineRawIcs, + mutationPatchJson: mutationPatchJson, + mutationPatchSchemaVersion: mutationPatchSchemaVersion, + targetComponentKey: targetComponentKey, + mutationScope: mutationScope, + destinationCollectionId: destinationCollectionId, + destinationCollectionHref: destinationCollectionHref, + destinationMemberHref: destinationMemberHref, + conflictState: conflictState, + conflictSnapshotId: conflictSnapshotId, + retryClassification: retryClassification, + localTempId: localTempId, + dependsOnOpId: dependsOnOpId, + requestJson: requestJson, + baselineUpdatedUtc: baselineUpdatedUtc, + baselineRawJson: baselineRawJson, + attemptCount: attemptCount, + nextAttemptAtUtc: nextAttemptAtUtc, + lastErrorCode: lastErrorCode, + lastErrorMessage: lastErrorMessage, + state: state, + lastError: lastError, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String accountId, + Value provider = const Value.absent(), + required String entityType, + required String operation, + Value operationType = const Value.absent(), + Value taskListId = const Value.absent(), + Value taskId = const Value.absent(), + Value calendarSourceId = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value eventId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value davCollectionHref = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davMemberHref = const Value.absent(), + Value baselineEtag = const Value.absent(), + Value baselineRawIcs = const Value.absent(), + Value mutationPatchJson = const Value.absent(), + Value mutationPatchSchemaVersion = const Value.absent(), + Value targetComponentKey = const Value.absent(), + Value mutationScope = const Value.absent(), + Value destinationCollectionId = const Value.absent(), + Value destinationCollectionHref = const Value.absent(), + Value destinationMemberHref = const Value.absent(), + Value conflictState = const Value.absent(), + Value conflictSnapshotId = const Value.absent(), + Value retryClassification = const Value.absent(), + Value localTempId = const Value.absent(), + Value dependsOnOpId = const Value.absent(), + required String requestJson, + Value baselineUpdatedUtc = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value attemptCount = const Value.absent(), + Value nextAttemptAtUtc = const Value.absent(), + Value lastErrorCode = const Value.absent(), + Value lastErrorMessage = const Value.absent(), + Value state = const Value.absent(), + Value lastError = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, + Value rowid = const Value.absent(), + }) => PendingOpsCompanion.insert( + id: id, + accountId: accountId, + provider: provider, + entityType: entityType, + operation: operation, + operationType: operationType, + taskListId: taskListId, + taskId: taskId, + calendarSourceId: calendarSourceId, + providerCalendarId: providerCalendarId, + eventId: eventId, + davCollectionId: davCollectionId, + davCollectionHref: davCollectionHref, + davObjectId: davObjectId, + davMemberHref: davMemberHref, + baselineEtag: baselineEtag, + baselineRawIcs: baselineRawIcs, + mutationPatchJson: mutationPatchJson, + mutationPatchSchemaVersion: mutationPatchSchemaVersion, + targetComponentKey: targetComponentKey, + mutationScope: mutationScope, + destinationCollectionId: destinationCollectionId, + destinationCollectionHref: destinationCollectionHref, + destinationMemberHref: destinationMemberHref, + conflictState: conflictState, + conflictSnapshotId: conflictSnapshotId, + retryClassification: retryClassification, + localTempId: localTempId, + dependsOnOpId: dependsOnOpId, + requestJson: requestJson, + baselineUpdatedUtc: baselineUpdatedUtc, + baselineRawJson: baselineRawJson, + attemptCount: attemptCount, + nextAttemptAtUtc: nextAttemptAtUtc, + lastErrorCode: lastErrorCode, + lastErrorMessage: lastErrorMessage, + state: state, + lastError: lastError, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$PendingOpsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: + ({ + accountId = false, + davCollectionId = false, + davObjectId = false, + destinationCollectionId = false, + conflictSnapshotId = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$PendingOpsTableReferences + ._accountIdTable(db), + referencedColumn: + $$PendingOpsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (davCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: $$PendingOpsTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$PendingOpsTableReferences + ._davCollectionIdTable(db) + .id, + ) + as T; + } + if (davObjectId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: $$PendingOpsTableReferences + ._davObjectIdTable(db), + referencedColumn: + $$PendingOpsTableReferences + ._davObjectIdTable(db) + .id, + ) + as T; + } + if (destinationCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: + table.destinationCollectionId, + referencedTable: $$PendingOpsTableReferences + ._destinationCollectionIdTable(db), + referencedColumn: + $$PendingOpsTableReferences + ._destinationCollectionIdTable(db) + .id, + ) + as T; + } + if (conflictSnapshotId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.conflictSnapshotId, + referencedTable: $$PendingOpsTableReferences + ._conflictSnapshotIdTable(db), + referencedColumn: + $$PendingOpsTableReferences + ._conflictSnapshotIdTable(db) + .id, + ) + as T; + } - GeneratedColumn get operationType => $composableBuilder( - column: $table.operationType, - builder: (column) => column, - ); + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} - GeneratedColumn get taskListId => $composableBuilder( - column: $table.taskListId, - builder: (column) => column, - ); +typedef $$PendingOpsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $PendingOpsTable, + PendingOp, + $$PendingOpsTableFilterComposer, + $$PendingOpsTableOrderingComposer, + $$PendingOpsTableAnnotationComposer, + $$PendingOpsTableCreateCompanionBuilder, + $$PendingOpsTableUpdateCompanionBuilder, + (PendingOp, $$PendingOpsTableReferences), + PendingOp, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool davObjectId, + bool destinationCollectionId, + bool conflictSnapshotId, + }) + >; +typedef $$SyncRunsTableCreateCompanionBuilder = + SyncRunsCompanion Function({ + required String id, + required String accountId, + Value provider, + required String mode, + required String startedAtUtc, + Value finishedAtUtc, + required String status, + Value taskListsSeen, + Value tasksSeen, + Value pendingOpsApplied, + Value errorCode, + Value errorMessage, + Value rowid, + }); +typedef $$SyncRunsTableUpdateCompanionBuilder = + SyncRunsCompanion Function({ + Value id, + Value accountId, + Value provider, + Value mode, + Value startedAtUtc, + Value finishedAtUtc, + Value status, + Value taskListsSeen, + Value tasksSeen, + Value pendingOpsApplied, + Value errorCode, + Value errorMessage, + Value rowid, + }); - GeneratedColumn get taskId => - $composableBuilder(column: $table.taskId, builder: (column) => column); +final class $$SyncRunsTableReferences + extends BaseReferences<_$AppDatabase, $SyncRunsTable, SyncRun> { + $$SyncRunsTableReferences(super.$_db, super.$_table, super.$_typedResult); - GeneratedColumn get calendarSourceId => $composableBuilder( - column: $table.calendarSourceId, - builder: (column) => column, - ); + static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts + .createAlias($_aliasNameGenerator(db.syncRuns.accountId, db.accounts.id)); - GeneratedColumn get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => column, - ); + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; - GeneratedColumn get eventId => - $composableBuilder(column: $table.eventId, builder: (column) => column); + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} - GeneratedColumn get davCollectionHref => $composableBuilder( - column: $table.davCollectionHref, - builder: (column) => column, +class $$SyncRunsTableFilterComposer + extends Composer<_$AppDatabase, $SyncRunsTable> { + $$SyncRunsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get davMemberHref => $composableBuilder( - column: $table.davMemberHref, - builder: (column) => column, + ColumnFilters get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get baselineEtag => $composableBuilder( - column: $table.baselineEtag, - builder: (column) => column, + ColumnFilters get mode => $composableBuilder( + column: $table.mode, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get baselineRawIcs => $composableBuilder( - column: $table.baselineRawIcs, - builder: (column) => column, + ColumnFilters get startedAtUtc => $composableBuilder( + column: $table.startedAtUtc, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get mutationPatchJson => $composableBuilder( - column: $table.mutationPatchJson, - builder: (column) => column, + ColumnFilters get finishedAtUtc => $composableBuilder( + column: $table.finishedAtUtc, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get mutationPatchSchemaVersion => $composableBuilder( - column: $table.mutationPatchSchemaVersion, - builder: (column) => column, + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get targetComponentKey => $composableBuilder( - column: $table.targetComponentKey, - builder: (column) => column, + ColumnFilters get taskListsSeen => $composableBuilder( + column: $table.taskListsSeen, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get mutationScope => $composableBuilder( - column: $table.mutationScope, - builder: (column) => column, + ColumnFilters get tasksSeen => $composableBuilder( + column: $table.tasksSeen, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get destinationCollectionHref => $composableBuilder( - column: $table.destinationCollectionHref, - builder: (column) => column, + ColumnFilters get pendingOpsApplied => $composableBuilder( + column: $table.pendingOpsApplied, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get destinationMemberHref => $composableBuilder( - column: $table.destinationMemberHref, - builder: (column) => column, + ColumnFilters get errorCode => $composableBuilder( + column: $table.errorCode, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get conflictState => $composableBuilder( - column: $table.conflictState, - builder: (column) => column, + ColumnFilters get errorMessage => $composableBuilder( + column: $table.errorMessage, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get retryClassification => $composableBuilder( - column: $table.retryClassification, - builder: (column) => column, - ); + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} - GeneratedColumn get localTempId => $composableBuilder( - column: $table.localTempId, - builder: (column) => column, +class $$SyncRunsTableOrderingComposer + extends Composer<_$AppDatabase, $SyncRunsTable> { + $$SyncRunsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get dependsOnOpId => $composableBuilder( - column: $table.dependsOnOpId, - builder: (column) => column, + ColumnOrderings get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get requestJson => $composableBuilder( - column: $table.requestJson, - builder: (column) => column, + ColumnOrderings get mode => $composableBuilder( + column: $table.mode, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get baselineUpdatedUtc => $composableBuilder( - column: $table.baselineUpdatedUtc, - builder: (column) => column, + ColumnOrderings get startedAtUtc => $composableBuilder( + column: $table.startedAtUtc, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, - builder: (column) => column, + ColumnOrderings get finishedAtUtc => $composableBuilder( + column: $table.finishedAtUtc, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get attemptCount => $composableBuilder( - column: $table.attemptCount, - builder: (column) => column, + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get nextAttemptAtUtc => $composableBuilder( - column: $table.nextAttemptAtUtc, - builder: (column) => column, + ColumnOrderings get taskListsSeen => $composableBuilder( + column: $table.taskListsSeen, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get lastErrorCode => $composableBuilder( - column: $table.lastErrorCode, - builder: (column) => column, + ColumnOrderings get tasksSeen => $composableBuilder( + column: $table.tasksSeen, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get lastErrorMessage => $composableBuilder( - column: $table.lastErrorMessage, - builder: (column) => column, + ColumnOrderings get pendingOpsApplied => $composableBuilder( + column: $table.pendingOpsApplied, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get state => - $composableBuilder(column: $table.state, builder: (column) => column); - - GeneratedColumn get lastError => - $composableBuilder(column: $table.lastError, builder: (column) => column); - - GeneratedColumn get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, - builder: (column) => column, + ColumnOrderings get errorCode => $composableBuilder( + column: $table.errorCode, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, - builder: (column) => column, + ColumnOrderings get errorMessage => $composableBuilder( + column: $table.errorMessage, + builder: (column) => ColumnOrderings(column), ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.accountId, referencedTable: $db.accounts, @@ -33234,7 +35927,7 @@ class $$PendingOpsTableAnnotationComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( + }) => $$AccountsTableOrderingComposer( $db: $db, $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -33245,67 +35938,74 @@ class $$PendingOpsTableAnnotationComposer ); return composer; } +} - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } +class $$SyncRunsTableAnnotationComposer + extends Composer<_$AppDatabase, $SyncRunsTable> { + $$SyncRunsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get provider => + $composableBuilder(column: $table.provider, builder: (column) => column); + + GeneratedColumn get mode => + $composableBuilder(column: $table.mode, builder: (column) => column); + + GeneratedColumn get startedAtUtc => $composableBuilder( + column: $table.startedAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get finishedAtUtc => $composableBuilder( + column: $table.finishedAtUtc, + builder: (column) => column, + ); - $$DavObjectsTableAnnotationComposer get davObjectId { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( - $db: $db, - $table: $db.davObjects, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); - $$DavCollectionsTableAnnotationComposer get destinationCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + GeneratedColumn get taskListsSeen => $composableBuilder( + column: $table.taskListsSeen, + builder: (column) => column, + ); + + GeneratedColumn get tasksSeen => + $composableBuilder(column: $table.tasksSeen, builder: (column) => column); + + GeneratedColumn get pendingOpsApplied => $composableBuilder( + column: $table.pendingOpsApplied, + builder: (column) => column, + ); + + GeneratedColumn get errorCode => + $composableBuilder(column: $table.errorCode, builder: (column) => column); + + GeneratedColumn get errorMessage => $composableBuilder( + column: $table.errorMessage, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.destinationCollectionId, - referencedTable: $db.davCollections, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( + }) => $$AccountsTableAnnotationComposer( $db: $db, - $table: $db.davCollections, + $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -33314,148 +36014,62 @@ class $$PendingOpsTableAnnotationComposer ); return composer; } - - $$DavConflictSnapshotsTableAnnotationComposer get conflictSnapshotId { - final $$DavConflictSnapshotsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.conflictSnapshotId, - referencedTable: $db.davConflictSnapshots, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavConflictSnapshotsTableAnnotationComposer( - $db: $db, - $table: $db.davConflictSnapshots, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$PendingOpsTableTableManager +class $$SyncRunsTableTableManager extends RootTableManager< _$AppDatabase, - $PendingOpsTable, - PendingOp, - $$PendingOpsTableFilterComposer, - $$PendingOpsTableOrderingComposer, - $$PendingOpsTableAnnotationComposer, - $$PendingOpsTableCreateCompanionBuilder, - $$PendingOpsTableUpdateCompanionBuilder, - (PendingOp, $$PendingOpsTableReferences), - PendingOp, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool davObjectId, - bool destinationCollectionId, - bool conflictSnapshotId, - }) + $SyncRunsTable, + SyncRun, + $$SyncRunsTableFilterComposer, + $$SyncRunsTableOrderingComposer, + $$SyncRunsTableAnnotationComposer, + $$SyncRunsTableCreateCompanionBuilder, + $$SyncRunsTableUpdateCompanionBuilder, + (SyncRun, $$SyncRunsTableReferences), + SyncRun, + PrefetchHooks Function({bool accountId}) > { - $$PendingOpsTableTableManager(_$AppDatabase db, $PendingOpsTable table) + $$SyncRunsTableTableManager(_$AppDatabase db, $SyncRunsTable table) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$PendingOpsTableFilterComposer($db: db, $table: table), + $$SyncRunsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$PendingOpsTableOrderingComposer($db: db, $table: table), + $$SyncRunsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$PendingOpsTableAnnotationComposer($db: db, $table: table), + $$SyncRunsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), Value provider = const Value.absent(), - Value entityType = const Value.absent(), - Value operation = const Value.absent(), - Value operationType = const Value.absent(), - Value taskListId = const Value.absent(), - Value taskId = const Value.absent(), - Value calendarSourceId = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value eventId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davCollectionHref = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davMemberHref = const Value.absent(), - Value baselineEtag = const Value.absent(), - Value baselineRawIcs = const Value.absent(), - Value mutationPatchJson = const Value.absent(), - Value mutationPatchSchemaVersion = const Value.absent(), - Value targetComponentKey = const Value.absent(), - Value mutationScope = const Value.absent(), - Value destinationCollectionId = const Value.absent(), - Value destinationCollectionHref = const Value.absent(), - Value destinationMemberHref = const Value.absent(), - Value conflictState = const Value.absent(), - Value conflictSnapshotId = const Value.absent(), - Value retryClassification = const Value.absent(), - Value localTempId = const Value.absent(), - Value dependsOnOpId = const Value.absent(), - Value requestJson = const Value.absent(), - Value baselineUpdatedUtc = const Value.absent(), - Value baselineRawJson = const Value.absent(), - Value attemptCount = const Value.absent(), - Value nextAttemptAtUtc = const Value.absent(), - Value lastErrorCode = const Value.absent(), - Value lastErrorMessage = const Value.absent(), - Value state = const Value.absent(), - Value lastError = const Value.absent(), - Value createdAtUtc = const Value.absent(), - Value updatedAtUtc = const Value.absent(), + Value mode = const Value.absent(), + Value startedAtUtc = const Value.absent(), + Value finishedAtUtc = const Value.absent(), + Value status = const Value.absent(), + Value taskListsSeen = const Value.absent(), + Value tasksSeen = const Value.absent(), + Value pendingOpsApplied = const Value.absent(), + Value errorCode = const Value.absent(), + Value errorMessage = const Value.absent(), Value rowid = const Value.absent(), - }) => PendingOpsCompanion( + }) => SyncRunsCompanion( id: id, accountId: accountId, provider: provider, - entityType: entityType, - operation: operation, - operationType: operationType, - taskListId: taskListId, - taskId: taskId, - calendarSourceId: calendarSourceId, - providerCalendarId: providerCalendarId, - eventId: eventId, - davCollectionId: davCollectionId, - davCollectionHref: davCollectionHref, - davObjectId: davObjectId, - davMemberHref: davMemberHref, - baselineEtag: baselineEtag, - baselineRawIcs: baselineRawIcs, - mutationPatchJson: mutationPatchJson, - mutationPatchSchemaVersion: mutationPatchSchemaVersion, - targetComponentKey: targetComponentKey, - mutationScope: mutationScope, - destinationCollectionId: destinationCollectionId, - destinationCollectionHref: destinationCollectionHref, - destinationMemberHref: destinationMemberHref, - conflictState: conflictState, - conflictSnapshotId: conflictSnapshotId, - retryClassification: retryClassification, - localTempId: localTempId, - dependsOnOpId: dependsOnOpId, - requestJson: requestJson, - baselineUpdatedUtc: baselineUpdatedUtc, - baselineRawJson: baselineRawJson, - attemptCount: attemptCount, - nextAttemptAtUtc: nextAttemptAtUtc, - lastErrorCode: lastErrorCode, - lastErrorMessage: lastErrorMessage, - state: state, - lastError: lastError, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, + mode: mode, + startedAtUtc: startedAtUtc, + finishedAtUtc: finishedAtUtc, + status: status, + taskListsSeen: taskListsSeen, + tasksSeen: tasksSeen, + pendingOpsApplied: pendingOpsApplied, + errorCode: errorCode, + errorMessage: errorMessage, rowid: rowid, ), createCompanionCallback: @@ -33463,346 +36077,680 @@ class $$PendingOpsTableTableManager required String id, required String accountId, Value provider = const Value.absent(), - required String entityType, - required String operation, - Value operationType = const Value.absent(), - Value taskListId = const Value.absent(), - Value taskId = const Value.absent(), - Value calendarSourceId = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value eventId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davCollectionHref = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davMemberHref = const Value.absent(), - Value baselineEtag = const Value.absent(), - Value baselineRawIcs = const Value.absent(), - Value mutationPatchJson = const Value.absent(), - Value mutationPatchSchemaVersion = const Value.absent(), - Value targetComponentKey = const Value.absent(), - Value mutationScope = const Value.absent(), - Value destinationCollectionId = const Value.absent(), - Value destinationCollectionHref = const Value.absent(), - Value destinationMemberHref = const Value.absent(), - Value conflictState = const Value.absent(), - Value conflictSnapshotId = const Value.absent(), - Value retryClassification = const Value.absent(), - Value localTempId = const Value.absent(), - Value dependsOnOpId = const Value.absent(), - required String requestJson, - Value baselineUpdatedUtc = const Value.absent(), - Value baselineRawJson = const Value.absent(), - Value attemptCount = const Value.absent(), - Value nextAttemptAtUtc = const Value.absent(), - Value lastErrorCode = const Value.absent(), - Value lastErrorMessage = const Value.absent(), - Value state = const Value.absent(), - Value lastError = const Value.absent(), - required String createdAtUtc, - required String updatedAtUtc, + required String mode, + required String startedAtUtc, + Value finishedAtUtc = const Value.absent(), + required String status, + Value taskListsSeen = const Value.absent(), + Value tasksSeen = const Value.absent(), + Value pendingOpsApplied = const Value.absent(), + Value errorCode = const Value.absent(), + Value errorMessage = const Value.absent(), Value rowid = const Value.absent(), - }) => PendingOpsCompanion.insert( + }) => SyncRunsCompanion.insert( id: id, accountId: accountId, provider: provider, - entityType: entityType, - operation: operation, - operationType: operationType, - taskListId: taskListId, - taskId: taskId, - calendarSourceId: calendarSourceId, - providerCalendarId: providerCalendarId, - eventId: eventId, - davCollectionId: davCollectionId, - davCollectionHref: davCollectionHref, - davObjectId: davObjectId, - davMemberHref: davMemberHref, - baselineEtag: baselineEtag, - baselineRawIcs: baselineRawIcs, - mutationPatchJson: mutationPatchJson, - mutationPatchSchemaVersion: mutationPatchSchemaVersion, - targetComponentKey: targetComponentKey, - mutationScope: mutationScope, - destinationCollectionId: destinationCollectionId, - destinationCollectionHref: destinationCollectionHref, - destinationMemberHref: destinationMemberHref, - conflictState: conflictState, - conflictSnapshotId: conflictSnapshotId, - retryClassification: retryClassification, - localTempId: localTempId, - dependsOnOpId: dependsOnOpId, - requestJson: requestJson, - baselineUpdatedUtc: baselineUpdatedUtc, - baselineRawJson: baselineRawJson, - attemptCount: attemptCount, - nextAttemptAtUtc: nextAttemptAtUtc, - lastErrorCode: lastErrorCode, - lastErrorMessage: lastErrorMessage, - state: state, - lastError: lastError, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, + mode: mode, + startedAtUtc: startedAtUtc, + finishedAtUtc: finishedAtUtc, + status: status, + taskListsSeen: taskListsSeen, + tasksSeen: tasksSeen, + pendingOpsApplied: pendingOpsApplied, + errorCode: errorCode, + errorMessage: errorMessage, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$PendingOpsTableReferences(db, table, e), + $$SyncRunsTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: - ({ - accountId = false, - davCollectionId = false, - davObjectId = false, - destinationCollectionId = false, - conflictSnapshotId = false, - }) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$PendingOpsTableReferences - ._accountIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: $$PendingOpsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } - if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: $$PendingOpsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; - } - if (destinationCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: - table.destinationCollectionId, - referencedTable: $$PendingOpsTableReferences - ._destinationCollectionIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._destinationCollectionIdTable(db) - .id, - ) - as T; - } - if (conflictSnapshotId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.conflictSnapshotId, - referencedTable: $$PendingOpsTableReferences - ._conflictSnapshotIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._conflictSnapshotIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: ({accountId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$SyncRunsTableReferences + ._accountIdTable(db), + referencedColumn: $$SyncRunsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$SyncRunsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SyncRunsTable, + SyncRun, + $$SyncRunsTableFilterComposer, + $$SyncRunsTableOrderingComposer, + $$SyncRunsTableAnnotationComposer, + $$SyncRunsTableCreateCompanionBuilder, + $$SyncRunsTableUpdateCompanionBuilder, + (SyncRun, $$SyncRunsTableReferences), + SyncRun, + PrefetchHooks Function({bool accountId}) + >; +typedef $$CalendarSourcesTableCreateCompanionBuilder = + CalendarSourcesCompanion Function({ + required String id, + required String accountId, + required String provider, + required String providerCalendarId, + Value davCollectionId, + required String summary, + Value description, + Value primaryCalendar, + Value selected, + Value remindersEnabled, + Value hidden, + Value readOnly, + Value backgroundColor, + Value foregroundColor, + Value colorId, + Value timeZone, + Value accessRole, + Value dataOwner, + Value isRemovable, + Value isDeleted, + Value rawJson, + required int createdAtLocal, + required int updatedAtLocal, + Value rowid, + }); +typedef $$CalendarSourcesTableUpdateCompanionBuilder = + CalendarSourcesCompanion Function({ + Value id, + Value accountId, + Value provider, + Value providerCalendarId, + Value davCollectionId, + Value summary, + Value description, + Value primaryCalendar, + Value selected, + Value remindersEnabled, + Value hidden, + Value readOnly, + Value backgroundColor, + Value foregroundColor, + Value colorId, + Value timeZone, + Value accessRole, + Value dataOwner, + Value isRemovable, + Value isDeleted, + Value rawJson, + Value createdAtLocal, + Value updatedAtLocal, + Value rowid, + }); + +final class $$CalendarSourcesTableReferences + extends + BaseReferences<_$AppDatabase, $CalendarSourcesTable, CalendarSource> { + $$CalendarSourcesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.calendarSources.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator( + db.calendarSources.davCollectionId, + db.davCollections.id, + ), + ); + + $$DavCollectionsTableProcessedTableManager? get davCollectionId { + final $_column = $_itemColumn('dav_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static MultiTypedResultKey<$CalendarEventsTable, List> + _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.calendarEvents, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.calendarEvents.calendarSourceId, + ), + ); + + $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { + final manager = $$CalendarEventsTableTableManager($_db, $_db.calendarEvents) + .filter( + (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$IcalImportReceiptsTable, List> + _icalImportReceiptsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.icalImportReceipts, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.icalImportReceipts.calendarSourceId, + ), + ); + + $$IcalImportReceiptsTableProcessedTableManager get icalImportReceiptsRefs { + final manager = + $$IcalImportReceiptsTableTableManager( + $_db, + $_db.icalImportReceipts, + ).filter( + (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _icalImportReceiptsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey< + $WebCalSubscriptionsTable, + List + > + _webCalSubscriptionsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.webCalSubscriptions, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.webCalSubscriptions.calendarSourceId, + ), + ); + + $$WebCalSubscriptionsTableProcessedTableManager get webCalSubscriptionsRefs { + final manager = + $$WebCalSubscriptionsTableTableManager( + $_db, + $_db.webCalSubscriptions, + ).filter( + (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _webCalSubscriptionsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + + static MultiTypedResultKey<$SyncCursorsTable, List> + _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.syncCursors, + aliasName: $_aliasNameGenerator( + db.calendarSources.id, + db.syncCursors.projectionSourceId, + ), + ); + + $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { + final manager = $$SyncCursorsTableTableManager($_db, $_db.syncCursors) + .filter( + (f) => f.projectionSourceId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$CalendarSourcesTableFilterComposer + extends Composer<_$AppDatabase, $CalendarSourcesTable> { + $$CalendarSourcesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get primaryCalendar => $composableBuilder( + column: $table.primaryCalendar, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get selected => $composableBuilder( + column: $table.selected, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get hidden => $composableBuilder( + column: $table.hidden, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get readOnly => $composableBuilder( + column: $table.readOnly, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get backgroundColor => $composableBuilder( + column: $table.backgroundColor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get foregroundColor => $composableBuilder( + column: $table.foregroundColor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get colorId => $composableBuilder( + column: $table.colorId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timeZone => $composableBuilder( + column: $table.timeZone, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accessRole => $composableBuilder( + column: $table.accessRole, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dataOwner => $composableBuilder( + column: $table.dataOwner, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => ColumnFilters(column), + ); - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; - }, - ); - }, - ), - ); -} + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } -typedef $$PendingOpsTableProcessedTableManager = - ProcessedTableManager< - _$AppDatabase, - $PendingOpsTable, - PendingOp, - $$PendingOpsTableFilterComposer, - $$PendingOpsTableOrderingComposer, - $$PendingOpsTableAnnotationComposer, - $$PendingOpsTableCreateCompanionBuilder, - $$PendingOpsTableUpdateCompanionBuilder, - (PendingOp, $$PendingOpsTableReferences), - PendingOp, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool davObjectId, - bool destinationCollectionId, - bool conflictSnapshotId, - }) - >; -typedef $$SyncRunsTableCreateCompanionBuilder = - SyncRunsCompanion Function({ - required String id, - required String accountId, - Value provider, - required String mode, - required String startedAtUtc, - Value finishedAtUtc, - required String status, - Value taskListsSeen, - Value tasksSeen, - Value pendingOpsApplied, - Value errorCode, - Value errorMessage, - Value rowid, - }); -typedef $$SyncRunsTableUpdateCompanionBuilder = - SyncRunsCompanion Function({ - Value id, - Value accountId, - Value provider, - Value mode, - Value startedAtUtc, - Value finishedAtUtc, - Value status, - Value taskListsSeen, - Value tasksSeen, - Value pendingOpsApplied, - Value errorCode, - Value errorMessage, - Value rowid, - }); + $$DavCollectionsTableFilterComposer get davCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableFilterComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } -final class $$SyncRunsTableReferences - extends BaseReferences<_$AppDatabase, $SyncRunsTable, SyncRun> { - $$SyncRunsTableReferences(super.$_db, super.$_table, super.$_typedResult); + Expression calendarEventsRefs( + Expression Function($$CalendarEventsTableFilterComposer f) f, + ) { + final $$CalendarEventsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventsTableFilterComposer( + $db: $db, + $table: $db.calendarEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } - static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts - .createAlias($_aliasNameGenerator(db.syncRuns.accountId, db.accounts.id)); + Expression icalImportReceiptsRefs( + Expression Function($$IcalImportReceiptsTableFilterComposer f) f, + ) { + final $$IcalImportReceiptsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.icalImportReceipts, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$IcalImportReceiptsTableFilterComposer( + $db: $db, + $table: $db.icalImportReceipts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableFilterComposer f) f, + ) { + final $$WebCalSubscriptionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableFilterComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + Expression syncCursorsRefs( + Expression Function($$SyncCursorsTableFilterComposer f) f, + ) { + final $$SyncCursorsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncCursors, + getReferencedColumn: (t) => t.projectionSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SyncCursorsTableFilterComposer( + $db: $db, + $table: $db.syncCursors, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return f(composer); } } -class $$SyncRunsTableFilterComposer - extends Composer<_$AppDatabase, $SyncRunsTable> { - $$SyncRunsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), +class $$CalendarSourcesTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarSourcesTable> { + $$CalendarSourcesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get primaryCalendar => $composableBuilder( + column: $table.primaryCalendar, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get selected => $composableBuilder( + column: $table.selected, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get hidden => $composableBuilder( + column: $table.hidden, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get readOnly => $composableBuilder( + column: $table.readOnly, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnFilters(column), + ColumnOrderings get backgroundColor => $composableBuilder( + column: $table.backgroundColor, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get mode => $composableBuilder( - column: $table.mode, - builder: (column) => ColumnFilters(column), + ColumnOrderings get foregroundColor => $composableBuilder( + column: $table.foregroundColor, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get startedAtUtc => $composableBuilder( - column: $table.startedAtUtc, - builder: (column) => ColumnFilters(column), + ColumnOrderings get colorId => $composableBuilder( + column: $table.colorId, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get finishedAtUtc => $composableBuilder( - column: $table.finishedAtUtc, - builder: (column) => ColumnFilters(column), + ColumnOrderings get timeZone => $composableBuilder( + column: $table.timeZone, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnFilters(column), + ColumnOrderings get accessRole => $composableBuilder( + column: $table.accessRole, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get taskListsSeen => $composableBuilder( - column: $table.taskListsSeen, - builder: (column) => ColumnFilters(column), + ColumnOrderings get dataOwner => $composableBuilder( + column: $table.dataOwner, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get tasksSeen => $composableBuilder( - column: $table.tasksSeen, - builder: (column) => ColumnFilters(column), + ColumnOrderings get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get pendingOpsApplied => $composableBuilder( - column: $table.pendingOpsApplied, - builder: (column) => ColumnFilters(column), + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get errorCode => $composableBuilder( - column: $table.errorCode, - builder: (column) => ColumnFilters(column), + ColumnOrderings get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get errorMessage => $composableBuilder( - column: $table.errorMessage, - builder: (column) => ColumnFilters(column), + ColumnOrderings get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => ColumnOrderings(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( + ColumnOrderings get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => ColumnOrderings(column), + ); + + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.accountId, referencedTable: $db.accounts, @@ -33812,7 +36760,7 @@ class $$SyncRunsTableFilterComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( + }) => $$AccountsTableOrderingComposer( $db: $db, $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -33823,384 +36771,712 @@ class $$SyncRunsTableFilterComposer ); return composer; } + + $$DavCollectionsTableOrderingComposer get davCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableOrderingComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$SyncRunsTableOrderingComposer - extends Composer<_$AppDatabase, $SyncRunsTable> { - $$SyncRunsTableOrderingComposer({ +class $$CalendarSourcesTableAnnotationComposer + extends Composer<_$AppDatabase, $CalendarSourcesTable> { + $$CalendarSourcesTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - ColumnOrderings get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get provider => + $composableBuilder(column: $table.provider, builder: (column) => column); + + GeneratedColumn get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => column, ); - ColumnOrderings get mode => $composableBuilder( - column: $table.mode, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, ); - ColumnOrderings get startedAtUtc => $composableBuilder( - column: $table.startedAtUtc, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get primaryCalendar => $composableBuilder( + column: $table.primaryCalendar, + builder: (column) => column, ); - ColumnOrderings get finishedAtUtc => $composableBuilder( - column: $table.finishedAtUtc, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get selected => + $composableBuilder(column: $table.selected, builder: (column) => column); + + GeneratedColumn get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => column, ); - ColumnOrderings get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get hidden => + $composableBuilder(column: $table.hidden, builder: (column) => column); + + GeneratedColumn get readOnly => + $composableBuilder(column: $table.readOnly, builder: (column) => column); + + GeneratedColumn get backgroundColor => $composableBuilder( + column: $table.backgroundColor, + builder: (column) => column, ); - ColumnOrderings get taskListsSeen => $composableBuilder( - column: $table.taskListsSeen, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get foregroundColor => $composableBuilder( + column: $table.foregroundColor, + builder: (column) => column, ); - ColumnOrderings get tasksSeen => $composableBuilder( - column: $table.tasksSeen, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get colorId => + $composableBuilder(column: $table.colorId, builder: (column) => column); + + GeneratedColumn get timeZone => + $composableBuilder(column: $table.timeZone, builder: (column) => column); + + GeneratedColumn get accessRole => $composableBuilder( + column: $table.accessRole, + builder: (column) => column, ); - ColumnOrderings get pendingOpsApplied => $composableBuilder( - column: $table.pendingOpsApplied, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get dataOwner => + $composableBuilder(column: $table.dataOwner, builder: (column) => column); + + GeneratedColumn get isRemovable => $composableBuilder( + column: $table.isRemovable, + builder: (column) => column, ); - ColumnOrderings get errorCode => $composableBuilder( - column: $table.errorCode, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + + GeneratedColumn get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => column, ); - ColumnOrderings get errorMessage => $composableBuilder( - column: $table.errorMessage, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => column, ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableAnnotationComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression calendarEventsRefs( + Expression Function($$CalendarEventsTableAnnotationComposer a) f, + ) { + final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.calendarSourceId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( + }) => $$CalendarEventsTableAnnotationComposer( $db: $db, - $table: $db.accounts, + $table: $db.calendarEvents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return composer; + return f(composer); } -} - -class $$SyncRunsTableAnnotationComposer - extends Composer<_$AppDatabase, $SyncRunsTable> { - $$SyncRunsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get provider => - $composableBuilder(column: $table.provider, builder: (column) => column); - - GeneratedColumn get mode => - $composableBuilder(column: $table.mode, builder: (column) => column); - - GeneratedColumn get startedAtUtc => $composableBuilder( - column: $table.startedAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get finishedAtUtc => $composableBuilder( - column: $table.finishedAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get status => - $composableBuilder(column: $table.status, builder: (column) => column); - - GeneratedColumn get taskListsSeen => $composableBuilder( - column: $table.taskListsSeen, - builder: (column) => column, - ); - - GeneratedColumn get tasksSeen => - $composableBuilder(column: $table.tasksSeen, builder: (column) => column); - - GeneratedColumn get pendingOpsApplied => $composableBuilder( - column: $table.pendingOpsApplied, - builder: (column) => column, - ); - GeneratedColumn get errorCode => - $composableBuilder(column: $table.errorCode, builder: (column) => column); + Expression icalImportReceiptsRefs( + Expression Function($$IcalImportReceiptsTableAnnotationComposer a) f, + ) { + final $$IcalImportReceiptsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.icalImportReceipts, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$IcalImportReceiptsTableAnnotationComposer( + $db: $db, + $table: $db.icalImportReceipts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } - GeneratedColumn get errorMessage => $composableBuilder( - column: $table.errorMessage, - builder: (column) => column, - ); + Expression webCalSubscriptionsRefs( + Expression Function($$WebCalSubscriptionsTableAnnotationComposer a) f, + ) { + final $$WebCalSubscriptionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.webCalSubscriptions, + getReferencedColumn: (t) => t.calendarSourceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebCalSubscriptionsTableAnnotationComposer( + $db: $db, + $table: $db.webCalSubscriptions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( + Expression syncCursorsRefs( + Expression Function($$SyncCursorsTableAnnotationComposer a) f, + ) { + final $$SyncCursorsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, + getCurrentColumn: (t) => t.id, + referencedTable: $db.syncCursors, + getReferencedColumn: (t) => t.projectionSourceId, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( + }) => $$SyncCursorsTableAnnotationComposer( $db: $db, - $table: $db.accounts, + $table: $db.syncCursors, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); - return composer; + return f(composer); } } -class $$SyncRunsTableTableManager +class $$CalendarSourcesTableTableManager extends RootTableManager< _$AppDatabase, - $SyncRunsTable, - SyncRun, - $$SyncRunsTableFilterComposer, - $$SyncRunsTableOrderingComposer, - $$SyncRunsTableAnnotationComposer, - $$SyncRunsTableCreateCompanionBuilder, - $$SyncRunsTableUpdateCompanionBuilder, - (SyncRun, $$SyncRunsTableReferences), - SyncRun, - PrefetchHooks Function({bool accountId}) + $CalendarSourcesTable, + CalendarSource, + $$CalendarSourcesTableFilterComposer, + $$CalendarSourcesTableOrderingComposer, + $$CalendarSourcesTableAnnotationComposer, + $$CalendarSourcesTableCreateCompanionBuilder, + $$CalendarSourcesTableUpdateCompanionBuilder, + (CalendarSource, $$CalendarSourcesTableReferences), + CalendarSource, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool calendarEventsRefs, + bool icalImportReceiptsRefs, + bool webCalSubscriptionsRefs, + bool syncCursorsRefs, + }) > { - $$SyncRunsTableTableManager(_$AppDatabase db, $SyncRunsTable table) - : super( + $$CalendarSourcesTableTableManager( + _$AppDatabase db, + $CalendarSourcesTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$SyncRunsTableFilterComposer($db: db, $table: table), + $$CalendarSourcesTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$SyncRunsTableOrderingComposer($db: db, $table: table), + $$CalendarSourcesTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$SyncRunsTableAnnotationComposer($db: db, $table: table), + $$CalendarSourcesTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), - Value provider = const Value.absent(), - Value mode = const Value.absent(), - Value startedAtUtc = const Value.absent(), - Value finishedAtUtc = const Value.absent(), - Value status = const Value.absent(), - Value taskListsSeen = const Value.absent(), - Value tasksSeen = const Value.absent(), - Value pendingOpsApplied = const Value.absent(), - Value errorCode = const Value.absent(), - Value errorMessage = const Value.absent(), + Value provider = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value summary = const Value.absent(), + Value description = const Value.absent(), + Value primaryCalendar = const Value.absent(), + Value selected = const Value.absent(), + Value remindersEnabled = const Value.absent(), + Value hidden = const Value.absent(), + Value readOnly = const Value.absent(), + Value backgroundColor = const Value.absent(), + Value foregroundColor = const Value.absent(), + Value colorId = const Value.absent(), + Value timeZone = const Value.absent(), + Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + Value createdAtLocal = const Value.absent(), + Value updatedAtLocal = const Value.absent(), Value rowid = const Value.absent(), - }) => SyncRunsCompanion( + }) => CalendarSourcesCompanion( id: id, accountId: accountId, provider: provider, - mode: mode, - startedAtUtc: startedAtUtc, - finishedAtUtc: finishedAtUtc, - status: status, - taskListsSeen: taskListsSeen, - tasksSeen: tasksSeen, - pendingOpsApplied: pendingOpsApplied, - errorCode: errorCode, - errorMessage: errorMessage, + providerCalendarId: providerCalendarId, + davCollectionId: davCollectionId, + summary: summary, + description: description, + primaryCalendar: primaryCalendar, + selected: selected, + remindersEnabled: remindersEnabled, + hidden: hidden, + readOnly: readOnly, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + colorId: colorId, + timeZone: timeZone, + accessRole: accessRole, + dataOwner: dataOwner, + isRemovable: isRemovable, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), createCompanionCallback: ({ required String id, required String accountId, - Value provider = const Value.absent(), - required String mode, - required String startedAtUtc, - Value finishedAtUtc = const Value.absent(), - required String status, - Value taskListsSeen = const Value.absent(), - Value tasksSeen = const Value.absent(), - Value pendingOpsApplied = const Value.absent(), - Value errorCode = const Value.absent(), - Value errorMessage = const Value.absent(), + required String provider, + required String providerCalendarId, + Value davCollectionId = const Value.absent(), + required String summary, + Value description = const Value.absent(), + Value primaryCalendar = const Value.absent(), + Value selected = const Value.absent(), + Value remindersEnabled = const Value.absent(), + Value hidden = const Value.absent(), + Value readOnly = const Value.absent(), + Value backgroundColor = const Value.absent(), + Value foregroundColor = const Value.absent(), + Value colorId = const Value.absent(), + Value timeZone = const Value.absent(), + Value accessRole = const Value.absent(), + Value dataOwner = const Value.absent(), + Value isRemovable = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + required int createdAtLocal, + required int updatedAtLocal, Value rowid = const Value.absent(), - }) => SyncRunsCompanion.insert( + }) => CalendarSourcesCompanion.insert( id: id, accountId: accountId, provider: provider, - mode: mode, - startedAtUtc: startedAtUtc, - finishedAtUtc: finishedAtUtc, - status: status, - taskListsSeen: taskListsSeen, - tasksSeen: tasksSeen, - pendingOpsApplied: pendingOpsApplied, - errorCode: errorCode, - errorMessage: errorMessage, + providerCalendarId: providerCalendarId, + davCollectionId: davCollectionId, + summary: summary, + description: description, + primaryCalendar: primaryCalendar, + selected: selected, + remindersEnabled: remindersEnabled, + hidden: hidden, + readOnly: readOnly, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + colorId: colorId, + timeZone: timeZone, + accessRole: accessRole, + dataOwner: dataOwner, + isRemovable: isRemovable, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$SyncRunsTableReferences(db, table, e), + $$CalendarSourcesTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: ({accountId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$SyncRunsTableReferences - ._accountIdTable(db), - referencedColumn: $$SyncRunsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: + ({ + accountId = false, + davCollectionId = false, + calendarEventsRefs = false, + icalImportReceiptsRefs = false, + webCalSubscriptionsRefs = false, + syncCursorsRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (calendarEventsRefs) db.calendarEvents, + if (icalImportReceiptsRefs) db.icalImportReceipts, + if (webCalSubscriptionsRefs) db.webCalSubscriptions, + if (syncCursorsRefs) db.syncCursors, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$CalendarSourcesTableReferences + ._accountIdTable(db), + referencedColumn: + $$CalendarSourcesTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (davCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: + $$CalendarSourcesTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$CalendarSourcesTableReferences + ._davCollectionIdTable(db) + .id, + ) + as T; + } - return state; + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (calendarEventsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + CalendarEvent + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._calendarEventsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).calendarEventsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarSourceId == item.id, + ), + typedResults: items, + ), + if (icalImportReceiptsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + IcalImportReceipt + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._icalImportReceiptsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).icalImportReceiptsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarSourceId == item.id, + ), + typedResults: items, + ), + if (webCalSubscriptionsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + WebCalSubscription + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._webCalSubscriptionsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).webCalSubscriptionsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarSourceId == item.id, + ), + typedResults: items, + ), + if (syncCursorsRefs) + await $_getPrefetchedData< + CalendarSource, + $CalendarSourcesTable, + SyncCursor + >( + currentTable: table, + referencedTable: $$CalendarSourcesTableReferences + ._syncCursorsRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarSourcesTableReferences( + db, + table, + p0, + ).syncCursorsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.projectionSourceId == item.id, + ), + typedResults: items, + ), + ]; }, - getPrefetchedDataCallback: (items) async { - return []; + ); }, - ); - }, ), ); } -typedef $$SyncRunsTableProcessedTableManager = +typedef $$CalendarSourcesTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $SyncRunsTable, - SyncRun, - $$SyncRunsTableFilterComposer, - $$SyncRunsTableOrderingComposer, - $$SyncRunsTableAnnotationComposer, - $$SyncRunsTableCreateCompanionBuilder, - $$SyncRunsTableUpdateCompanionBuilder, - (SyncRun, $$SyncRunsTableReferences), - SyncRun, - PrefetchHooks Function({bool accountId}) + $CalendarSourcesTable, + CalendarSource, + $$CalendarSourcesTableFilterComposer, + $$CalendarSourcesTableOrderingComposer, + $$CalendarSourcesTableAnnotationComposer, + $$CalendarSourcesTableCreateCompanionBuilder, + $$CalendarSourcesTableUpdateCompanionBuilder, + (CalendarSource, $$CalendarSourcesTableReferences), + CalendarSource, + PrefetchHooks Function({ + bool accountId, + bool davCollectionId, + bool calendarEventsRefs, + bool icalImportReceiptsRefs, + bool webCalSubscriptionsRefs, + bool syncCursorsRefs, + }) >; -typedef $$CalendarSourcesTableCreateCompanionBuilder = - CalendarSourcesCompanion Function({ +typedef $$CalendarEventsTableCreateCompanionBuilder = + CalendarEventsCompanion Function({ required String id, required String accountId, + required String calendarSourceId, required String provider, required String providerCalendarId, + required String providerEventId, Value davCollectionId, - required String summary, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value occurrenceKey, + Value projectionVersion, + Value providerRecurringEventId, + Value providerOriginalStartKey, + Value etagOrChangeKey, + Value status, + required String title, Value description, - Value primaryCalendar, - Value selected, - Value remindersEnabled, - Value hidden, - Value readOnly, - Value backgroundColor, - Value foregroundColor, + Value location, + Value allDay, + Value startDate, + Value startDateTime, + Value startTimeZone, + Value endDate, + Value endDateTime, + Value endTimeZone, + Value recurrenceJson, + Value remindersJson, + Value attendeesJson, + Value categoriesJson, + Value organizerJson, + Value creatorJson, Value colorId, - Value timeZone, - Value accessRole, - Value dataOwner, - Value isRemovable, + Value colorHex, + Value visibility, + Value transparencyOrShowAs, + Value eventType, + Value webLink, + Value conferenceJson, + Value attachmentsJson, + Value isCancelled, Value isDeleted, Value rawJson, + Value createdAtServer, + Value updatedAtServer, required int createdAtLocal, required int updatedAtLocal, + Value syncStatus, + Value baselineRawJson, Value rowid, }); -typedef $$CalendarSourcesTableUpdateCompanionBuilder = - CalendarSourcesCompanion Function({ +typedef $$CalendarEventsTableUpdateCompanionBuilder = + CalendarEventsCompanion Function({ Value id, Value accountId, + Value calendarSourceId, Value provider, Value providerCalendarId, + Value providerEventId, Value davCollectionId, - Value summary, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value occurrenceKey, + Value projectionVersion, + Value providerRecurringEventId, + Value providerOriginalStartKey, + Value etagOrChangeKey, + Value status, + Value title, Value description, - Value primaryCalendar, - Value selected, - Value remindersEnabled, - Value hidden, - Value readOnly, - Value backgroundColor, - Value foregroundColor, + Value location, + Value allDay, + Value startDate, + Value startDateTime, + Value startTimeZone, + Value endDate, + Value endDateTime, + Value endTimeZone, + Value recurrenceJson, + Value remindersJson, + Value attendeesJson, + Value categoriesJson, + Value organizerJson, + Value creatorJson, Value colorId, - Value timeZone, - Value accessRole, - Value dataOwner, - Value isRemovable, + Value colorHex, + Value visibility, + Value transparencyOrShowAs, + Value eventType, + Value webLink, + Value conferenceJson, + Value attachmentsJson, + Value isCancelled, Value isDeleted, Value rawJson, + Value createdAtServer, + Value updatedAtServer, Value createdAtLocal, Value updatedAtLocal, + Value syncStatus, + Value baselineRawJson, Value rowid, }); -final class $$CalendarSourcesTableReferences - extends - BaseReferences<_$AppDatabase, $CalendarSourcesTable, CalendarSource> { - $$CalendarSourcesTableReferences( +final class $$CalendarEventsTableReferences + extends BaseReferences<_$AppDatabase, $CalendarEventsTable, CalendarEvent> { + $$CalendarEventsTableReferences( super.$_db, super.$_table, super.$_typedResult, @@ -34208,7 +37484,7 @@ final class $$CalendarSourcesTableReferences static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts.createAlias( - $_aliasNameGenerator(db.calendarSources.accountId, db.accounts.id), + $_aliasNameGenerator(db.calendarEvents.accountId, db.accounts.id), ); $$AccountsTableProcessedTableManager get accountId { @@ -34225,537 +37501,390 @@ final class $$CalendarSourcesTableReferences ); } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.calendarSources.davCollectionId, - db.davCollections.id, - ), - ); - - $$DavCollectionsTableProcessedTableManager? get davCollectionId { - final $_column = $_itemColumn('dav_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static MultiTypedResultKey<$CalendarEventsTable, List> - _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.calendarEvents.calendarSourceId, - ), - ); - - $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { - final manager = $$CalendarEventsTableTableManager($_db, $_db.calendarEvents) - .filter( - (f) => f.calendarSourceId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull(_calendarEventsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } - - static MultiTypedResultKey<$SyncCursorsTable, List> - _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( - db.syncCursors, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.syncCursors.projectionSourceId, - ), - ); - - $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { - final manager = $$SyncCursorsTableTableManager($_db, $_db.syncCursors) - .filter( - (f) => f.projectionSourceId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull(_syncCursorsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), - ); - } -} - -class $$CalendarSourcesTableFilterComposer - extends Composer<_$AppDatabase, $CalendarSourcesTable> { - $$CalendarSourcesTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get summary => $composableBuilder( - column: $table.summary, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get description => $composableBuilder( - column: $table.description, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get primaryCalendar => $composableBuilder( - column: $table.primaryCalendar, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get selected => $composableBuilder( - column: $table.selected, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get remindersEnabled => $composableBuilder( - column: $table.remindersEnabled, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get hidden => $composableBuilder( - column: $table.hidden, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get readOnly => $composableBuilder( - column: $table.readOnly, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get backgroundColor => $composableBuilder( - column: $table.backgroundColor, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get foregroundColor => $composableBuilder( - column: $table.foregroundColor, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get colorId => $composableBuilder( - column: $table.colorId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get timeZone => $composableBuilder( - column: $table.timeZone, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get accessRole => $composableBuilder( - column: $table.accessRole, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get dataOwner => $composableBuilder( - column: $table.dataOwner, - builder: (column) => ColumnFilters(column), - ); + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => + db.calendarSources.createAlias( + $_aliasNameGenerator( + db.calendarEvents.calendarSourceId, + db.calendarSources.id, + ), + ); - ColumnFilters get isRemovable => $composableBuilder( - column: $table.isRemovable, - builder: (column) => ColumnFilters(column), - ); + $$CalendarSourcesTableProcessedTableManager get calendarSourceId { + final $_column = $_itemColumn('calendar_source_id')!; - ColumnFilters get isDeleted => $composableBuilder( - column: $table.isDeleted, - builder: (column) => ColumnFilters(column), - ); + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } - ColumnFilters get rawJson => $composableBuilder( - column: $table.rawJson, - builder: (column) => ColumnFilters(column), - ); + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => + db.davCollections.createAlias( + $_aliasNameGenerator( + db.calendarEvents.davCollectionId, + db.davCollections.id, + ), + ); - ColumnFilters get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, - builder: (column) => ColumnFilters(column), - ); + $$DavCollectionsTableProcessedTableManager? get davCollectionId { + final $_column = $_itemColumn('dav_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } - ColumnFilters get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, - builder: (column) => ColumnFilters(column), - ); + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => + db.davObjects.createAlias( + $_aliasNameGenerator(db.calendarEvents.davObjectId, db.davObjects.id), + ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), + $$DavObjectsTableProcessedTableManager? get davObjectId { + final $_column = $_itemColumn('dav_object_id'); + if ($_column == null) return null; + final manager = $$DavObjectsTableTableManager( + $_db, + $_db.davObjects, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), ); - return composer; } - $$DavCollectionsTableFilterComposer get davCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), + static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => + db.davObjectComponents.createAlias( + $_aliasNameGenerator( + db.calendarEvents.davComponentId, + db.davObjectComponents.id, + ), + ); + + $$DavObjectComponentsTableProcessedTableManager? get davComponentId { + final $_column = $_itemColumn('dav_component_id'); + if ($_column == null) return null; + final manager = $$DavObjectComponentsTableTableManager( + $_db, + $_db.davObjectComponents, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davComponentIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), ); - return composer; } - Expression calendarEventsRefs( - Expression Function($$CalendarEventsTableFilterComposer f) f, - ) { - final $$CalendarEventsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.calendarSourceId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableFilterComposer( - $db: $db, - $table: $db.calendarEvents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), + static MultiTypedResultKey< + $CalendarEventAttendeesTable, + List + > + _calendarEventAttendeesRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.calendarEventAttendees, + aliasName: $_aliasNameGenerator( + db.calendarEvents.id, + db.calendarEventAttendees.calendarEventId, + ), + ); + + $$CalendarEventAttendeesTableProcessedTableManager + get calendarEventAttendeesRefs { + final manager = + $$CalendarEventAttendeesTableTableManager( + $_db, + $_db.calendarEventAttendees, + ).filter( + (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _calendarEventAttendeesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), ); - return f(composer); } - Expression syncCursorsRefs( - Expression Function($$SyncCursorsTableFilterComposer f) f, - ) { - final $$SyncCursorsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.syncCursors, - getReferencedColumn: (t) => t.projectionSourceId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$SyncCursorsTableFilterComposer( - $db: $db, - $table: $db.syncCursors, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), + static MultiTypedResultKey< + $CalendarEventRemindersTable, + List + > + _calendarEventRemindersRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.calendarEventReminders, + aliasName: $_aliasNameGenerator( + db.calendarEvents.id, + db.calendarEventReminders.calendarEventId, + ), + ); + + $$CalendarEventRemindersTableProcessedTableManager + get calendarEventRemindersRefs { + final manager = + $$CalendarEventRemindersTableTableManager( + $_db, + $_db.calendarEventReminders, + ).filter( + (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _calendarEventRemindersRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), ); - return f(composer); } } -class $$CalendarSourcesTableOrderingComposer - extends Composer<_$AppDatabase, $CalendarSourcesTable> { - $$CalendarSourcesTableOrderingComposer({ +class $$CalendarEventsTableFilterComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( + ColumnFilters get id => $composableBuilder( column: $table.id, - builder: (column) => ColumnOrderings(column), + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get provider => $composableBuilder( + ColumnFilters get provider => $composableBuilder( column: $table.provider, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get summary => $composableBuilder( - column: $table.summary, - builder: (column) => ColumnOrderings(column), + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get description => $composableBuilder( - column: $table.description, - builder: (column) => ColumnOrderings(column), + ColumnFilters get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get primaryCalendar => $composableBuilder( - column: $table.primaryCalendar, - builder: (column) => ColumnOrderings(column), + ColumnFilters get providerEventId => $composableBuilder( + column: $table.providerEventId, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get selected => $composableBuilder( - column: $table.selected, - builder: (column) => ColumnOrderings(column), + ColumnFilters get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get remindersEnabled => $composableBuilder( - column: $table.remindersEnabled, - builder: (column) => ColumnOrderings(column), + ColumnFilters get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get hidden => $composableBuilder( - column: $table.hidden, - builder: (column) => ColumnOrderings(column), + ColumnFilters get occurrenceKey => $composableBuilder( + column: $table.occurrenceKey, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get readOnly => $composableBuilder( - column: $table.readOnly, - builder: (column) => ColumnOrderings(column), + ColumnFilters get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get backgroundColor => $composableBuilder( - column: $table.backgroundColor, - builder: (column) => ColumnOrderings(column), + ColumnFilters get providerRecurringEventId => $composableBuilder( + column: $table.providerRecurringEventId, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get foregroundColor => $composableBuilder( - column: $table.foregroundColor, - builder: (column) => ColumnOrderings(column), + ColumnFilters get providerOriginalStartKey => $composableBuilder( + column: $table.providerOriginalStartKey, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get colorId => $composableBuilder( - column: $table.colorId, - builder: (column) => ColumnOrderings(column), + ColumnFilters get etagOrChangeKey => $composableBuilder( + column: $table.etagOrChangeKey, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get timeZone => $composableBuilder( - column: $table.timeZone, - builder: (column) => ColumnOrderings(column), + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get accessRole => $composableBuilder( - column: $table.accessRole, - builder: (column) => ColumnOrderings(column), + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get dataOwner => $composableBuilder( - column: $table.dataOwner, - builder: (column) => ColumnOrderings(column), + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get isRemovable => $composableBuilder( - column: $table.isRemovable, - builder: (column) => ColumnOrderings(column), + ColumnFilters get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get isDeleted => $composableBuilder( - column: $table.isDeleted, - builder: (column) => ColumnOrderings(column), + ColumnFilters get allDay => $composableBuilder( + column: $table.allDay, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get rawJson => $composableBuilder( - column: $table.rawJson, - builder: (column) => ColumnOrderings(column), + ColumnFilters get startDate => $composableBuilder( + column: $table.startDate, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, - builder: (column) => ColumnOrderings(column), + ColumnFilters get startDateTime => $composableBuilder( + column: $table.startDateTime, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, - builder: (column) => ColumnOrderings(column), + ColumnFilters get startTimeZone => $composableBuilder( + column: $table.startTimeZone, + builder: (column) => ColumnFilters(column), ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + ColumnFilters get endDate => $composableBuilder( + column: $table.endDate, + builder: (column) => ColumnFilters(column), + ); - $$DavCollectionsTableOrderingComposer get davCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } -} + ColumnFilters get endDateTime => $composableBuilder( + column: $table.endDateTime, + builder: (column) => ColumnFilters(column), + ); -class $$CalendarSourcesTableAnnotationComposer - extends Composer<_$AppDatabase, $CalendarSourcesTable> { - $$CalendarSourcesTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + ColumnFilters get endTimeZone => $composableBuilder( + column: $table.endTimeZone, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get provider => - $composableBuilder(column: $table.provider, builder: (column) => column); + ColumnFilters get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => column, + ColumnFilters get remindersJson => $composableBuilder( + column: $table.remindersJson, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get summary => - $composableBuilder(column: $table.summary, builder: (column) => column); + ColumnFilters get attendeesJson => $composableBuilder( + column: $table.attendeesJson, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get description => $composableBuilder( - column: $table.description, - builder: (column) => column, + ColumnFilters get categoriesJson => $composableBuilder( + column: $table.categoriesJson, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get primaryCalendar => $composableBuilder( - column: $table.primaryCalendar, - builder: (column) => column, + ColumnFilters get organizerJson => $composableBuilder( + column: $table.organizerJson, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get selected => - $composableBuilder(column: $table.selected, builder: (column) => column); + ColumnFilters get creatorJson => $composableBuilder( + column: $table.creatorJson, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get remindersEnabled => $composableBuilder( - column: $table.remindersEnabled, - builder: (column) => column, + ColumnFilters get colorId => $composableBuilder( + column: $table.colorId, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get hidden => - $composableBuilder(column: $table.hidden, builder: (column) => column); + ColumnFilters get colorHex => $composableBuilder( + column: $table.colorHex, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get readOnly => - $composableBuilder(column: $table.readOnly, builder: (column) => column); + ColumnFilters get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get backgroundColor => $composableBuilder( - column: $table.backgroundColor, - builder: (column) => column, + ColumnFilters get transparencyOrShowAs => $composableBuilder( + column: $table.transparencyOrShowAs, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get foregroundColor => $composableBuilder( - column: $table.foregroundColor, - builder: (column) => column, + ColumnFilters get eventType => $composableBuilder( + column: $table.eventType, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get colorId => - $composableBuilder(column: $table.colorId, builder: (column) => column); + ColumnFilters get webLink => $composableBuilder( + column: $table.webLink, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get timeZone => - $composableBuilder(column: $table.timeZone, builder: (column) => column); + ColumnFilters get conferenceJson => $composableBuilder( + column: $table.conferenceJson, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get accessRole => $composableBuilder( - column: $table.accessRole, - builder: (column) => column, + ColumnFilters get attachmentsJson => $composableBuilder( + column: $table.attachmentsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isCancelled => $composableBuilder( + column: $table.isCancelled, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get dataOwner => - $composableBuilder(column: $table.dataOwner, builder: (column) => column); + ColumnFilters get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get isRemovable => $composableBuilder( - column: $table.isRemovable, - builder: (column) => column, + ColumnFilters get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get isDeleted => - $composableBuilder(column: $table.isDeleted, builder: (column) => column); + ColumnFilters get createdAtServer => $composableBuilder( + column: $table.createdAtServer, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); + ColumnFilters get updatedAtServer => $composableBuilder( + column: $table.updatedAtServer, + builder: (column) => ColumnFilters(column), + ); - GeneratedColumn get createdAtLocal => $composableBuilder( + ColumnFilters get createdAtLocal => $composableBuilder( column: $table.createdAtLocal, - builder: (column) => column, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get updatedAtLocal => $composableBuilder( + ColumnFilters get updatedAtLocal => $composableBuilder( column: $table.updatedAtLocal, - builder: (column) => column, + builder: (column) => ColumnFilters(column), ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( + ColumnFilters get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => ColumnFilters(column), + ); + + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.accountId, referencedTable: $db.accounts, @@ -34765,7 +37894,7 @@ class $$CalendarSourcesTableAnnotationComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( + }) => $$AccountsTableFilterComposer( $db: $db, $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -34777,8 +37906,31 @@ class $$CalendarSourcesTableAnnotationComposer return composer; } - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + $$CalendarSourcesTableFilterComposer get calendarSourceId { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableFilterComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavCollectionsTableFilterComposer get davCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.davCollectionId, referencedTable: $db.davCollections, @@ -34788,7 +37940,7 @@ class $$CalendarSourcesTableAnnotationComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( + }) => $$DavCollectionsTableFilterComposer( $db: $db, $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -34800,876 +37952,1346 @@ class $$CalendarSourcesTableAnnotationComposer return composer; } - Expression calendarEventsRefs( - Expression Function($$CalendarEventsTableAnnotationComposer a) f, - ) { - final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( + $$DavObjectsTableFilterComposer get davObjectId { + final $$DavObjectsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.calendarSourceId, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableAnnotationComposer( + }) => $$DavObjectsTableFilterComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavObjectComponentsTableFilterComposer get davComponentId { + final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableFilterComposer( + $db: $db, + $table: $db.davObjectComponents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, ), ); + return composer; + } + + Expression calendarEventAttendeesRefs( + Expression Function($$CalendarEventAttendeesTableFilterComposer f) f, + ) { + final $$CalendarEventAttendeesTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventAttendees, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventAttendeesTableFilterComposer( + $db: $db, + $table: $db.calendarEventAttendees, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression calendarEventRemindersRefs( + Expression Function($$CalendarEventRemindersTableFilterComposer f) f, + ) { + final $$CalendarEventRemindersTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventReminders, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventRemindersTableFilterComposer( + $db: $db, + $table: $db.calendarEventReminders, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } +} + +class $$CalendarEventsTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarEventsTable> { + $$CalendarEventsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerCalendarId => $composableBuilder( + column: $table.providerCalendarId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerEventId => $composableBuilder( + column: $table.providerEventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get recurrenceIdKey => $composableBuilder( + column: $table.recurrenceIdKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get occurrenceKey => $composableBuilder( + column: $table.occurrenceKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerRecurringEventId => $composableBuilder( + column: $table.providerRecurringEventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerOriginalStartKey => $composableBuilder( + column: $table.providerOriginalStartKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get etagOrChangeKey => $composableBuilder( + column: $table.etagOrChangeKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnOrderings(column), + ); - Expression syncCursorsRefs( - Expression Function($$SyncCursorsTableAnnotationComposer a) f, - ) { - final $$SyncCursorsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.syncCursors, - getReferencedColumn: (t) => t.projectionSourceId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$SyncCursorsTableAnnotationComposer( - $db: $db, - $table: $db.syncCursors, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } -} + ColumnOrderings get allDay => $composableBuilder( + column: $table.allDay, + builder: (column) => ColumnOrderings(column), + ); -class $$CalendarSourcesTableTableManager - extends - RootTableManager< - _$AppDatabase, - $CalendarSourcesTable, - CalendarSource, - $$CalendarSourcesTableFilterComposer, - $$CalendarSourcesTableOrderingComposer, - $$CalendarSourcesTableAnnotationComposer, - $$CalendarSourcesTableCreateCompanionBuilder, - $$CalendarSourcesTableUpdateCompanionBuilder, - (CalendarSource, $$CalendarSourcesTableReferences), - CalendarSource, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool calendarEventsRefs, - bool syncCursorsRefs, - }) - > { - $$CalendarSourcesTableTableManager( - _$AppDatabase db, - $CalendarSourcesTable table, - ) : super( - TableManagerState( - db: db, - table: table, - createFilteringComposer: () => - $$CalendarSourcesTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$CalendarSourcesTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$CalendarSourcesTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: - ({ - Value id = const Value.absent(), - Value accountId = const Value.absent(), - Value provider = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value summary = const Value.absent(), - Value description = const Value.absent(), - Value primaryCalendar = const Value.absent(), - Value selected = const Value.absent(), - Value remindersEnabled = const Value.absent(), - Value hidden = const Value.absent(), - Value readOnly = const Value.absent(), - Value backgroundColor = const Value.absent(), - Value foregroundColor = const Value.absent(), - Value colorId = const Value.absent(), - Value timeZone = const Value.absent(), - Value accessRole = const Value.absent(), - Value dataOwner = const Value.absent(), - Value isRemovable = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - Value createdAtLocal = const Value.absent(), - Value updatedAtLocal = const Value.absent(), - Value rowid = const Value.absent(), - }) => CalendarSourcesCompanion( - id: id, - accountId: accountId, - provider: provider, - providerCalendarId: providerCalendarId, - davCollectionId: davCollectionId, - summary: summary, - description: description, - primaryCalendar: primaryCalendar, - selected: selected, - remindersEnabled: remindersEnabled, - hidden: hidden, - readOnly: readOnly, - backgroundColor: backgroundColor, - foregroundColor: foregroundColor, - colorId: colorId, - timeZone: timeZone, - accessRole: accessRole, - dataOwner: dataOwner, - isRemovable: isRemovable, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - rowid: rowid, - ), - createCompanionCallback: - ({ - required String id, - required String accountId, - required String provider, - required String providerCalendarId, - Value davCollectionId = const Value.absent(), - required String summary, - Value description = const Value.absent(), - Value primaryCalendar = const Value.absent(), - Value selected = const Value.absent(), - Value remindersEnabled = const Value.absent(), - Value hidden = const Value.absent(), - Value readOnly = const Value.absent(), - Value backgroundColor = const Value.absent(), - Value foregroundColor = const Value.absent(), - Value colorId = const Value.absent(), - Value timeZone = const Value.absent(), - Value accessRole = const Value.absent(), - Value dataOwner = const Value.absent(), - Value isRemovable = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - required int createdAtLocal, - required int updatedAtLocal, - Value rowid = const Value.absent(), - }) => CalendarSourcesCompanion.insert( - id: id, - accountId: accountId, - provider: provider, - providerCalendarId: providerCalendarId, - davCollectionId: davCollectionId, - summary: summary, - description: description, - primaryCalendar: primaryCalendar, - selected: selected, - remindersEnabled: remindersEnabled, - hidden: hidden, - readOnly: readOnly, - backgroundColor: backgroundColor, - foregroundColor: foregroundColor, - colorId: colorId, - timeZone: timeZone, - accessRole: accessRole, - dataOwner: dataOwner, - isRemovable: isRemovable, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map( - (e) => ( - e.readTable(table), - $$CalendarSourcesTableReferences(db, table, e), - ), - ) - .toList(), - prefetchHooksCallback: - ({ - accountId = false, - davCollectionId = false, - calendarEventsRefs = false, - syncCursorsRefs = false, - }) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [ - if (calendarEventsRefs) db.calendarEvents, - if (syncCursorsRefs) db.syncCursors, - ], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$CalendarSourcesTableReferences - ._accountIdTable(db), - referencedColumn: - $$CalendarSourcesTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$CalendarSourcesTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$CalendarSourcesTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } + ColumnOrderings get startDate => $composableBuilder( + column: $table.startDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startDateTime => $composableBuilder( + column: $table.startDateTime, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startTimeZone => $composableBuilder( + column: $table.startTimeZone, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endDate => $composableBuilder( + column: $table.endDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endDateTime => $composableBuilder( + column: $table.endDateTime, + builder: (column) => ColumnOrderings(column), + ); - return state; - }, - getPrefetchedDataCallback: (items) async { - return [ - if (calendarEventsRefs) - await $_getPrefetchedData< - CalendarSource, - $CalendarSourcesTable, - CalendarEvent - >( - currentTable: table, - referencedTable: $$CalendarSourcesTableReferences - ._calendarEventsRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarSourcesTableReferences( - db, - table, - p0, - ).calendarEventsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarSourceId == item.id, - ), - typedResults: items, - ), - if (syncCursorsRefs) - await $_getPrefetchedData< - CalendarSource, - $CalendarSourcesTable, - SyncCursor - >( - currentTable: table, - referencedTable: $$CalendarSourcesTableReferences - ._syncCursorsRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarSourcesTableReferences( - db, - table, - p0, - ).syncCursorsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.projectionSourceId == item.id, - ), - typedResults: items, - ), - ]; - }, - ); - }, - ), - ); -} + ColumnOrderings get endTimeZone => $composableBuilder( + column: $table.endTimeZone, + builder: (column) => ColumnOrderings(column), + ); -typedef $$CalendarSourcesTableProcessedTableManager = - ProcessedTableManager< - _$AppDatabase, - $CalendarSourcesTable, - CalendarSource, - $$CalendarSourcesTableFilterComposer, - $$CalendarSourcesTableOrderingComposer, - $$CalendarSourcesTableAnnotationComposer, - $$CalendarSourcesTableCreateCompanionBuilder, - $$CalendarSourcesTableUpdateCompanionBuilder, - (CalendarSource, $$CalendarSourcesTableReferences), - CalendarSource, - PrefetchHooks Function({ - bool accountId, - bool davCollectionId, - bool calendarEventsRefs, - bool syncCursorsRefs, - }) - >; -typedef $$CalendarEventsTableCreateCompanionBuilder = - CalendarEventsCompanion Function({ - required String id, - required String accountId, - required String calendarSourceId, - required String provider, - required String providerCalendarId, - required String providerEventId, - Value davCollectionId, - Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value occurrenceKey, - Value projectionVersion, - Value providerRecurringEventId, - Value providerOriginalStartKey, - Value etagOrChangeKey, - Value status, - required String title, - Value description, - Value location, - Value allDay, - Value startDate, - Value startDateTime, - Value startTimeZone, - Value endDate, - Value endDateTime, - Value endTimeZone, - Value recurrenceJson, - Value remindersJson, - Value attendeesJson, - Value categoriesJson, - Value organizerJson, - Value creatorJson, - Value colorId, - Value colorHex, - Value visibility, - Value transparencyOrShowAs, - Value eventType, - Value webLink, - Value conferenceJson, - Value attachmentsJson, - Value isCancelled, - Value isDeleted, - Value rawJson, - Value createdAtServer, - Value updatedAtServer, - required int createdAtLocal, - required int updatedAtLocal, - Value syncStatus, - Value baselineRawJson, - Value rowid, - }); -typedef $$CalendarEventsTableUpdateCompanionBuilder = - CalendarEventsCompanion Function({ - Value id, - Value accountId, - Value calendarSourceId, - Value provider, - Value providerCalendarId, - Value providerEventId, - Value davCollectionId, - Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value occurrenceKey, - Value projectionVersion, - Value providerRecurringEventId, - Value providerOriginalStartKey, - Value etagOrChangeKey, - Value status, - Value title, - Value description, - Value location, - Value allDay, - Value startDate, - Value startDateTime, - Value startTimeZone, - Value endDate, - Value endDateTime, - Value endTimeZone, - Value recurrenceJson, - Value remindersJson, - Value attendeesJson, - Value categoriesJson, - Value organizerJson, - Value creatorJson, - Value colorId, - Value colorHex, - Value visibility, - Value transparencyOrShowAs, - Value eventType, - Value webLink, - Value conferenceJson, - Value attachmentsJson, - Value isCancelled, - Value isDeleted, - Value rawJson, - Value createdAtServer, - Value updatedAtServer, - Value createdAtLocal, - Value updatedAtLocal, - Value syncStatus, - Value baselineRawJson, - Value rowid, - }); + ColumnOrderings get recurrenceJson => $composableBuilder( + column: $table.recurrenceJson, + builder: (column) => ColumnOrderings(column), + ); -final class $$CalendarEventsTableReferences - extends BaseReferences<_$AppDatabase, $CalendarEventsTable, CalendarEvent> { - $$CalendarEventsTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, + ColumnOrderings get remindersJson => $composableBuilder( + column: $table.remindersJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get attendeesJson => $composableBuilder( + column: $table.attendeesJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get categoriesJson => $composableBuilder( + column: $table.categoriesJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get organizerJson => $composableBuilder( + column: $table.organizerJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get creatorJson => $composableBuilder( + column: $table.creatorJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get colorId => $composableBuilder( + column: $table.colorId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get colorHex => $composableBuilder( + column: $table.colorHex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get transparencyOrShowAs => $composableBuilder( + column: $table.transparencyOrShowAs, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get eventType => $composableBuilder( + column: $table.eventType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get webLink => $composableBuilder( + column: $table.webLink, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get conferenceJson => $composableBuilder( + column: $table.conferenceJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get attachmentsJson => $composableBuilder( + column: $table.attachmentsJson, + builder: (column) => ColumnOrderings(column), ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.calendarEvents.accountId, db.accounts.id), - ); + ColumnOrderings get isCancelled => $composableBuilder( + column: $table.isCancelled, + builder: (column) => ColumnOrderings(column), + ); - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; + ColumnOrderings get isDeleted => $composableBuilder( + column: $table.isDeleted, + builder: (column) => ColumnOrderings(column), + ); - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } + ColumnOrderings get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnOrderings(column), + ); - static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => - db.calendarSources.createAlias( - $_aliasNameGenerator( - db.calendarEvents.calendarSourceId, - db.calendarSources.id, - ), - ); + ColumnOrderings get createdAtServer => $composableBuilder( + column: $table.createdAtServer, + builder: (column) => ColumnOrderings(column), + ); - $$CalendarSourcesTableProcessedTableManager get calendarSourceId { - final $_column = $_itemColumn('calendar_source_id')!; + ColumnOrderings get updatedAtServer => $composableBuilder( + column: $table.updatedAtServer, + builder: (column) => ColumnOrderings(column), + ); - final manager = $$CalendarSourcesTableTableManager( - $_db, - $_db.calendarSources, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } + ColumnOrderings get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => ColumnOrderings(column), + ); - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.calendarEvents.davCollectionId, - db.davCollections.id, - ), - ); + ColumnOrderings get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => ColumnOrderings(column), + ); - $$DavCollectionsTableProcessedTableManager? get davCollectionId { - final $_column = $_itemColumn('dav_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } + ColumnOrderings get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => ColumnOrderings(column), + ); - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator(db.calendarEvents.davObjectId, db.davObjects.id), - ); + ColumnOrderings get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => ColumnOrderings(column), + ); - $$DavObjectsTableProcessedTableManager? get davObjectId { - final $_column = $_itemColumn('dav_object_id'); - if ($_column == null) return null; - final manager = $$DavObjectsTableTableManager( - $_db, - $_db.davObjects, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davObjectIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableOrderingComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } - static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => - db.davObjectComponents.createAlias( - $_aliasNameGenerator( - db.calendarEvents.davComponentId, - db.davObjectComponents.id, - ), - ); - - $$DavObjectComponentsTableProcessedTableManager? get davComponentId { - final $_column = $_itemColumn('dav_component_id'); - if ($_column == null) return null; - final manager = $$DavObjectComponentsTableTableManager( - $_db, - $_db.davObjectComponents, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davComponentIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), + $$CalendarSourcesTableOrderingComposer get calendarSourceId { + final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableOrderingComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } - static MultiTypedResultKey< - $CalendarEventAttendeesTable, - List - > - _calendarEventAttendeesRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.calendarEventAttendees, - aliasName: $_aliasNameGenerator( - db.calendarEvents.id, - db.calendarEventAttendees.calendarEventId, - ), - ); - - $$CalendarEventAttendeesTableProcessedTableManager - get calendarEventAttendeesRefs { - final manager = - $$CalendarEventAttendeesTableTableManager( - $_db, - $_db.calendarEventAttendees, - ).filter( - (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull( - _calendarEventAttendeesRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + $$DavCollectionsTableOrderingComposer get davCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableOrderingComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; } - static MultiTypedResultKey< - $CalendarEventRemindersTable, - List - > - _calendarEventRemindersRefsTable(_$AppDatabase db) => - MultiTypedResultKey.fromTable( - db.calendarEventReminders, - aliasName: $_aliasNameGenerator( - db.calendarEvents.id, - db.calendarEventReminders.calendarEventId, - ), - ); - - $$CalendarEventRemindersTableProcessedTableManager - get calendarEventRemindersRefs { - final manager = - $$CalendarEventRemindersTableTableManager( - $_db, - $_db.calendarEventReminders, - ).filter( - (f) => f.calendarEventId.id.sqlEquals($_itemColumn('id')!), - ); - - final cache = $_typedResult.readTableOrNull( - _calendarEventRemindersRefsTable($_db), - ); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache), + $$DavObjectsTableOrderingComposer get davObjectId { + final $$DavObjectsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableOrderingComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), ); + return composer; + } + + $$DavObjectComponentsTableOrderingComposer get davComponentId { + final $$DavObjectComponentsTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableOrderingComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; } } -class $$CalendarEventsTableFilterComposer +class $$CalendarEventsTableAnnotationComposer extends Composer<_$AppDatabase, $CalendarEventsTable> { - $$CalendarEventsTableFilterComposer({ + $$CalendarEventsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - ColumnFilters get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get provider => + $composableBuilder(column: $table.provider, builder: (column) => column); - ColumnFilters get providerCalendarId => $composableBuilder( + GeneratedColumn get providerCalendarId => $composableBuilder( column: $table.providerCalendarId, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get providerEventId => $composableBuilder( + GeneratedColumn get providerEventId => $composableBuilder( column: $table.providerEventId, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get icalUid => + $composableBuilder(column: $table.icalUid, builder: (column) => column); - ColumnFilters get recurrenceIdKey => $composableBuilder( + GeneratedColumn get recurrenceIdKey => $composableBuilder( column: $table.recurrenceIdKey, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get occurrenceKey => $composableBuilder( + GeneratedColumn get occurrenceKey => $composableBuilder( column: $table.occurrenceKey, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get projectionVersion => $composableBuilder( + GeneratedColumn get projectionVersion => $composableBuilder( column: $table.projectionVersion, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get providerRecurringEventId => $composableBuilder( + GeneratedColumn get providerRecurringEventId => $composableBuilder( column: $table.providerRecurringEventId, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get providerOriginalStartKey => $composableBuilder( + GeneratedColumn get providerOriginalStartKey => $composableBuilder( column: $table.providerOriginalStartKey, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get etagOrChangeKey => $composableBuilder( + GeneratedColumn get etagOrChangeKey => $composableBuilder( column: $table.etagOrChangeKey, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); - ColumnFilters get title => $composableBuilder( - column: $table.title, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); - ColumnFilters get description => $composableBuilder( + GeneratedColumn get description => $composableBuilder( column: $table.description, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get location => $composableBuilder( - column: $table.location, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get location => + $composableBuilder(column: $table.location, builder: (column) => column); - ColumnFilters get allDay => $composableBuilder( - column: $table.allDay, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get allDay => + $composableBuilder(column: $table.allDay, builder: (column) => column); - ColumnFilters get startDate => $composableBuilder( - column: $table.startDate, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get startDate => + $composableBuilder(column: $table.startDate, builder: (column) => column); - ColumnFilters get startDateTime => $composableBuilder( + GeneratedColumn get startDateTime => $composableBuilder( column: $table.startDateTime, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get startTimeZone => $composableBuilder( + GeneratedColumn get startTimeZone => $composableBuilder( column: $table.startTimeZone, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get endDate => $composableBuilder( - column: $table.endDate, - builder: (column) => ColumnFilters(column), - ); + GeneratedColumn get endDate => + $composableBuilder(column: $table.endDate, builder: (column) => column); - ColumnFilters get endDateTime => $composableBuilder( + GeneratedColumn get endDateTime => $composableBuilder( column: $table.endDateTime, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get endTimeZone => $composableBuilder( + GeneratedColumn get endTimeZone => $composableBuilder( column: $table.endTimeZone, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get recurrenceJson => $composableBuilder( + GeneratedColumn get recurrenceJson => $composableBuilder( column: $table.recurrenceJson, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get remindersJson => $composableBuilder( + GeneratedColumn get remindersJson => $composableBuilder( column: $table.remindersJson, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get attendeesJson => $composableBuilder( + GeneratedColumn get attendeesJson => $composableBuilder( column: $table.attendeesJson, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get categoriesJson => $composableBuilder( + GeneratedColumn get categoriesJson => $composableBuilder( column: $table.categoriesJson, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get organizerJson => $composableBuilder( + GeneratedColumn get organizerJson => $composableBuilder( column: $table.organizerJson, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get creatorJson => $composableBuilder( + GeneratedColumn get creatorJson => $composableBuilder( column: $table.creatorJson, - builder: (column) => ColumnFilters(column), + builder: (column) => column, ); - ColumnFilters get colorId => $composableBuilder( - column: $table.colorId, - builder: (column) => ColumnFilters(column), + GeneratedColumn get colorId => + $composableBuilder(column: $table.colorId, builder: (column) => column); + + GeneratedColumn get colorHex => + $composableBuilder(column: $table.colorHex, builder: (column) => column); + + GeneratedColumn get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => column, + ); + + GeneratedColumn get transparencyOrShowAs => $composableBuilder( + column: $table.transparencyOrShowAs, + builder: (column) => column, + ); + + GeneratedColumn get eventType => + $composableBuilder(column: $table.eventType, builder: (column) => column); + + GeneratedColumn get webLink => + $composableBuilder(column: $table.webLink, builder: (column) => column); + + GeneratedColumn get conferenceJson => $composableBuilder( + column: $table.conferenceJson, + builder: (column) => column, + ); + + GeneratedColumn get attachmentsJson => $composableBuilder( + column: $table.attachmentsJson, + builder: (column) => column, + ); + + GeneratedColumn get isCancelled => $composableBuilder( + column: $table.isCancelled, + builder: (column) => column, + ); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + + GeneratedColumn get createdAtServer => $composableBuilder( + column: $table.createdAtServer, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtServer => $composableBuilder( + column: $table.updatedAtServer, + builder: (column) => column, + ); + + GeneratedColumn get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => column, + ); + + GeneratedColumn get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => column, + ); + + GeneratedColumn get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => column, ); - ColumnFilters get colorHex => $composableBuilder( - column: $table.colorHex, - builder: (column) => ColumnFilters(column), - ); + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableAnnotationComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - ColumnFilters get visibility => $composableBuilder( - column: $table.visibility, - builder: (column) => ColumnFilters(column), - ); + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableAnnotationComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - ColumnFilters get transparencyOrShowAs => $composableBuilder( - column: $table.transparencyOrShowAs, - builder: (column) => ColumnFilters(column), - ); + $$DavObjectsTableAnnotationComposer get davObjectId { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableAnnotationComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - ColumnFilters get eventType => $composableBuilder( - column: $table.eventType, - builder: (column) => ColumnFilters(column), - ); + $$DavObjectComponentsTableAnnotationComposer get davComponentId { + final $$DavObjectComponentsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableAnnotationComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - ColumnFilters get webLink => $composableBuilder( - column: $table.webLink, - builder: (column) => ColumnFilters(column), - ); + Expression calendarEventAttendeesRefs( + Expression Function($$CalendarEventAttendeesTableAnnotationComposer a) f, + ) { + final $$CalendarEventAttendeesTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventAttendees, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventAttendeesTableAnnotationComposer( + $db: $db, + $table: $db.calendarEventAttendees, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } - ColumnFilters get conferenceJson => $composableBuilder( - column: $table.conferenceJson, - builder: (column) => ColumnFilters(column), - ); + Expression calendarEventRemindersRefs( + Expression Function($$CalendarEventRemindersTableAnnotationComposer a) f, + ) { + final $$CalendarEventRemindersTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventReminders, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventRemindersTableAnnotationComposer( + $db: $db, + $table: $db.calendarEventReminders, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} - ColumnFilters get attachmentsJson => $composableBuilder( - column: $table.attachmentsJson, - builder: (column) => ColumnFilters(column), - ); +class $$CalendarEventsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + (CalendarEvent, $$CalendarEventsTableReferences), + CalendarEvent, + PrefetchHooks Function({ + bool accountId, + bool calendarSourceId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + bool calendarEventAttendeesRefs, + bool calendarEventRemindersRefs, + }) + > { + $$CalendarEventsTableTableManager( + _$AppDatabase db, + $CalendarEventsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CalendarEventsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CalendarEventsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CalendarEventsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountId = const Value.absent(), + Value calendarSourceId = const Value.absent(), + Value provider = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value providerEventId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value occurrenceKey = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value providerRecurringEventId = const Value.absent(), + Value providerOriginalStartKey = const Value.absent(), + Value etagOrChangeKey = const Value.absent(), + Value status = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value location = const Value.absent(), + Value allDay = const Value.absent(), + Value startDate = const Value.absent(), + Value startDateTime = const Value.absent(), + Value startTimeZone = const Value.absent(), + Value endDate = const Value.absent(), + Value endDateTime = const Value.absent(), + Value endTimeZone = const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value remindersJson = const Value.absent(), + Value attendeesJson = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value organizerJson = const Value.absent(), + Value creatorJson = const Value.absent(), + Value colorId = const Value.absent(), + Value colorHex = const Value.absent(), + Value visibility = const Value.absent(), + Value transparencyOrShowAs = const Value.absent(), + Value eventType = const Value.absent(), + Value webLink = const Value.absent(), + Value conferenceJson = const Value.absent(), + Value attachmentsJson = const Value.absent(), + Value isCancelled = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + Value createdAtServer = const Value.absent(), + Value updatedAtServer = const Value.absent(), + Value createdAtLocal = const Value.absent(), + Value updatedAtLocal = const Value.absent(), + Value syncStatus = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => CalendarEventsCompanion( + id: id, + accountId: accountId, + calendarSourceId: calendarSourceId, + provider: provider, + providerCalendarId: providerCalendarId, + providerEventId: providerEventId, + davCollectionId: davCollectionId, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + occurrenceKey: occurrenceKey, + projectionVersion: projectionVersion, + providerRecurringEventId: providerRecurringEventId, + providerOriginalStartKey: providerOriginalStartKey, + etagOrChangeKey: etagOrChangeKey, + status: status, + title: title, + description: description, + location: location, + allDay: allDay, + startDate: startDate, + startDateTime: startDateTime, + startTimeZone: startTimeZone, + endDate: endDate, + endDateTime: endDateTime, + endTimeZone: endTimeZone, + recurrenceJson: recurrenceJson, + remindersJson: remindersJson, + attendeesJson: attendeesJson, + categoriesJson: categoriesJson, + organizerJson: organizerJson, + creatorJson: creatorJson, + colorId: colorId, + colorHex: colorHex, + visibility: visibility, + transparencyOrShowAs: transparencyOrShowAs, + eventType: eventType, + webLink: webLink, + conferenceJson: conferenceJson, + attachmentsJson: attachmentsJson, + isCancelled: isCancelled, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtServer: createdAtServer, + updatedAtServer: updatedAtServer, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, + syncStatus: syncStatus, + baselineRawJson: baselineRawJson, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String accountId, + required String calendarSourceId, + required String provider, + required String providerCalendarId, + required String providerEventId, + Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value occurrenceKey = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value providerRecurringEventId = const Value.absent(), + Value providerOriginalStartKey = const Value.absent(), + Value etagOrChangeKey = const Value.absent(), + Value status = const Value.absent(), + required String title, + Value description = const Value.absent(), + Value location = const Value.absent(), + Value allDay = const Value.absent(), + Value startDate = const Value.absent(), + Value startDateTime = const Value.absent(), + Value startTimeZone = const Value.absent(), + Value endDate = const Value.absent(), + Value endDateTime = const Value.absent(), + Value endTimeZone = const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value remindersJson = const Value.absent(), + Value attendeesJson = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value organizerJson = const Value.absent(), + Value creatorJson = const Value.absent(), + Value colorId = const Value.absent(), + Value colorHex = const Value.absent(), + Value visibility = const Value.absent(), + Value transparencyOrShowAs = const Value.absent(), + Value eventType = const Value.absent(), + Value webLink = const Value.absent(), + Value conferenceJson = const Value.absent(), + Value attachmentsJson = const Value.absent(), + Value isCancelled = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + Value createdAtServer = const Value.absent(), + Value updatedAtServer = const Value.absent(), + required int createdAtLocal, + required int updatedAtLocal, + Value syncStatus = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => CalendarEventsCompanion.insert( + id: id, + accountId: accountId, + calendarSourceId: calendarSourceId, + provider: provider, + providerCalendarId: providerCalendarId, + providerEventId: providerEventId, + davCollectionId: davCollectionId, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + occurrenceKey: occurrenceKey, + projectionVersion: projectionVersion, + providerRecurringEventId: providerRecurringEventId, + providerOriginalStartKey: providerOriginalStartKey, + etagOrChangeKey: etagOrChangeKey, + status: status, + title: title, + description: description, + location: location, + allDay: allDay, + startDate: startDate, + startDateTime: startDateTime, + startTimeZone: startTimeZone, + endDate: endDate, + endDateTime: endDateTime, + endTimeZone: endTimeZone, + recurrenceJson: recurrenceJson, + remindersJson: remindersJson, + attendeesJson: attendeesJson, + categoriesJson: categoriesJson, + organizerJson: organizerJson, + creatorJson: creatorJson, + colorId: colorId, + colorHex: colorHex, + visibility: visibility, + transparencyOrShowAs: transparencyOrShowAs, + eventType: eventType, + webLink: webLink, + conferenceJson: conferenceJson, + attachmentsJson: attachmentsJson, + isCancelled: isCancelled, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtServer: createdAtServer, + updatedAtServer: updatedAtServer, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, + syncStatus: syncStatus, + baselineRawJson: baselineRawJson, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$CalendarEventsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: + ({ + accountId = false, + calendarSourceId = false, + davCollectionId = false, + davObjectId = false, + davComponentId = false, + calendarEventAttendeesRefs = false, + calendarEventRemindersRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (calendarEventAttendeesRefs) db.calendarEventAttendees, + if (calendarEventRemindersRefs) db.calendarEventReminders, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$CalendarEventsTableReferences + ._accountIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (calendarSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$CalendarEventsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._calendarSourceIdTable(db) + .id, + ) + as T; + } + if (davCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: + $$CalendarEventsTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davCollectionIdTable(db) + .id, + ) + as T; + } + if (davObjectId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: + $$CalendarEventsTableReferences + ._davObjectIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davObjectIdTable(db) + .id, + ) + as T; + } + if (davComponentId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davComponentId, + referencedTable: + $$CalendarEventsTableReferences + ._davComponentIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davComponentIdTable(db) + .id, + ) + as T; + } - ColumnFilters get isCancelled => $composableBuilder( - column: $table.isCancelled, - builder: (column) => ColumnFilters(column), - ); + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (calendarEventAttendeesRefs) + await $_getPrefetchedData< + CalendarEvent, + $CalendarEventsTable, + CalendarEventAttendee + >( + currentTable: table, + referencedTable: $$CalendarEventsTableReferences + ._calendarEventAttendeesRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarEventsTableReferences( + db, + table, + p0, + ).calendarEventAttendeesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarEventId == item.id, + ), + typedResults: items, + ), + if (calendarEventRemindersRefs) + await $_getPrefetchedData< + CalendarEvent, + $CalendarEventsTable, + CalendarEventReminder + >( + currentTable: table, + referencedTable: $$CalendarEventsTableReferences + ._calendarEventRemindersRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarEventsTableReferences( + db, + table, + p0, + ).calendarEventRemindersRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarEventId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} - ColumnFilters get isDeleted => $composableBuilder( - column: $table.isDeleted, - builder: (column) => ColumnFilters(column), - ); +typedef $$CalendarEventsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + (CalendarEvent, $$CalendarEventsTableReferences), + CalendarEvent, + PrefetchHooks Function({ + bool accountId, + bool calendarSourceId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + bool calendarEventAttendeesRefs, + bool calendarEventRemindersRefs, + }) + >; +typedef $$IcalImportReceiptsTableCreateCompanionBuilder = + IcalImportReceiptsCompanion Function({ + required String calendarSourceId, + required String icalUid, + Value eventId, + required String importedAtUtc, + Value rowid, + }); +typedef $$IcalImportReceiptsTableUpdateCompanionBuilder = + IcalImportReceiptsCompanion Function({ + Value calendarSourceId, + Value icalUid, + Value eventId, + Value importedAtUtc, + Value rowid, + }); - ColumnFilters get rawJson => $composableBuilder( - column: $table.rawJson, - builder: (column) => ColumnFilters(column), +final class $$IcalImportReceiptsTableReferences + extends + BaseReferences< + _$AppDatabase, + $IcalImportReceiptsTable, + IcalImportReceipt + > { + $$IcalImportReceiptsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, ); - ColumnFilters get createdAtServer => $composableBuilder( - column: $table.createdAtServer, - builder: (column) => ColumnFilters(column), - ); + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => + db.calendarSources.createAlias( + $_aliasNameGenerator( + db.icalImportReceipts.calendarSourceId, + db.calendarSources.id, + ), + ); - ColumnFilters get updatedAtServer => $composableBuilder( - column: $table.updatedAtServer, - builder: (column) => ColumnFilters(column), - ); + $$CalendarSourcesTableProcessedTableManager get calendarSourceId { + final $_column = $_itemColumn('calendar_source_id')!; - ColumnFilters get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, - builder: (column) => ColumnFilters(column), - ); + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} - ColumnFilters get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, +class $$IcalImportReceiptsTableFilterComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get icalUid => $composableBuilder( + column: $table.icalUid, builder: (column) => ColumnFilters(column), ); - ColumnFilters get syncStatus => $composableBuilder( - column: $table.syncStatus, + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + ColumnFilters get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, builder: (column) => ColumnFilters(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - $$CalendarSourcesTableFilterComposer get calendarSourceId { final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( composer: this, @@ -35692,44 +39314,46 @@ class $$CalendarEventsTableFilterComposer ); return composer; } +} - $$DavCollectionsTableFilterComposer get davCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } +class $$IcalImportReceiptsTableOrderingComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnOrderings(column), + ); - $$DavObjectsTableFilterComposer get davObjectId { - final $$DavObjectsTableFilterComposer composer = $composerBuilder( + ColumnOrderings get eventId => $composableBuilder( + column: $table.eventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + $$CalendarSourcesTableOrderingComposer get calendarSourceId { + final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableFilterComposer( + }) => $$CalendarSourcesTableOrderingComposer( $db: $db, - $table: $db.davObjects, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -35738,21 +39362,42 @@ class $$CalendarEventsTableFilterComposer ); return composer; } +} + +class $$IcalImportReceiptsTableAnnotationComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get icalUid => + $composableBuilder(column: $table.icalUid, builder: (column) => column); - $$DavObjectComponentsTableFilterComposer get davComponentId { - final $$DavObjectComponentsTableFilterComposer composer = $composerBuilder( + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); + + GeneratedColumn get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => column, + ); + + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableFilterComposer( + }) => $$CalendarSourcesTableAnnotationComposer( $db: $db, - $table: $db.davObjectComponents, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -35761,354 +39406,571 @@ class $$CalendarEventsTableFilterComposer ); return composer; } +} - Expression calendarEventAttendeesRefs( - Expression Function($$CalendarEventAttendeesTableFilterComposer f) f, - ) { - final $$CalendarEventAttendeesTableFilterComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventAttendees, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventAttendeesTableFilterComposer( - $db: $db, - $table: $db.calendarEventAttendees, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, +class $$IcalImportReceiptsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $IcalImportReceiptsTable, + IcalImportReceipt, + $$IcalImportReceiptsTableFilterComposer, + $$IcalImportReceiptsTableOrderingComposer, + $$IcalImportReceiptsTableAnnotationComposer, + $$IcalImportReceiptsTableCreateCompanionBuilder, + $$IcalImportReceiptsTableUpdateCompanionBuilder, + (IcalImportReceipt, $$IcalImportReceiptsTableReferences), + IcalImportReceipt, + PrefetchHooks Function({bool calendarSourceId}) + > { + $$IcalImportReceiptsTableTableManager( + _$AppDatabase db, + $IcalImportReceiptsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$IcalImportReceiptsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$IcalImportReceiptsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$IcalImportReceiptsTableAnnotationComposer( + $db: db, + $table: table, ), - ); - return f(composer); + updateCompanionCallback: + ({ + Value calendarSourceId = const Value.absent(), + Value icalUid = const Value.absent(), + Value eventId = const Value.absent(), + Value importedAtUtc = const Value.absent(), + Value rowid = const Value.absent(), + }) => IcalImportReceiptsCompanion( + calendarSourceId: calendarSourceId, + icalUid: icalUid, + eventId: eventId, + importedAtUtc: importedAtUtc, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String calendarSourceId, + required String icalUid, + Value eventId = const Value.absent(), + required String importedAtUtc, + Value rowid = const Value.absent(), + }) => IcalImportReceiptsCompanion.insert( + calendarSourceId: calendarSourceId, + icalUid: icalUid, + eventId: eventId, + importedAtUtc: importedAtUtc, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$IcalImportReceiptsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({calendarSourceId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (calendarSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$IcalImportReceiptsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $IcalImportReceiptsTable, + IcalImportReceipt, + $$IcalImportReceiptsTableFilterComposer, + $$IcalImportReceiptsTableOrderingComposer, + $$IcalImportReceiptsTableAnnotationComposer, + $$IcalImportReceiptsTableCreateCompanionBuilder, + $$IcalImportReceiptsTableUpdateCompanionBuilder, + (IcalImportReceipt, $$IcalImportReceiptsTableReferences), + IcalImportReceipt, + PrefetchHooks Function({bool calendarSourceId}) + >; +typedef $$WebCalSubscriptionsTableCreateCompanionBuilder = + WebCalSubscriptionsCompanion Function({ + required String id, + required String accountId, + required String calendarSourceId, + required String feedFingerprint, + required String safeOrigin, + Value validatorTargetFingerprint, + Value etag, + Value lastModified, + Value contentType, + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + Value serverRefreshIntervalSeconds, + required String nextRefreshAtUtc, + Value lastCheckedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastChangedAtUtc, + Value consecutiveFailureCount, + Value lastFailureCode, + Value lastFailureHttpStatus, + Value generation, + Value parserVersion, + Value projectionVersion, + required String createdAtUtc, + required String updatedAtUtc, + Value rowid, + }); +typedef $$WebCalSubscriptionsTableUpdateCompanionBuilder = + WebCalSubscriptionsCompanion Function({ + Value id, + Value accountId, + Value calendarSourceId, + Value feedFingerprint, + Value safeOrigin, + Value validatorTargetFingerprint, + Value etag, + Value lastModified, + Value contentType, + Value snapshotIcsBody, + Value rawBodyHash, + Value semanticHash, + Value refreshMode, + Value serverRefreshIntervalSeconds, + Value nextRefreshAtUtc, + Value lastCheckedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastChangedAtUtc, + Value consecutiveFailureCount, + Value lastFailureCode, + Value lastFailureHttpStatus, + Value generation, + Value parserVersion, + Value projectionVersion, + Value createdAtUtc, + Value updatedAtUtc, + Value rowid, + }); + +final class $$WebCalSubscriptionsTableReferences + extends + BaseReferences< + _$AppDatabase, + $WebCalSubscriptionsTable, + WebCalSubscription + > { + $$WebCalSubscriptionsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias( + $_aliasNameGenerator(db.webCalSubscriptions.accountId, db.accounts.id), + ); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); } - Expression calendarEventRemindersRefs( - Expression Function($$CalendarEventRemindersTableFilterComposer f) f, - ) { - final $$CalendarEventRemindersTableFilterComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventReminders, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventRemindersTableFilterComposer( - $db: $db, - $table: $db.calendarEventReminders, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => + db.calendarSources.createAlias( + $_aliasNameGenerator( + db.webCalSubscriptions.calendarSourceId, + db.calendarSources.id, + ), + ); + + $$CalendarSourcesTableProcessedTableManager get calendarSourceId { + final $_column = $_itemColumn('calendar_source_id')!; + + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); } } -class $$CalendarEventsTableOrderingComposer - extends Composer<_$AppDatabase, $CalendarEventsTable> { - $$CalendarEventsTableOrderingComposer({ +class $$WebCalSubscriptionsTableFilterComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( + ColumnFilters get id => $composableBuilder( column: $table.id, - builder: (column) => ColumnOrderings(column), + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnOrderings(column), + ColumnFilters get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => ColumnOrderings(column), + ColumnFilters get safeOrigin => $composableBuilder( + column: $table.safeOrigin, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get providerEventId => $composableBuilder( - column: $table.providerEventId, - builder: (column) => ColumnOrderings(column), + ColumnFilters get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnOrderings(column), + ColumnFilters get etag => $composableBuilder( + column: $table.etag, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => ColumnOrderings(column), + ColumnFilters get contentType => $composableBuilder( + column: $table.contentType, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get occurrenceKey => $composableBuilder( - column: $table.occurrenceKey, - builder: (column) => ColumnOrderings(column), + ColumnFilters get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => ColumnOrderings(column), + ColumnFilters get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get providerRecurringEventId => $composableBuilder( - column: $table.providerRecurringEventId, - builder: (column) => ColumnOrderings(column), + ColumnFilters get semanticHash => $composableBuilder( + column: $table.semanticHash, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get providerOriginalStartKey => $composableBuilder( - column: $table.providerOriginalStartKey, - builder: (column) => ColumnOrderings(column), + ColumnFilters get refreshMode => $composableBuilder( + column: $table.refreshMode, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get etagOrChangeKey => $composableBuilder( - column: $table.etagOrChangeKey, - builder: (column) => ColumnOrderings(column), + ColumnFilters get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get status => $composableBuilder( - column: $table.status, - builder: (column) => ColumnOrderings(column), + ColumnFilters get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get title => $composableBuilder( - column: $table.title, - builder: (column) => ColumnOrderings(column), + ColumnFilters get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get description => $composableBuilder( - column: $table.description, - builder: (column) => ColumnOrderings(column), + ColumnFilters get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get location => $composableBuilder( - column: $table.location, - builder: (column) => ColumnOrderings(column), + ColumnFilters get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get allDay => $composableBuilder( - column: $table.allDay, - builder: (column) => ColumnOrderings(column), + ColumnFilters get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get startDate => $composableBuilder( - column: $table.startDate, - builder: (column) => ColumnOrderings(column), + ColumnFilters get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get startDateTime => $composableBuilder( - column: $table.startDateTime, - builder: (column) => ColumnOrderings(column), + ColumnFilters get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get startTimeZone => $composableBuilder( - column: $table.startTimeZone, - builder: (column) => ColumnOrderings(column), + ColumnFilters get generation => $composableBuilder( + column: $table.generation, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get endDate => $composableBuilder( - column: $table.endDate, - builder: (column) => ColumnOrderings(column), + ColumnFilters get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get endDateTime => $composableBuilder( - column: $table.endDateTime, - builder: (column) => ColumnOrderings(column), + ColumnFilters get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get endTimeZone => $composableBuilder( - column: $table.endTimeZone, - builder: (column) => ColumnOrderings(column), + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, - builder: (column) => ColumnOrderings(column), + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnFilters(column), ); - ColumnOrderings get remindersJson => $composableBuilder( - column: $table.remindersJson, + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$CalendarSourcesTableFilterComposer get calendarSourceId { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableFilterComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$WebCalSubscriptionsTableOrderingComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get attendeesJson => $composableBuilder( - column: $table.attendeesJson, + ColumnOrderings get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + ColumnOrderings get safeOrigin => $composableBuilder( + column: $table.safeOrigin, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get organizerJson => $composableBuilder( - column: $table.organizerJson, + ColumnOrderings get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get creatorJson => $composableBuilder( - column: $table.creatorJson, + ColumnOrderings get etag => $composableBuilder( + column: $table.etag, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get colorId => $composableBuilder( - column: $table.colorId, + ColumnOrderings get lastModified => $composableBuilder( + column: $table.lastModified, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get colorHex => $composableBuilder( - column: $table.colorHex, + ColumnOrderings get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get visibility => $composableBuilder( - column: $table.visibility, + ColumnOrderings get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get transparencyOrShowAs => $composableBuilder( - column: $table.transparencyOrShowAs, + ColumnOrderings get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get eventType => $composableBuilder( - column: $table.eventType, + ColumnOrderings get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get webLink => $composableBuilder( - column: $table.webLink, + ColumnOrderings get refreshMode => $composableBuilder( + column: $table.refreshMode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get conferenceJson => $composableBuilder( - column: $table.conferenceJson, + ColumnOrderings get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get attachmentsJson => $composableBuilder( - column: $table.attachmentsJson, + ColumnOrderings get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isCancelled => $composableBuilder( - column: $table.isCancelled, + ColumnOrderings get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get isDeleted => $composableBuilder( - column: $table.isDeleted, + ColumnOrderings get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnOrderings get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtServer => $composableBuilder( - column: $table.createdAtServer, + ColumnOrderings get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtServer => $composableBuilder( - column: $table.updatedAtServer, + ColumnOrderings get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + ColumnOrderings get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + ColumnOrderings get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get syncStatus => $composableBuilder( - column: $table.syncStatus, + ColumnOrderings get parserVersion => $composableBuilder( + column: $table.parserVersion, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + ColumnOrderings get projectionVersion => $composableBuilder( + column: $table.projectionVersion, builder: (column) => ColumnOrderings(column), ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$CalendarSourcesTableOrderingComposer get calendarSourceId { - final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.calendarSourceId, - referencedTable: $db.calendarSources, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableOrderingComposer( - $db: $db, - $table: $db.calendarSources, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + ColumnOrderings get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnOrderings(column), + ); - $$DavCollectionsTableOrderingComposer get davCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + ColumnOrderings get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( + }) => $$AccountsTableOrderingComposer( $db: $db, - $table: $db.davCollections, + $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -36118,20 +39980,20 @@ class $$CalendarEventsTableOrderingComposer return composer; } - $$DavObjectsTableOrderingComposer get davObjectId { - final $$DavObjectsTableOrderingComposer composer = $composerBuilder( + $$CalendarSourcesTableOrderingComposer get calendarSourceId { + final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableOrderingComposer( + }) => $$CalendarSourcesTableOrderingComposer( $db: $db, - $table: $db.davObjects, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -36140,35 +40002,11 @@ class $$CalendarEventsTableOrderingComposer ); return composer; } - - $$DavObjectComponentsTableOrderingComposer get davComponentId { - final $$DavObjectComponentsTableOrderingComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableOrderingComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$CalendarEventsTableAnnotationComposer - extends Composer<_$AppDatabase, $CalendarEventsTable> { - $$CalendarEventsTableAnnotationComposer({ +class $$WebCalSubscriptionsTableAnnotationComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -36178,258 +40016,133 @@ class $$CalendarEventsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get provider => - $composableBuilder(column: $table.provider, builder: (column) => column); - - GeneratedColumn get providerCalendarId => $composableBuilder( - column: $table.providerCalendarId, - builder: (column) => column, - ); - - GeneratedColumn get providerEventId => $composableBuilder( - column: $table.providerEventId, - builder: (column) => column, - ); - - GeneratedColumn get icalUid => - $composableBuilder(column: $table.icalUid, builder: (column) => column); - - GeneratedColumn get recurrenceIdKey => $composableBuilder( - column: $table.recurrenceIdKey, - builder: (column) => column, - ); - - GeneratedColumn get occurrenceKey => $composableBuilder( - column: $table.occurrenceKey, - builder: (column) => column, - ); - - GeneratedColumn get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => column, - ); - - GeneratedColumn get providerRecurringEventId => $composableBuilder( - column: $table.providerRecurringEventId, - builder: (column) => column, - ); - - GeneratedColumn get providerOriginalStartKey => $composableBuilder( - column: $table.providerOriginalStartKey, - builder: (column) => column, - ); - - GeneratedColumn get etagOrChangeKey => $composableBuilder( - column: $table.etagOrChangeKey, - builder: (column) => column, - ); - - GeneratedColumn get status => - $composableBuilder(column: $table.status, builder: (column) => column); - - GeneratedColumn get title => - $composableBuilder(column: $table.title, builder: (column) => column); - - GeneratedColumn get description => $composableBuilder( - column: $table.description, - builder: (column) => column, - ); - - GeneratedColumn get location => - $composableBuilder(column: $table.location, builder: (column) => column); - - GeneratedColumn get allDay => - $composableBuilder(column: $table.allDay, builder: (column) => column); - - GeneratedColumn get startDate => - $composableBuilder(column: $table.startDate, builder: (column) => column); - - GeneratedColumn get startDateTime => $composableBuilder( - column: $table.startDateTime, - builder: (column) => column, - ); - - GeneratedColumn get startTimeZone => $composableBuilder( - column: $table.startTimeZone, - builder: (column) => column, - ); - - GeneratedColumn get endDate => - $composableBuilder(column: $table.endDate, builder: (column) => column); - - GeneratedColumn get endDateTime => $composableBuilder( - column: $table.endDateTime, + GeneratedColumn get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, builder: (column) => column, ); - GeneratedColumn get endTimeZone => $composableBuilder( - column: $table.endTimeZone, + GeneratedColumn get safeOrigin => $composableBuilder( + column: $table.safeOrigin, builder: (column) => column, ); - GeneratedColumn get recurrenceJson => $composableBuilder( - column: $table.recurrenceJson, + GeneratedColumn get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, builder: (column) => column, ); - GeneratedColumn get remindersJson => $composableBuilder( - column: $table.remindersJson, - builder: (column) => column, - ); + GeneratedColumn get etag => + $composableBuilder(column: $table.etag, builder: (column) => column); - GeneratedColumn get attendeesJson => $composableBuilder( - column: $table.attendeesJson, + GeneratedColumn get lastModified => $composableBuilder( + column: $table.lastModified, builder: (column) => column, ); - GeneratedColumn get categoriesJson => $composableBuilder( - column: $table.categoriesJson, + GeneratedColumn get contentType => $composableBuilder( + column: $table.contentType, builder: (column) => column, ); - GeneratedColumn get organizerJson => $composableBuilder( - column: $table.organizerJson, + GeneratedColumn get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, builder: (column) => column, ); - GeneratedColumn get creatorJson => $composableBuilder( - column: $table.creatorJson, + GeneratedColumn get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, builder: (column) => column, ); - GeneratedColumn get colorId => - $composableBuilder(column: $table.colorId, builder: (column) => column); - - GeneratedColumn get colorHex => - $composableBuilder(column: $table.colorHex, builder: (column) => column); - - GeneratedColumn get visibility => $composableBuilder( - column: $table.visibility, + GeneratedColumn get semanticHash => $composableBuilder( + column: $table.semanticHash, builder: (column) => column, ); - GeneratedColumn get transparencyOrShowAs => $composableBuilder( - column: $table.transparencyOrShowAs, + GeneratedColumn get refreshMode => $composableBuilder( + column: $table.refreshMode, builder: (column) => column, ); - GeneratedColumn get eventType => - $composableBuilder(column: $table.eventType, builder: (column) => column); - - GeneratedColumn get webLink => - $composableBuilder(column: $table.webLink, builder: (column) => column); - - GeneratedColumn get conferenceJson => $composableBuilder( - column: $table.conferenceJson, + GeneratedColumn get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, builder: (column) => column, ); - GeneratedColumn get attachmentsJson => $composableBuilder( - column: $table.attachmentsJson, + GeneratedColumn get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, builder: (column) => column, ); - GeneratedColumn get isCancelled => $composableBuilder( - column: $table.isCancelled, + GeneratedColumn get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, builder: (column) => column, ); - GeneratedColumn get isDeleted => - $composableBuilder(column: $table.isDeleted, builder: (column) => column); - - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); - - GeneratedColumn get createdAtServer => $composableBuilder( - column: $table.createdAtServer, + GeneratedColumn get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, builder: (column) => column, ); - GeneratedColumn get updatedAtServer => $composableBuilder( - column: $table.updatedAtServer, + GeneratedColumn get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, builder: (column) => column, ); - GeneratedColumn get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + GeneratedColumn get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, builder: (column) => column, ); - GeneratedColumn get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + GeneratedColumn get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, builder: (column) => column, ); - GeneratedColumn get syncStatus => $composableBuilder( - column: $table.syncStatus, + GeneratedColumn get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, builder: (column) => column, ); - GeneratedColumn get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, + GeneratedColumn get generation => $composableBuilder( + column: $table.generation, builder: (column) => column, ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$CalendarSourcesTableAnnotationComposer get calendarSourceId { - final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.calendarSourceId, - referencedTable: $db.calendarSources, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableAnnotationComposer( - $db: $db, - $table: $db.calendarSources, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + GeneratedColumn get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => column, + ); - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + GeneratedColumn get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => column, + ); + + GeneratedColumn get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( + }) => $$AccountsTableAnnotationComposer( $db: $db, - $table: $db.davCollections, + $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -36439,20 +40152,20 @@ class $$CalendarEventsTableAnnotationComposer return composer; } - $$DavObjectsTableAnnotationComposer get davObjectId { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( + }) => $$CalendarSourcesTableAnnotationComposer( $db: $db, - $table: $db.davObjects, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -36461,224 +40174,99 @@ class $$CalendarEventsTableAnnotationComposer ); return composer; } - - $$DavObjectComponentsTableAnnotationComposer get davComponentId { - final $$DavObjectComponentsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableAnnotationComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - Expression calendarEventAttendeesRefs( - Expression Function($$CalendarEventAttendeesTableAnnotationComposer a) f, - ) { - final $$CalendarEventAttendeesTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventAttendees, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventAttendeesTableAnnotationComposer( - $db: $db, - $table: $db.calendarEventAttendees, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression calendarEventRemindersRefs( - Expression Function($$CalendarEventRemindersTableAnnotationComposer a) f, - ) { - final $$CalendarEventRemindersTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventReminders, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventRemindersTableAnnotationComposer( - $db: $db, - $table: $db.calendarEventReminders, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$CalendarEventsTableTableManager +class $$WebCalSubscriptionsTableTableManager extends RootTableManager< _$AppDatabase, - $CalendarEventsTable, - CalendarEvent, - $$CalendarEventsTableFilterComposer, - $$CalendarEventsTableOrderingComposer, - $$CalendarEventsTableAnnotationComposer, - $$CalendarEventsTableCreateCompanionBuilder, - $$CalendarEventsTableUpdateCompanionBuilder, - (CalendarEvent, $$CalendarEventsTableReferences), - CalendarEvent, - PrefetchHooks Function({ - bool accountId, - bool calendarSourceId, - bool davCollectionId, - bool davObjectId, - bool davComponentId, - bool calendarEventAttendeesRefs, - bool calendarEventRemindersRefs, - }) + $WebCalSubscriptionsTable, + WebCalSubscription, + $$WebCalSubscriptionsTableFilterComposer, + $$WebCalSubscriptionsTableOrderingComposer, + $$WebCalSubscriptionsTableAnnotationComposer, + $$WebCalSubscriptionsTableCreateCompanionBuilder, + $$WebCalSubscriptionsTableUpdateCompanionBuilder, + (WebCalSubscription, $$WebCalSubscriptionsTableReferences), + WebCalSubscription, + PrefetchHooks Function({bool accountId, bool calendarSourceId}) > { - $$CalendarEventsTableTableManager( + $$WebCalSubscriptionsTableTableManager( _$AppDatabase db, - $CalendarEventsTable table, + $WebCalSubscriptionsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CalendarEventsTableFilterComposer($db: db, $table: table), + $$WebCalSubscriptionsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CalendarEventsTableOrderingComposer($db: db, $table: table), + $$WebCalSubscriptionsTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$CalendarEventsTableAnnotationComposer($db: db, $table: table), + $$WebCalSubscriptionsTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ Value id = const Value.absent(), Value accountId = const Value.absent(), Value calendarSourceId = const Value.absent(), - Value provider = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value providerEventId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value occurrenceKey = const Value.absent(), + Value feedFingerprint = const Value.absent(), + Value safeOrigin = const Value.absent(), + Value validatorTargetFingerprint = + const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + Value snapshotIcsBody = const Value.absent(), + Value rawBodyHash = const Value.absent(), + Value semanticHash = const Value.absent(), + Value refreshMode = const Value.absent(), + Value serverRefreshIntervalSeconds = const Value.absent(), + Value nextRefreshAtUtc = const Value.absent(), + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + Value generation = const Value.absent(), + Value parserVersion = const Value.absent(), Value projectionVersion = const Value.absent(), - Value providerRecurringEventId = const Value.absent(), - Value providerOriginalStartKey = const Value.absent(), - Value etagOrChangeKey = const Value.absent(), - Value status = const Value.absent(), - Value title = const Value.absent(), - Value description = const Value.absent(), - Value location = const Value.absent(), - Value allDay = const Value.absent(), - Value startDate = const Value.absent(), - Value startDateTime = const Value.absent(), - Value startTimeZone = const Value.absent(), - Value endDate = const Value.absent(), - Value endDateTime = const Value.absent(), - Value endTimeZone = const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value remindersJson = const Value.absent(), - Value attendeesJson = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value organizerJson = const Value.absent(), - Value creatorJson = const Value.absent(), - Value colorId = const Value.absent(), - Value colorHex = const Value.absent(), - Value visibility = const Value.absent(), - Value transparencyOrShowAs = const Value.absent(), - Value eventType = const Value.absent(), - Value webLink = const Value.absent(), - Value conferenceJson = const Value.absent(), - Value attachmentsJson = const Value.absent(), - Value isCancelled = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - Value createdAtServer = const Value.absent(), - Value updatedAtServer = const Value.absent(), - Value createdAtLocal = const Value.absent(), - Value updatedAtLocal = const Value.absent(), - Value syncStatus = const Value.absent(), - Value baselineRawJson = const Value.absent(), - Value rowid = const Value.absent(), - }) => CalendarEventsCompanion( - id: id, - accountId: accountId, - calendarSourceId: calendarSourceId, - provider: provider, - providerCalendarId: providerCalendarId, - providerEventId: providerEventId, - davCollectionId: davCollectionId, - davObjectId: davObjectId, - davComponentId: davComponentId, - icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - occurrenceKey: occurrenceKey, - projectionVersion: projectionVersion, - providerRecurringEventId: providerRecurringEventId, - providerOriginalStartKey: providerOriginalStartKey, - etagOrChangeKey: etagOrChangeKey, - status: status, - title: title, - description: description, - location: location, - allDay: allDay, - startDate: startDate, - startDateTime: startDateTime, - startTimeZone: startTimeZone, - endDate: endDate, - endDateTime: endDateTime, - endTimeZone: endTimeZone, - recurrenceJson: recurrenceJson, - remindersJson: remindersJson, - attendeesJson: attendeesJson, - categoriesJson: categoriesJson, - organizerJson: organizerJson, - creatorJson: creatorJson, - colorId: colorId, - colorHex: colorHex, - visibility: visibility, - transparencyOrShowAs: transparencyOrShowAs, - eventType: eventType, - webLink: webLink, - conferenceJson: conferenceJson, - attachmentsJson: attachmentsJson, - isCancelled: isCancelled, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtServer: createdAtServer, - updatedAtServer: updatedAtServer, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - syncStatus: syncStatus, - baselineRawJson: baselineRawJson, + Value createdAtUtc = const Value.absent(), + Value updatedAtUtc = const Value.absent(), + Value rowid = const Value.absent(), + }) => WebCalSubscriptionsCompanion( + id: id, + accountId: accountId, + calendarSourceId: calendarSourceId, + feedFingerprint: feedFingerprint, + safeOrigin: safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint, + etag: etag, + lastModified: lastModified, + contentType: contentType, + snapshotIcsBody: snapshotIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + refreshMode: refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + consecutiveFailureCount: consecutiveFailureCount, + lastFailureCode: lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus, + generation: generation, + parserVersion: parserVersion, + projectionVersion: projectionVersion, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: @@ -36686,131 +40274,73 @@ class $$CalendarEventsTableTableManager required String id, required String accountId, required String calendarSourceId, - required String provider, - required String providerCalendarId, - required String providerEventId, - Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value occurrenceKey = const Value.absent(), + required String feedFingerprint, + required String safeOrigin, + Value validatorTargetFingerprint = + const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + Value serverRefreshIntervalSeconds = const Value.absent(), + required String nextRefreshAtUtc, + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + Value generation = const Value.absent(), + Value parserVersion = const Value.absent(), Value projectionVersion = const Value.absent(), - Value providerRecurringEventId = const Value.absent(), - Value providerOriginalStartKey = const Value.absent(), - Value etagOrChangeKey = const Value.absent(), - Value status = const Value.absent(), - required String title, - Value description = const Value.absent(), - Value location = const Value.absent(), - Value allDay = const Value.absent(), - Value startDate = const Value.absent(), - Value startDateTime = const Value.absent(), - Value startTimeZone = const Value.absent(), - Value endDate = const Value.absent(), - Value endDateTime = const Value.absent(), - Value endTimeZone = const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value remindersJson = const Value.absent(), - Value attendeesJson = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value organizerJson = const Value.absent(), - Value creatorJson = const Value.absent(), - Value colorId = const Value.absent(), - Value colorHex = const Value.absent(), - Value visibility = const Value.absent(), - Value transparencyOrShowAs = const Value.absent(), - Value eventType = const Value.absent(), - Value webLink = const Value.absent(), - Value conferenceJson = const Value.absent(), - Value attachmentsJson = const Value.absent(), - Value isCancelled = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - Value createdAtServer = const Value.absent(), - Value updatedAtServer = const Value.absent(), - required int createdAtLocal, - required int updatedAtLocal, - Value syncStatus = const Value.absent(), - Value baselineRawJson = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, Value rowid = const Value.absent(), - }) => CalendarEventsCompanion.insert( + }) => WebCalSubscriptionsCompanion.insert( id: id, accountId: accountId, calendarSourceId: calendarSourceId, - provider: provider, - providerCalendarId: providerCalendarId, - providerEventId: providerEventId, - davCollectionId: davCollectionId, - davObjectId: davObjectId, - davComponentId: davComponentId, - icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - occurrenceKey: occurrenceKey, + feedFingerprint: feedFingerprint, + safeOrigin: safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint, + etag: etag, + lastModified: lastModified, + contentType: contentType, + snapshotIcsBody: snapshotIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + refreshMode: refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + consecutiveFailureCount: consecutiveFailureCount, + lastFailureCode: lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus, + generation: generation, + parserVersion: parserVersion, projectionVersion: projectionVersion, - providerRecurringEventId: providerRecurringEventId, - providerOriginalStartKey: providerOriginalStartKey, - etagOrChangeKey: etagOrChangeKey, - status: status, - title: title, - description: description, - location: location, - allDay: allDay, - startDate: startDate, - startDateTime: startDateTime, - startTimeZone: startTimeZone, - endDate: endDate, - endDateTime: endDateTime, - endTimeZone: endTimeZone, - recurrenceJson: recurrenceJson, - remindersJson: remindersJson, - attendeesJson: attendeesJson, - categoriesJson: categoriesJson, - organizerJson: organizerJson, - creatorJson: creatorJson, - colorId: colorId, - colorHex: colorHex, - visibility: visibility, - transparencyOrShowAs: transparencyOrShowAs, - eventType: eventType, - webLink: webLink, - conferenceJson: conferenceJson, - attachmentsJson: attachmentsJson, - isCancelled: isCancelled, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtServer: createdAtServer, - updatedAtServer: updatedAtServer, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - syncStatus: syncStatus, - baselineRawJson: baselineRawJson, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( e.readTable(table), - $$CalendarEventsTableReferences(db, table, e), + $$WebCalSubscriptionsTableReferences(db, table, e), ), ) .toList(), prefetchHooksCallback: - ({ - accountId = false, - calendarSourceId = false, - davCollectionId = false, - davObjectId = false, - davComponentId = false, - calendarEventAttendeesRefs = false, - calendarEventRemindersRefs = false, - }) { + ({accountId = false, calendarSourceId = false}) { return PrefetchHooks( db: db, - explicitlyWatchedTables: [ - if (calendarEventAttendeesRefs) db.calendarEventAttendees, - if (calendarEventRemindersRefs) db.calendarEventReminders, - ], + explicitlyWatchedTables: [], addJoins: < T extends TableManagerState< @@ -36833,10 +40363,10 @@ class $$CalendarEventsTableTableManager currentTable: table, currentColumn: table.accountId, referencedTable: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._accountIdTable(db), referencedColumn: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._accountIdTable(db) .id, ) @@ -36848,108 +40378,20 @@ class $$CalendarEventsTableTableManager currentTable: table, currentColumn: table.calendarSourceId, referencedTable: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._calendarSourceIdTable(db), referencedColumn: - $$CalendarEventsTableReferences + $$WebCalSubscriptionsTableReferences ._calendarSourceIdTable(db) .id, ) as T; } - if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } - if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: - $$CalendarEventsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; - } - if (davComponentId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davComponentId, - referencedTable: - $$CalendarEventsTableReferences - ._davComponentIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davComponentIdTable(db) - .id, - ) - as T; - } return state; }, getPrefetchedDataCallback: (items) async { - return [ - if (calendarEventAttendeesRefs) - await $_getPrefetchedData< - CalendarEvent, - $CalendarEventsTable, - CalendarEventAttendee - >( - currentTable: table, - referencedTable: $$CalendarEventsTableReferences - ._calendarEventAttendeesRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarEventsTableReferences( - db, - table, - p0, - ).calendarEventAttendeesRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarEventId == item.id, - ), - typedResults: items, - ), - if (calendarEventRemindersRefs) - await $_getPrefetchedData< - CalendarEvent, - $CalendarEventsTable, - CalendarEventReminder - >( - currentTable: table, - referencedTable: $$CalendarEventsTableReferences - ._calendarEventRemindersRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarEventsTableReferences( - db, - table, - p0, - ).calendarEventRemindersRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarEventId == item.id, - ), - typedResults: items, - ), - ]; + return []; }, ); }, @@ -36957,27 +40399,19 @@ class $$CalendarEventsTableTableManager ); } -typedef $$CalendarEventsTableProcessedTableManager = +typedef $$WebCalSubscriptionsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $CalendarEventsTable, - CalendarEvent, - $$CalendarEventsTableFilterComposer, - $$CalendarEventsTableOrderingComposer, - $$CalendarEventsTableAnnotationComposer, - $$CalendarEventsTableCreateCompanionBuilder, - $$CalendarEventsTableUpdateCompanionBuilder, - (CalendarEvent, $$CalendarEventsTableReferences), - CalendarEvent, - PrefetchHooks Function({ - bool accountId, - bool calendarSourceId, - bool davCollectionId, - bool davObjectId, - bool davComponentId, - bool calendarEventAttendeesRefs, - bool calendarEventRemindersRefs, - }) + $WebCalSubscriptionsTable, + WebCalSubscription, + $$WebCalSubscriptionsTableFilterComposer, + $$WebCalSubscriptionsTableOrderingComposer, + $$WebCalSubscriptionsTableAnnotationComposer, + $$WebCalSubscriptionsTableCreateCompanionBuilder, + $$WebCalSubscriptionsTableUpdateCompanionBuilder, + (WebCalSubscription, $$WebCalSubscriptionsTableReferences), + WebCalSubscription, + PrefetchHooks Function({bool accountId, bool calendarSourceId}) >; typedef $$CalendarEventAttendeesTableCreateCompanionBuilder = CalendarEventAttendeesCompanion Function({ @@ -39721,6 +43155,10 @@ class $AppDatabaseManager { $$CalendarSourcesTableTableManager(_db, _db.calendarSources); $$CalendarEventsTableTableManager get calendarEvents => $$CalendarEventsTableTableManager(_db, _db.calendarEvents); + $$IcalImportReceiptsTableTableManager get icalImportReceipts => + $$IcalImportReceiptsTableTableManager(_db, _db.icalImportReceipts); + $$WebCalSubscriptionsTableTableManager get webCalSubscriptions => + $$WebCalSubscriptionsTableTableManager(_db, _db.webCalSubscriptions); $$CalendarEventAttendeesTableTableManager get calendarEventAttendees => $$CalendarEventAttendeesTableTableManager( _db, diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index a7119f82..c3efa1dc 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; -const latestSchemaVersion = 10; +const latestSchemaVersion = 11; /// A recoverable, non-secret diagnostic raised when an on-disk schema cannot /// be migrated without guessing remote identity or losing synchronized data. @@ -54,6 +54,9 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { if (from < 10) { await _migrateToV10(migrator, database); } + if (from < 11) { + await _migrateToV11(migrator, database); + } await _createIndexes(database); await _verifyForeignKeys(database); }, @@ -63,6 +66,14 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { ); } +Future _migrateToV11(Migrator migrator, AppDatabase database) async { + // Rebuild Accounts so SQLite adopts the extended provider and credential + // constraints while preserving every existing row and identifier. + await migrator.alterTable(TableMigration(database.accounts)); + await migrator.createTable(database.icalImportReceipts); + await migrator.createTable(database.webCalSubscriptions); +} + Future _migrateToV10(Migrator migrator, AppDatabase database) async { for (final column in [ database.calendarSources.dataOwner, @@ -698,6 +709,12 @@ Future _createIndexes(AppDatabase database) async { 'dismissed_at_utc, snoozed_until_utc)', ); } + if (await _hasTable(database, 'web_cal_subscriptions')) { + await database.customStatement( + 'CREATE INDEX IF NOT EXISTS idx_webcal_subscriptions_due ' + 'ON web_cal_subscriptions(next_refresh_at_utc)', + ); + } } Future _addColumnIfMissing( diff --git a/lib/src/db/tables.dart b/lib/src/db/tables.dart index 0e62c7da..7e2c1e69 100644 --- a/lib/src/db/tables.dart +++ b/lib/src/db/tables.dart @@ -29,9 +29,10 @@ class Accounts extends Table { @override List get customConstraints => const [ - "CHECK (provider IN ('google', 'microsoft', 'apple_icloud', 'nextcloud'))", + "CHECK (provider IN ('google', 'microsoft', 'apple_icloud', 'nextcloud', " + "'webcal'))", "CHECK (credential_kind IN ('oauth', 'apple_app_specific_password', " - "'nextcloud_app_password'))", + "'nextcloud_app_password', 'webcal_subscription'))", 'CHECK (length(trim(authority)) > 0)', 'CHECK (length(trim(provider_account_id)) > 0)', ]; @@ -484,6 +485,58 @@ class CalendarEvents extends Table { Set> get primaryKey => {id}; } +class IcalImportReceipts extends Table { + TextColumn get calendarSourceId => + text().references(CalendarSources, #id, onDelete: KeyAction.cascade)(); + TextColumn get icalUid => text()(); + TextColumn get eventId => text().nullable()(); + TextColumn get importedAtUtc => text()(); + + @override + Set> get primaryKey => {calendarSourceId, icalUid}; +} + +class WebCalSubscriptions extends Table { + TextColumn get id => text()(); + TextColumn get accountId => + text().references(Accounts, #id, onDelete: KeyAction.cascade)(); + TextColumn get calendarSourceId => + text().references(CalendarSources, #id, onDelete: KeyAction.cascade)(); + TextColumn get feedFingerprint => text().unique()(); + TextColumn get safeOrigin => text()(); + TextColumn get validatorTargetFingerprint => text().nullable()(); + TextColumn get etag => text().nullable()(); + TextColumn get lastModified => text().nullable()(); + TextColumn get contentType => text().nullable()(); + TextColumn get snapshotIcsBody => text()(); + TextColumn get rawBodyHash => text()(); + TextColumn get semanticHash => text()(); + TextColumn get refreshMode => text()(); + IntColumn get serverRefreshIntervalSeconds => integer().nullable()(); + TextColumn get nextRefreshAtUtc => text()(); + TextColumn get lastCheckedAtUtc => text().nullable()(); + TextColumn get lastSuccessfulSyncAtUtc => text().nullable()(); + TextColumn get lastChangedAtUtc => text().nullable()(); + IntColumn get consecutiveFailureCount => + integer().withDefault(const Constant(0))(); + TextColumn get lastFailureCode => text().nullable()(); + IntColumn get lastFailureHttpStatus => integer().nullable()(); + IntColumn get generation => integer().withDefault(const Constant(1))(); + IntColumn get parserVersion => integer().withDefault(const Constant(1))(); + IntColumn get projectionVersion => integer().withDefault(const Constant(1))(); + TextColumn get createdAtUtc => text()(); + TextColumn get updatedAtUtc => text()(); + + @override + Set> get primaryKey => {id}; + + @override + List>> get uniqueKeys => [ + {accountId}, + {calendarSourceId}, + ]; +} + class CalendarEventAttendees extends Table { TextColumn get id => text()(); TextColumn get calendarEventId => diff --git a/lib/src/demo/demo_profile.dart b/lib/src/demo/demo_profile.dart index a5b19358..e6d1d72c 100644 --- a/lib/src/demo/demo_profile.dart +++ b/lib/src/demo/demo_profile.dart @@ -105,12 +105,12 @@ class BusyMaxDemoProfile { ), syncSchedulerProvider.overrideWith((ref) { final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async => const [], + listSyncEligibleAccounts: () async => const [], syncAccount: (_) async {}, onSyncFailure: (_) async {}, interval: Duration.zero, ); - ref.onDispose(scheduler.stop); + ref.onDispose(() => unawaited(scheduler.dispose())); return scheduler; }), notificationSchedulerProvider.overrideWith((ref) { diff --git a/lib/src/features/accounts/data/accounts_repository.dart b/lib/src/features/accounts/data/accounts_repository.dart index 3f125db4..b91560cc 100644 --- a/lib/src/features/accounts/data/accounts_repository.dart +++ b/lib/src/features/accounts/data/accounts_repository.dart @@ -85,6 +85,14 @@ class AccountEntity { AccountConnectionStateCodec.parse(authState); bool get isSignedIn => authState == accountAuthStateSignedIn; + bool get isSubscription => provider == BusyProvider.webCal; + bool get isAuthenticationAccount => !isSubscription; + bool get isTaskCapable => + !isSubscription && tasksEnabled && provider != BusyProvider.appleICloud; + bool get isScheduleVisible => calendarsEnabled; + bool get isSyncEligible => + authState == accountAuthStateSignedIn || + authState == accountAuthStateTemporarilyUnavailable; bool get needsReconnect => authState == accountAuthStateReauthRequired; bool get hasConnectionIssue => authState != accountAuthStateSignedIn; @@ -164,7 +172,11 @@ class AccountsRepository { Stream> watchVisibleAccounts() { final query = _database.select(_database.accounts) - ..where((row) => row.authState.isIn([...accountCachedAvailableStates])) + ..where( + (row) => + row.authState.isIn([...accountCachedAvailableStates]) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + ) ..orderBy([ (row) => OrderingTerm.asc(row.provider), (row) => OrderingTerm.asc(row.displayName), @@ -177,7 +189,11 @@ class AccountsRepository { Future> listSignedInAccounts() async { final query = _database.select(_database.accounts) - ..where((row) => row.authState.equals(accountAuthStateSignedIn)) + ..where( + (row) => + row.authState.equals(accountAuthStateSignedIn) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + ) ..orderBy([ (row) => OrderingTerm.asc(row.provider), (row) => OrderingTerm.asc(row.displayName), @@ -205,7 +221,11 @@ class AccountsRepository { Future> listVisibleAccounts() async { final query = _database.select(_database.accounts) - ..where((row) => row.authState.isIn(accountCachedAvailableStates)) + ..where( + (row) => + row.authState.isIn(accountCachedAvailableStates) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + ) ..orderBy([ (row) => OrderingTerm.asc(row.provider), (row) => OrderingTerm.asc(row.displayName), @@ -338,12 +358,14 @@ CredentialKind _defaultCredentialKind(BusyProvider provider) => BusyProvider.google || BusyProvider.microsoft => CredentialKind.oauth, BusyProvider.appleICloud => CredentialKind.appleAppSpecificPassword, BusyProvider.nextcloud => CredentialKind.nextcloudAppPassword, + BusyProvider.webCal => CredentialKind.webCalSubscription, }; CredentialKind _credentialKindFromStorage(String value) => switch (value) { 'oauth' => CredentialKind.oauth, 'apple_app_specific_password' => CredentialKind.appleAppSpecificPassword, 'nextcloud_app_password' => CredentialKind.nextcloudAppPassword, + 'webcal_subscription' => CredentialKind.webCalSubscription, _ => throw SecretStoreCorruptException( 'Unsupported stored credential kind for account metadata.', ), diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index a7bc0e0b..194802dd 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -201,6 +201,9 @@ class AuthRepository { // DAV credentials are cleared through SecretStore once the account is // moved to reauthentication-required state. break; + case BusyProvider.webCal: + // Subscriptions never participate in authentication recovery. + break; } } diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index fa7275aa..c641a840 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -179,6 +179,8 @@ class _SignInScreenState extends ConsumerState { ), onAddNextcloud: () => _signIn(_OnboardingProvider.nextcloud), + onAddSubscription: () => + context.go('/settings?page=accounts'), onCancelSignIn: _cancelSignIn, ), _OnboardingStep.preferences => @@ -479,6 +481,7 @@ class _AccountsOnboardingStep extends StatelessWidget { required this.onAddMicrosoft, required this.onAddApple, required this.onAddNextcloud, + required this.onAddSubscription, required this.onCancelSignIn, }); @@ -495,6 +498,7 @@ class _AccountsOnboardingStep extends StatelessWidget { final VoidCallback onAddMicrosoft; final VoidCallback onAddApple; final VoidCallback onAddNextcloud; + final VoidCallback onAddSubscription; final VoidCallback onCancelSignIn; @override @@ -564,6 +568,16 @@ class _AccountsOnboardingStep extends StatelessWidget { tooltip: l10n.addNextcloudAccount, onPressed: onAddNextcloud, ), + const SizedBox(height: BusyMaxSpacing.md), + _ProviderSignInButton( + label: l10n.addCalendarSubscription, + loadingLabel: l10n.addCalendarSubscription, + configured: true, + enabled: !isSigningIn, + loading: false, + tooltip: l10n.calendarSubscriptionsDescription, + onPressed: onAddSubscription, + ), if (accounts.isNotEmpty) ...[ const SizedBox(height: BusyMaxSpacing.lg), _SignedInAccountsSummary(accounts: accounts), diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 94ef9f59..44ae9f1f 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -576,6 +576,9 @@ class CalendarRepository { BusyProvider.appleICloud || BusyProvider.nextcloud => throw StateError( 'DAV calendar colors are not mutable.', ), + BusyProvider.webCal => throw StateError( + 'WebCal subscription colors use local subscription operations.', + ), }; await _database.transaction(() async { await (_database.update( @@ -812,12 +815,16 @@ class CalendarRepository { ? baseline['summaryOverride']!.toString() : baseline['summary']?.toString(), BusyProvider.microsoft => baseline['name']?.toString(), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; final baselineColorId = switch (provider) { BusyProvider.google => baseline['colorId']?.toString(), BusyProvider.microsoft => baseline['color']?.toString(), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; final baselineBackground = switch (provider) { BusyProvider.google => baseline['backgroundColor']?.toString(), @@ -826,7 +833,9 @@ class CalendarRepository { backgroundColor: baseline['hexColor']?.toString(), colorId: baselineColorId, ), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; await (_database.update( _database.calendarSources, @@ -1030,6 +1039,7 @@ class CalendarRepository { EventEditorDraft draft, { CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, + bool rebuildNotifications = true, }) async { final source = await (_database.select( _database.calendarSources, @@ -1046,7 +1056,11 @@ class CalendarRepository { ); } if (source.davCollectionId != null) { - return _createLocalDavEvent(source, draft); + return _createLocalDavEvent( + source, + draft, + rebuildNotifications: rebuildNotifications, + ); } final calendarCreateOp = await _pendingCalendarCreate(source.id); final now = _now().millisecondsSinceEpoch; @@ -1148,11 +1162,57 @@ class CalendarRepository { ), ); }); + if (rebuildNotifications) { + await _notificationScheduleService().rebuildUpcomingEventNotifications( + draft.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + return operationId; + } + + Future> createImportedEventsBatch({ + required CalendarSourceEntity destination, + required List<({String icalUid, EventEditorDraft draft})> events, + }) async { + if (events.isEmpty) return const []; + if (events.any( + (event) => + event.draft.accountId != destination.accountId || + event.draft.sourceId != destination.id || + event.draft.providerCalendarId != destination.providerCalendarId || + event.draft.attendees.isNotEmpty || + event.draft.createConference || + event.draft.conference != null, + )) { + throw ArgumentError('The import batch contains an unsafe event draft.'); + } + final operationIds = []; + await _database.transaction(() async { + for (final event in events) { + final operationId = await createLocalEvent( + event.draft, + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + rebuildNotifications: false, + ); + operationIds.add(operationId); + await _database + .into(_database.icalImportReceipts) + .insert( + IcalImportReceiptsCompanion.insert( + calendarSourceId: destination.id, + icalUid: event.icalUid, + eventId: const Value(null), + importedAtUtc: _now().toUtc().toIso8601String(), + ), + ); + } + }); await _notificationScheduleService().rebuildUpcomingEventNotifications( - draft.accountId, + destination.accountId, ); await _onNotificationScheduleChanged?.call(); - return operationId; + return operationIds; } Future updateLocalEvent( @@ -2453,8 +2513,9 @@ class CalendarRepository { Future _createLocalDavEvent( CalendarSource source, - EventEditorDraft draft, - ) async { + EventEditorDraft draft, { + required bool rebuildNotifications, + }) async { _requireDavSchedulingUnchanged(draft, creating: true); final start = draft.start; final end = draft.end; @@ -2551,10 +2612,12 @@ class CalendarRepository { localProjectionId: projectionId, ); }); - await _notificationScheduleService().rebuildUpcomingEventNotifications( - draft.accountId, - ); - await _onNotificationScheduleChanged?.call(); + if (rebuildNotifications) { + await _notificationScheduleService().rebuildUpcomingEventNotifications( + draft.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } return operationId; } @@ -3515,7 +3578,9 @@ CalendarEventsCompanion _eventPatchProjection({ request.containsKey('responseRequested') || request.containsKey('hideAttendees') || request.containsKey('allowNewTimeProposals'), - BusyProvider.appleICloud || BusyProvider.nextcloud => false, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => false, }; return CalendarEventsCompanion( title: request.containsKey('title') @@ -3731,7 +3796,9 @@ Object? _conferenceRequest(EventEditorDraft draft, BusyProvider provider) { }, }, BusyProvider.microsoft => 'teamsForBusiness', - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; } @@ -3857,6 +3924,7 @@ Map _optimisticEventRaw( } case BusyProvider.appleICloud: case BusyProvider.nextcloud: + case BusyProvider.webCal: break; } return raw; @@ -3906,6 +3974,7 @@ Map _optimisticEventRawForPatch( ); case BusyProvider.appleICloud: case BusyProvider.nextcloud: + case BusyProvider.webCal: break; } return raw; diff --git a/lib/src/features/calendar/presentation/calendar_color_dialog.dart b/lib/src/features/calendar/presentation/calendar_color_dialog.dart index 58ab9360..1774a912 100644 --- a/lib/src/features/calendar/presentation/calendar_color_dialog.dart +++ b/lib/src/features/calendar/presentation/calendar_color_dialog.dart @@ -57,7 +57,9 @@ class _CalendarColorDialogState extends State<_CalendarColorDialog> { BusyProvider.microsoft => _findChoice( (choice) => choice.providerValue == widget.currentColorId, ), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; } diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 5e1de344..5a340551 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -1240,7 +1240,9 @@ class _EventEditorState extends State { BusyProvider.microsoft => source.allowedConferenceSolutions.contains( 'teamsForBusiness', ), - BusyProvider.appleICloud || BusyProvider.nextcloud => false, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => false, }; if (hasConference || canCreateConference) { rows.add( diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart index cf1651ed..61937e4e 100644 --- a/lib/src/features/calendar/presentation/event_editor_draft.dart +++ b/lib/src/features/calendar/presentation/event_editor_draft.dart @@ -172,7 +172,9 @@ class EventEditorDraft { final isOrganizer = switch (detail.provider) { BusyProvider.google => _jsonBool(organizer?['self']), BusyProvider.microsoft => _jsonBool(raw['isOrganizer']), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; final hideAttendees = switch (detail.provider) { BusyProvider.google => @@ -180,7 +182,9 @@ class EventEditorDraft { ? !(raw['guestsCanSeeOtherGuests'] as bool) : null, BusyProvider.microsoft => _jsonBool(raw['hideAttendees']), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; return EventEditorDraft.existing( diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index 5da9ec34..185a444d 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -456,7 +456,8 @@ List<_EventReminder> _eventReminders( ], startUtc: startUtc); } if ((provider == BusyProvider.appleICloud || - provider == BusyProvider.nextcloud) && + provider == BusyProvider.nextcloud || + provider == BusyProvider.webCal) && decoded is Map) { return _davEventReminders( decoded.cast(), diff --git a/lib/src/features/recurrence/domain/event_recurrence_codec.dart b/lib/src/features/recurrence/domain/event_recurrence_codec.dart index 33eb1250..ac7bdf2a 100644 --- a/lib/src/features/recurrence/domain/event_recurrence_codec.dart +++ b/lib/src/features/recurrence/domain/event_recurrence_codec.dart @@ -94,11 +94,13 @@ abstract final class EventRecurrenceCodec { BusyProvider.microsoft => _decodeMicrosoft(value, baseDate: baseDate), BusyProvider.google || BusyProvider.appleICloud || - BusyProvider.nextcloud => _decodeIcalendar(value, baseDate: baseDate), + BusyProvider.nextcloud || + BusyProvider.webCal => _decodeIcalendar(value, baseDate: baseDate), }; } static bool canEncode(BusyProvider provider, RecurrenceRule rule) { + if (provider == BusyProvider.webCal) return false; return limitsFor(provider).supports(rule); } @@ -132,6 +134,9 @@ abstract final class EventRecurrenceCodec { timeZone: timeZone, original: original, ), + BusyProvider.webCal => throw StateError( + 'WebCal subscriptions are read-only.', + ), }; } } diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 807f1b9b..e411fb6f 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -13,8 +14,11 @@ import '../../../app/busymax_glyphs.dart'; import '../../../calendar_providers/calendar_colors.dart'; import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../l10n/l10n.dart'; +import '../../../ical/ical_import_service.dart'; +import '../../../ical/ical_ingestion.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import '../../../webcal/webcal_subscription_service.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../calendar/data/calendar_repository.dart'; import '../../calendar/presentation/calendar_color_dialog.dart'; @@ -64,11 +68,26 @@ class ScheduleSidebar extends ConsumerWidget { child: ListView( padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), children: [ - for (final account in accounts) + BusyMaxActionRow( + key: const ValueKey('import-ics-file'), + title: context.l10n.importIcsFile, + leading: const Icon(Icons.file_open_outlined), + onTap: () => unawaited(showIcsImportFlow(context, ref)), + ), + for (final account in accounts.where( + (account) => !account.isSubscription, + )) _AccountSourcesGroup( key: ValueKey(('schedule-account', account.id)), account: account, ), + if (accounts.any((account) => account.isSubscription)) + _SubscriptionSourcesGroup( + accounts: [ + for (final account in accounts) + if (account.isSubscription) account, + ], + ), ], ), ), @@ -363,6 +382,114 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { } } +class _SubscriptionSourcesGroup extends ConsumerWidget { + const _SubscriptionSourcesGroup({required this.accounts}); + + final List accounts; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accountById = {for (final account in accounts) account.id: account}; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + start: BusyMaxSpacing.md, + top: BusyMaxSpacing.md, + end: BusyMaxSpacing.md, + bottom: BusyMaxSpacing.xs, + ), + child: Text( + context.l10n.subscriptions, + style: Theme.of(context).textTheme.labelLarge, + ), + ), + StreamBuilder>( + stream: ref + .watch(calendarRepositoryProvider) + .watchSourcesForAccounts(accountById.keys.toList()), + builder: (context, snapshot) { + final sources = (snapshot.data ?? const []) + .where((source) => source.provider == BusyProvider.webCal); + return Column( + children: [ + for (final source in sources) + _SubscriptionSourceRow( + key: ValueKey(('subscription-source', source.id)), + source: source, + ), + ], + ); + }, + ), + ], + ); + } +} + +class _SubscriptionSourceRow extends ConsumerWidget { + const _SubscriptionSourceRow({super.key, required this.source}); + + final CalendarSourceEntity source; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _CompactSourceRow( + title: '${source.summary} · ${context.l10n.readOnlySharedCollection}', + leading: _SourceDot(seed: source.id, colorHex: source.backgroundColor), + trailing: _SourceRowActions( + visibilityButton: _SourceVisibilityButton( + value: source.selected && !source.hidden, + semanticLabel: source.summary, + onChanged: (value) => ref + .read(calendarRepositoryProvider) + .setSourceSelected(source.id, value), + ), + menuButton: BusyMaxMenuButton( + tooltip: context.l10n.options, + highlightWhenOpen: false, + onSelected: (value) { + switch (value) { + case 'refresh': + unawaited(_refreshSubscriptionSource(context, ref, source)); + case 'rename': + unawaited(_renameSubscriptionSource(context, ref, source)); + case 'color': + unawaited(_colorSubscriptionSource(context, ref, source)); + case 'unsubscribe': + unawaited(_unsubscribeSource(context, ref, source)); + } + }, + entries: [ + BusyMaxMenuEntry( + value: 'refresh', + label: context.l10n.refreshNow, + icon: YaruIcons.refresh, + ), + BusyMaxMenuEntry( + value: 'rename', + label: context.l10n.rename, + icon: Icons.edit_outlined, + ), + BusyMaxMenuEntry( + value: 'color', + label: context.l10n.calendarColor, + icon: Icons.palette_outlined, + ), + BusyMaxMenuEntry( + value: 'unsubscribe', + label: context.l10n.unsubscribe, + icon: YaruIcons.trash, + destructive: true, + ), + ], + ), + ), + ); + } +} + class _AccountHeaderRow extends StatelessWidget { const _AccountHeaderRow({ required this.account, @@ -641,6 +768,7 @@ String scheduleTaskListLabel( BusyProvider.microsoft => context.l10n.microsoftTodoProvider, BusyProvider.appleICloud => context.l10n.appleICloudTasksProvider, BusyProvider.nextcloud => context.l10n.nextcloudTasksProvider, + BusyProvider.webCal => 'WebCal', }; final title = list.title.trim(); if (title.isEmpty || @@ -678,6 +806,7 @@ Uri? scheduleCalendarProviderWebUri( ), BusyProvider.appleICloud => null, BusyProvider.nextcloud => _safeAccountWebUri(account.authority), + BusyProvider.webCal => null, }; } @@ -688,6 +817,7 @@ Uri? scheduleTaskProviderWebUri(AccountEntity account) { BusyProvider.microsoft => Uri.https('to-do.office.com', '/tasks/'), BusyProvider.appleICloud => null, BusyProvider.nextcloud => _safeAccountWebUri(account.authority), + BusyProvider.webCal => null, }; } @@ -1111,3 +1241,287 @@ Future _deleteTaskList( } } } + +Future showIcsImportFlow( + BuildContext context, + WidgetRef ref, { + String? filePath, +}) async { + const typeGroup = XTypeGroup( + label: 'iCalendar', + extensions: ['ics'], + mimeTypes: ['text/calendar'], + ); + final file = filePath == null + ? await openFile(acceptedTypeGroups: const [typeGroup]) + : XFile(filePath); + if (file == null || !context.mounted) return; + try { + final length = await file.length(); + if (length > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + final service = ref.read(icalImportServiceProvider); + final preview = service.parsePreview(await file.readAsBytes()); + final destinations = await service.writableDestinations(); + final accountLabels = { + for (final account + in await ref.read(accountsRepositoryProvider).watchAccounts().first) + account.id: account.selectorLabel, + }; + if (!context.mounted) return; + final destination = await showBusyMaxModalDialog( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + barrierDismissible: false, + builder: (dialogContext) => _IcalImportPreviewDialog( + preview: preview, + destinations: destinations, + accountLabels: accountLabels, + ), + ); + if (destination == null || !context.mounted) return; + final report = await service.importPreview( + preview: preview, + destination: destination, + ); + if (!context.mounted) return; + await showBusyMaxModalDialog( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + builder: (dialogContext) => _IcalImportReportDialog(report: report), + ); + } on Object catch (error) { + if (!context.mounted) return; + final code = switch (error) { + IcalIngestionException(:final code) => code, + _ => 'IcalImportFailed', + }; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.importIcsFailed(code)))); + } +} + +class _IcalImportPreviewDialog extends StatefulWidget { + const _IcalImportPreviewDialog({ + required this.preview, + required this.destinations, + required this.accountLabels, + }); + + final IcalImportPreview preview; + final List destinations; + final Map accountLabels; + + @override + State<_IcalImportPreviewDialog> createState() => + _IcalImportPreviewDialogState(); +} + +class _IcalImportPreviewDialogState extends State<_IcalImportPreviewDialog> { + CalendarSourceEntity? _destination; + + @override + void initState() { + super.initState(); + _destination = widget.destinations.firstOrNull; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final omitted = widget.preview.fieldsThatWillBeOmitted.toList()..sort(); + return BusyMaxDialogShell( + title: l10n.importIcsPreview, + maxWidth: BusyMaxSizes.compactDetailsWidth, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('confirm-ics-import'), + onPressed: _destination == null + ? null + : () => Navigator.of(context).pop(_destination), + child: Text(l10n.importIcsConfirm), + ), + ], + children: [ + Text(l10n.importEventsFound(widget.preview.eventCount)), + if (widget.preview.invalidEventCount > 0) + Text(l10n.importInvalidEvents(widget.preview.invalidEventCount)), + if (omitted.isNotEmpty) + Text(l10n.importFieldsOmitted(omitted.join(', '))), + const SizedBox(height: BusyMaxSpacing.md), + if (widget.destinations.isEmpty) + Text(l10n.noWritableCalendars) + else + BusyMaxComboRow( + key: const ValueKey('ics-import-destination'), + title: l10n.importDestinationCalendar, + values: widget.destinations, + selected: _destination!, + labelFor: (source) => + '${widget.accountLabels[source.accountId] ?? source.provider.displayName} · ${source.summary}', + onSelected: (value) => setState(() => _destination = value), + ), + ], + ); + } +} + +class _IcalImportReportDialog extends StatelessWidget { + const _IcalImportReportDialog({required this.report}); + + final IcalImportReport report; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final omitted = report.fieldsIntentionallyOmitted.toList()..sort(); + final unsupported = report.unsupportedRecurrenceSets + .map((item) => '${item.uid}: ${item.reason}') + .toList(growable: false); + return BusyMaxDialogShell( + title: l10n.importIcsComplete, + actions: [ + BusyMaxPushButton.suggested( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.close), + ), + ], + children: [ + Text( + [ + l10n.importQueued(report.queued), + l10n.importDuplicatesSkipped(report.duplicatesSkipped), + l10n.importUnsupportedSets(report.unsupportedRecurrenceSets.length), + ...unsupported, + l10n.importInvalidEvents(report.invalidEvents), + if (omitted.isNotEmpty) + l10n.importFieldsOmitted(omitted.join(', ')), + ].join('\n'), + ), + ], + ); + } +} + +Future _subscriptionForSource( + WidgetRef ref, + CalendarSourceEntity source, +) async { + final subscriptions = await ref + .read(webCalSubscriptionServiceProvider) + .listSubscriptions(); + return subscriptions + .where((subscription) => subscription.calendarSourceId == source.id) + .firstOrNull; +} + +Future _refreshSubscriptionSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .refreshSubscription(subscription.id, force: true); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +Future _renameSubscriptionSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final name = await showBusyMaxTextPrompt( + context, + title: context.l10n.rename, + label: context.l10n.subscriptionName, + actionLabel: context.l10n.rename, + initialValue: source.summary, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (name == null || !context.mounted) return; + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .renameSubscription(subscription.id, name); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +Future _colorSubscriptionSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final color = await showBusyMaxTextPrompt( + context, + title: context.l10n.calendarColor, + label: context.l10n.subscriptionColor, + actionLabel: context.l10n.save, + initialValue: source.backgroundColor, + message: context.l10n.subscriptionColorHelp, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (color == null || !context.mounted) return; + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .changeSubscriptionColor(subscription.id, color); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +Future _unsubscribeSource( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.unsubscribeCalendarTitle(source.summary), + message: context.l10n.unsubscribeCalendarConfirmation, + confirmLabel: context.l10n.unsubscribe, + destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!confirmed || !context.mounted) return; + try { + final subscription = await _subscriptionForSource(ref, source); + if (subscription == null) return; + await ref + .read(webCalSubscriptionServiceProvider) + .unsubscribe(subscription.id); + } on Object catch (error) { + if (context.mounted) _showWebCalError(context, error); + } +} + +void _showWebCalError(BuildContext context, Object error) { + final code = switch (error) { + WebCalSubscriptionException(:final code) => code, + _ => 'WebCalOperationFailed', + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.subscriptionOperationFailed(code))), + ); +} diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index e1d957d9..fea943f6 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1011,7 +1011,7 @@ class _ScheduleWorkspaceState extends ConsumerState { List accounts, ) async { final allLists = []; - for (final account in accounts) { + for (final account in accounts.where((account) => account.isTaskCapable)) { final repository = ref.read( taskListsRepositoryForAccountProvider(account.id), ); @@ -1142,15 +1142,18 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - void _setMode(ScheduleViewMode mode) { + void _setMode(ScheduleViewMode mode, {DateTime? agendaDate}) { if (_mode == mode) { + if (mode == ScheduleViewMode.agenda && agendaDate != null) { + _setDate(agendaDate); + } return; } setState(() { _mode = mode; _lastSettingsMode = mode; if (mode == ScheduleViewMode.agenda) { - _selectedDate = _day(DateTime.now()); + _selectedDate = _day(agendaDate ?? DateTime.now()); _resetAgendaLoadedDays(); } if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { @@ -2290,7 +2293,7 @@ class _ScheduleWorkspaceState extends ConsumerState { try { final accounts = await ref .read(accountsRepositoryProvider) - .listSignedInAccounts(); + .listSyncEligibleAccounts(); if (accounts.isEmpty) { return; } @@ -2342,7 +2345,7 @@ class _ScheduleWorkspaceState extends ConsumerState { case ScheduleWorkspaceCommandKind.today: _goToToday(); case ScheduleWorkspaceCommandKind.agenda: - _setMode(ScheduleViewMode.agenda); + _setMode(ScheduleViewMode.agenda, agendaDate: command.date); case ScheduleWorkspaceCommandKind.newEvent: unawaited(_openNewEvent(sources, _selectedDate)); case ScheduleWorkspaceCommandKind.newTask: diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 0064122e..35a890e9 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -25,6 +25,8 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; import '../../../l10n/app_locale.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../webcal/webcal_subscription_service.dart'; +import '../../../webcal/webcal_uri.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../accounts/domain/account_connection_state.dart'; @@ -57,6 +59,7 @@ class _SettingsScreenState extends ConsumerState { BusyProvider? _connectingProvider; DavCancellationToken? _davCancellation; final _removingAccountIds = {}; + final _busySubscriptionIds = {}; @override void initState() { @@ -93,6 +96,8 @@ class _SettingsScreenState extends ConsumerState { ref.watch(davCollectionsStreamProvider).valueOrNull ?? const []; final davConflicts = ref.watch(davConflictsStreamProvider).valueOrNull ?? const []; + final subscriptions = + ref.watch(webCalSubscriptionsProvider).valueOrNull ?? const []; final config = ref.watch(buildConfigProvider); final settings = ref.watch(appSettingsControllerProvider); final launchAtLogin = ref.watch(launchAtLoginEnabledProvider); @@ -137,6 +142,16 @@ class _SettingsScreenState extends ConsumerState { ), onResolveConflict: (conflict, resolution) => unawaited(_resolveConflict(conflict, resolution)), + subscriptions: subscriptions, + busySubscriptionIds: _busySubscriptionIds, + onAddSubscription: () => unawaited(_addSubscription()), + onRefreshSubscription: (subscription) => + unawaited(_refreshSubscription(subscription)), + onRenameSubscription: (subscription) => + unawaited(_renameSubscription(subscription)), + onChangeSubscriptionColor: (subscription) => + unawaited(_changeSubscriptionColor(subscription)), + onUnsubscribe: (subscription) => unawaited(_unsubscribe(subscription)), ), SettingsPage.schedule => BusyMaxGroupedList( title: l10n.scheduleDisplaySettings, @@ -378,6 +393,102 @@ class _SettingsScreenState extends ConsumerState { ); } + Future _addSubscription({String? initialUrl}) async { + await showAddCalendarSubscriptionFlow(context, ref, initialUrl: initialUrl); + } + + Future _refreshSubscription( + WebCalSubscriptionEntity subscription, + ) async { + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .refreshSubscription(subscription.id, force: true); + }); + } + + Future _renameSubscription( + WebCalSubscriptionEntity subscription, + ) async { + final value = await showBusyMaxTextPrompt( + context, + title: context.l10n.rename, + label: context.l10n.subscriptionName, + actionLabel: context.l10n.rename, + initialValue: subscription.name, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (value == null || !mounted) return; + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .renameSubscription(subscription.id, value); + }); + } + + Future _changeSubscriptionColor( + WebCalSubscriptionEntity subscription, + ) async { + final value = await showBusyMaxTextPrompt( + context, + title: context.l10n.calendarColor, + label: context.l10n.subscriptionColor, + actionLabel: context.l10n.save, + initialValue: subscription.color, + message: context.l10n.subscriptionColorHelp, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (value == null || !mounted) return; + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .changeSubscriptionColor(subscription.id, value); + }); + } + + Future _unsubscribe(WebCalSubscriptionEntity subscription) async { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.unsubscribeCalendarTitle(subscription.name), + message: context.l10n.unsubscribeCalendarConfirmation, + confirmLabel: context.l10n.unsubscribe, + destructive: true, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!confirmed || !mounted) return; + await _runSubscriptionOperation(subscription.id, () async { + await ref + .read(webCalSubscriptionServiceProvider) + .unsubscribe(subscription.id); + }); + } + + Future _runSubscriptionOperation( + String subscriptionId, + Future Function() operation, + ) async { + if (_busySubscriptionIds.contains(subscriptionId)) return; + setState(() => _busySubscriptionIds.add(subscriptionId)); + try { + await operation(); + } on Object catch (error) { + if (mounted) _showSubscriptionError(error); + } finally { + if (mounted) setState(() => _busySubscriptionIds.remove(subscriptionId)); + } + } + + void _showSubscriptionError(Object error) { + final code = switch (error) { + WebCalSubscriptionException(:final code) => code, + WebCalUriException(:final code) => code, + _ => 'WebCalOperationFailed', + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.subscriptionOperationFailed(code))), + ); + } + Future _setLaunchAtLogin(BuildContext context, bool enabled) async { try { await ref.read(linuxAutostartServiceProvider).setEnabled(enabled); @@ -597,6 +708,10 @@ class _SettingsScreenState extends ConsumerState { enteredServer: nextcloudServer!, cancellationToken: cancellation, )).accountId; + case BusyProvider.webCal: + throw StateError( + 'WebCal subscriptions use the calendar subscription dialog.', + ); } if (accountId != null) { unawaited(_syncConnectedAccount(runSync, accountId)); @@ -1039,6 +1154,13 @@ class _AccountManagementSection extends StatelessWidget { required this.onEventsSelected, required this.onTasksSelected, required this.onResolveConflict, + required this.subscriptions, + required this.busySubscriptionIds, + required this.onAddSubscription, + required this.onRefreshSubscription, + required this.onRenameSubscription, + required this.onChangeSubscriptionColor, + required this.onUnsubscribe, }); final List accounts; @@ -1066,6 +1188,16 @@ class _AccountManagementSection extends StatelessWidget { DavConflictResolution resolution, ) onResolveConflict; + final List subscriptions; + final Set busySubscriptionIds; + final VoidCallback onAddSubscription; + final void Function(WebCalSubscriptionEntity subscription) + onRefreshSubscription; + final void Function(WebCalSubscriptionEntity subscription) + onRenameSubscription; + final void Function(WebCalSubscriptionEntity subscription) + onChangeSubscriptionColor; + final void Function(WebCalSubscriptionEntity subscription) onUnsubscribe; @override Widget build(BuildContext context) { @@ -1163,11 +1295,320 @@ class _AccountManagementSection extends StatelessWidget { onResolve: onResolveConflict, ), ], + _CalendarSubscriptionsCard( + subscriptions: subscriptions, + busySubscriptionIds: busySubscriptionIds, + onAdd: onAddSubscription, + onRefresh: onRefreshSubscription, + onRename: onRenameSubscription, + onChangeColor: onChangeSubscriptionColor, + onUnsubscribe: onUnsubscribe, + ), ], ); } } +class _CalendarSubscriptionsCard extends StatelessWidget { + const _CalendarSubscriptionsCard({ + required this.subscriptions, + required this.busySubscriptionIds, + required this.onAdd, + required this.onRefresh, + required this.onRename, + required this.onChangeColor, + required this.onUnsubscribe, + }); + + final List subscriptions; + final Set busySubscriptionIds; + final VoidCallback onAdd; + final void Function(WebCalSubscriptionEntity) onRefresh; + final void Function(WebCalSubscriptionEntity) onRename; + final void Function(WebCalSubscriptionEntity) onChangeColor; + final void Function(WebCalSubscriptionEntity) onUnsubscribe; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxGroupedList( + title: l10n.calendarSubscriptions, + description: l10n.calendarSubscriptionsDescription, + filled: true, + children: [ + BusyMaxActionRow( + key: const ValueKey('add-calendar-subscription'), + title: l10n.addCalendarSubscription, + leading: const Icon(YaruIcons.plus), + onTap: onAdd, + ), + ], + ), + for (final subscription in subscriptions) + BusyMaxGroupedList( + key: ValueKey('calendar-subscription-${subscription.id}'), + title: subscription.name, + description: subscription.safeOrigin, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.subscriptionReadOnly, + subtitle: _subscriptionTiming(context, subscription), + leading: _DavCollectionIndicator(color: subscription.color), + ), + BusyMaxActionRow( + title: subscription.lastFailureCode == null + ? l10n.subscriptionStatusHealthy + : l10n.subscriptionStatusIssue( + subscription.lastFailureCode!, + ), + subtitle: l10n.subscriptionNextRefresh( + _formatDavDateTime(context, subscription.nextRefreshAtUtc), + ), + leading: Icon( + subscription.lastFailureCode == null + ? YaruIcons.checkmark + : YaruIcons.warning, + ), + ), + BusyMaxActionRow( + title: l10n.refreshNow, + leading: const Icon(YaruIcons.sync), + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onRefresh(subscription), + ), + BusyMaxActionRow( + title: l10n.rename, + leading: const Icon(Icons.edit_outlined), + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onRename(subscription), + ), + BusyMaxActionRow( + title: l10n.calendarColor, + leading: const Icon(Icons.palette_outlined), + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onChangeColor(subscription), + ), + BusyMaxActionRow( + title: l10n.unsubscribe, + leading: Icon( + YaruIcons.trash, + color: Theme.of(context).colorScheme.error, + ), + destructive: true, + onTap: busySubscriptionIds.contains(subscription.id) + ? null + : () => onUnsubscribe(subscription), + ), + ], + ), + ], + ); + } +} + +String _subscriptionTiming( + BuildContext context, + WebCalSubscriptionEntity subscription, +) { + final last = subscription.lastSuccessfulSyncAtUtc; + return last == null + ? context.l10n.subscriptionNeverRefreshed + : context.l10n.subscriptionLastRefresh(_formatDavDateTime(context, last)); +} + +final class _WebCalAddInput { + const _WebCalAddInput({ + required this.url, + required this.name, + required this.color, + required this.refreshMode, + }); + + final String url; + final String? name; + final String? color; + final WebCalRefreshMode refreshMode; +} + +class _WebCalAddDialog extends StatefulWidget { + const _WebCalAddDialog({this.initialUrl}); + + final String? initialUrl; + + @override + State<_WebCalAddDialog> createState() => _WebCalAddDialogState(); +} + +class _WebCalAddDialogState extends State<_WebCalAddDialog> { + late final TextEditingController _urlController; + late final TextEditingController _nameController; + late final TextEditingController _colorController; + var _refreshMode = WebCalRefreshMode.automatic; + + @override + void initState() { + super.initState(); + _urlController = TextEditingController(text: widget.initialUrl) + ..addListener(_changed); + _nameController = TextEditingController(); + _colorController = TextEditingController()..addListener(_changed); + } + + @override + void dispose() { + _urlController + ..removeListener(_changed) + ..dispose(); + _nameController.dispose(); + _colorController + ..removeListener(_changed) + ..dispose(); + super.dispose(); + } + + void _changed() => setState(() {}); + + NormalizedWebCalUri? get _normalized { + try { + return normalizeWebCalUri(_urlController.text); + } on FormatException { + return null; + } + } + + bool get _colorValid { + final value = _colorController.text.trim(); + return value.isEmpty || RegExp(r'^#[0-9A-Fa-f]{6}$').hasMatch(value); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final normalized = _normalized; + return BusyMaxDialogShell( + title: l10n.addCalendarSubscription, + maxWidth: BusyMaxSizes.compactDetailsWidth, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('confirm-add-subscription'), + onPressed: normalized == null || !_colorValid + ? null + : () => Navigator.of(context).pop( + _WebCalAddInput( + url: _urlController.text, + name: _nameController.text.trim().isEmpty + ? null + : _nameController.text.trim(), + color: _colorController.text.trim().isEmpty + ? null + : _colorController.text.trim(), + refreshMode: _refreshMode, + ), + ), + child: Text(l10n.addSubscriptionAction), + ), + ], + children: [ + TextField( + key: const ValueKey('subscription-url'), + controller: _urlController, + autofocus: true, + decoration: InputDecoration( + labelText: l10n.subscriptionUrl, + helperText: l10n.subscriptionUrlHelp, + errorText: + _urlController.text.trim().isNotEmpty && normalized == null + ? l10n.subscriptionUrlInvalid + : null, + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + TextField( + controller: _nameController, + decoration: InputDecoration(labelText: l10n.subscriptionName), + ), + const SizedBox(height: BusyMaxSpacing.md), + TextField( + controller: _colorController, + decoration: InputDecoration( + labelText: l10n.subscriptionColor, + helperText: l10n.subscriptionColorHelp, + errorText: _colorValid ? null : l10n.subscriptionColorInvalid, + ), + ), + const SizedBox(height: BusyMaxSpacing.md), + BusyMaxComboRow( + title: l10n.subscriptionRefreshMode, + values: WebCalRefreshMode.values, + selected: _refreshMode, + labelFor: (mode) => _refreshModeLabel(context, mode), + onSelected: (value) => setState(() => _refreshMode = value), + ), + const SizedBox(height: BusyMaxSpacing.md), + Text( + normalized == null + ? l10n.subscriptionSafeOriginUnavailable + : l10n.subscriptionSafeOrigin(normalized.safeOrigin.toString()), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } +} + +String _refreshModeLabel(BuildContext context, WebCalRefreshMode mode) => + switch (mode) { + WebCalRefreshMode.automatic => context.l10n.subscriptionAutomatic, + WebCalRefreshMode.hourly => context.l10n.subscriptionHourly, + WebCalRefreshMode.sixHours => context.l10n.subscriptionSixHours, + WebCalRefreshMode.daily => context.l10n.subscriptionDaily, + }; + +Future showAddCalendarSubscriptionFlow( + BuildContext context, + WidgetRef ref, { + String? initialUrl, +}) async { + final input = await showBusyMaxModalDialog<_WebCalAddInput>( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + barrierDismissible: false, + builder: (dialogContext) => _WebCalAddDialog(initialUrl: initialUrl), + ); + if (input == null || !context.mounted) return; + try { + await ref + .read(webCalSubscriptionServiceProvider) + .addSubscription( + subscriptionUrl: input.url, + localName: input.name, + color: input.color, + refreshMode: input.refreshMode, + ); + } on Object catch (error) { + if (!context.mounted) return; + final code = switch (error) { + WebCalSubscriptionException(:final code) => code, + WebCalUriException(:final code) => code, + _ => 'WebCalOperationFailed', + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.subscriptionOperationFailed(code))), + ); + } +} + class _AccountManagementCard extends StatelessWidget { const _AccountManagementCard({ required this.account, @@ -1572,6 +2013,7 @@ String _accountProviderLabel(BuildContext context, BusyProvider provider) { BusyProvider.microsoft => context.l10n.microsoftProvider, BusyProvider.appleICloud => context.l10n.appleICloudProvider, BusyProvider.nextcloud => context.l10n.nextcloudProvider, + BusyProvider.webCal => 'WebCal', }; } diff --git a/lib/src/features/sync/account_sync_operations.dart b/lib/src/features/sync/account_sync_operations.dart index c644e028..3b4eb6dc 100644 --- a/lib/src/features/sync/account_sync_operations.dart +++ b/lib/src/features/sync/account_sync_operations.dart @@ -1,6 +1,8 @@ +import '../../providers/busy_provider.dart'; + typedef AccountSyncAction = Future Function(String accountId, {required bool full}); -typedef AccountUsesDav = Future Function(String accountId); +typedef AccountProvider = Future Function(String accountId); abstract interface class AccountSyncOperations { Future syncAccount(String accountId, {required bool full}); @@ -82,45 +84,63 @@ final class DisabledAccountSyncOperations implements AccountSyncOperations { final class RoutingAccountSyncOperations implements AccountSyncOperations { const RoutingAccountSyncOperations({ - required AccountUsesDav usesDav, + required AccountProvider providerForAccount, required AccountSyncAction syncDav, + required AccountSyncAction syncWebCal, required AccountSyncAction syncTasksRest, required AccountSyncAction syncCalendarRest, - }) : _usesDav = usesDav, + }) : _providerForAccount = providerForAccount, _syncDav = syncDav, + _syncWebCal = syncWebCal, _syncTasksRest = syncTasksRest, _syncCalendarRest = syncCalendarRest; - final AccountUsesDav _usesDav; + final AccountProvider _providerForAccount; final AccountSyncAction _syncDav; + final AccountSyncAction _syncWebCal; final AccountSyncAction _syncTasksRest; final AccountSyncAction _syncCalendarRest; @override Future syncAccount(String accountId, {required bool full}) async { - if (await _usesDav(accountId)) { - await _syncDav(accountId, full: full); - return; + switch (await _providerForAccount(accountId)) { + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + await _syncDav(accountId, full: full); + case BusyProvider.webCal: + await _syncWebCal(accountId, full: full); + case BusyProvider.google: + case BusyProvider.microsoft: + await _syncTasksRest(accountId, full: full); + await _syncCalendarRest(accountId, full: full); } - await _syncTasksRest(accountId, full: full); - await _syncCalendarRest(accountId, full: full); } @override Future syncCalendar(String accountId, {required bool full}) async { - if (await _usesDav(accountId)) { - await _syncDav(accountId, full: full); - return; + switch (await _providerForAccount(accountId)) { + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + await _syncDav(accountId, full: full); + case BusyProvider.webCal: + await _syncWebCal(accountId, full: full); + case BusyProvider.google: + case BusyProvider.microsoft: + await _syncCalendarRest(accountId, full: full); } - await _syncCalendarRest(accountId, full: full); } @override Future syncTasks(String accountId, {required bool full}) async { - if (await _usesDav(accountId)) { - await _syncDav(accountId, full: full); - return; + switch (await _providerForAccount(accountId)) { + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + await _syncDav(accountId, full: full); + case BusyProvider.webCal: + return; + case BusyProvider.google: + case BusyProvider.microsoft: + await _syncTasksRest(accountId, full: full); } - await _syncTasksRest(accountId, full: full); } } diff --git a/lib/src/features/sync/all_accounts_sync_scheduler.dart b/lib/src/features/sync/all_accounts_sync_scheduler.dart index 271978cc..5082043f 100644 --- a/lib/src/features/sync/all_accounts_sync_scheduler.dart +++ b/lib/src/features/sync/all_accounts_sync_scheduler.dart @@ -4,31 +4,38 @@ import '../accounts/data/accounts_repository.dart'; class AllAccountsSyncScheduler { AllAccountsSyncScheduler({ - required Future> Function() listSignedInAccounts, + required Future> Function() listSyncEligibleAccounts, required Future Function(String accountId) syncAccount, required Future Function(Object error) onSyncFailure, Future Function(String accountId, Object error)? onAccountSyncFailure, Future Function()? canSync, Duration interval = const Duration(minutes: 15), - }) : _listSignedInAccounts = listSignedInAccounts, + }) : _listSyncEligibleAccounts = listSyncEligibleAccounts, _syncAccount = syncAccount, _onSyncFailure = onSyncFailure, _onAccountSyncFailure = onAccountSyncFailure, _canSync = canSync ?? _allowSync, _interval = interval; - final Future> Function() _listSignedInAccounts; + final Future> Function() _listSyncEligibleAccounts; final Future Function(String accountId) _syncAccount; final Future Function(Object error) _onSyncFailure; final Future Function(String accountId, Object error)? _onAccountSyncFailure; final Future Function() _canSync; final Duration _interval; + final _runningChanges = StreamController.broadcast(sync: true); Timer? _timer; bool _running = false; bool _runAgain = false; Duration get interval => _interval; + bool get isRunning => _running; + + Stream watchRunning() async* { + yield _running; + yield* _runningChanges.stream; + } void start() { stop(); @@ -46,20 +53,26 @@ class AllAccountsSyncScheduler { _runAgain = false; } + Future dispose() async { + stop(); + await _runningChanges.close(); + } + Future runNow() async { if (_running) { _runAgain = true; return; } _running = true; + if (!_runningChanges.isClosed) _runningChanges.add(true); try { do { _runAgain = false; if (!await _canSync()) { return; } - await runAllSignedInAccountSync( - listSignedInAccounts: _listSignedInAccounts, + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: _listSyncEligibleAccounts, syncAccount: _syncAccount, onSyncFailure: _onSyncFailure, onAccountSyncFailure: _onAccountSyncFailure, @@ -67,19 +80,20 @@ class AllAccountsSyncScheduler { } while (_runAgain); } finally { _running = false; + if (!_runningChanges.isClosed) _runningChanges.add(false); } } } Future _allowSync() async => true; -Future runAllSignedInAccountSync({ - required Future> Function() listSignedInAccounts, +Future runAllSyncEligibleAccountSync({ + required Future> Function() listSyncEligibleAccounts, required Future Function(String accountId) syncAccount, required Future Function(Object error) onSyncFailure, Future Function(String accountId, Object error)? onAccountSyncFailure, }) async { - final accounts = await listSignedInAccounts(); + final accounts = await listSyncEligibleAccounts(); for (final account in accounts) { try { await syncAccount(account.id); diff --git a/lib/src/features/tasks/domain/task_capabilities.dart b/lib/src/features/tasks/domain/task_capabilities.dart index a1411054..5272c63f 100644 --- a/lib/src/features/tasks/domain/task_capabilities.dart +++ b/lib/src/features/tasks/domain/task_capabilities.dart @@ -240,4 +240,5 @@ TaskCollectionCapabilities adapterDefaultTaskCapabilities( BusyProvider.microsoft => microsoftTaskCollectionCapabilities, BusyProvider.appleICloud => noTaskCollectionCapabilities, BusyProvider.nextcloud => nextcloudTaskCollectionCapabilities, + BusyProvider.webCal => noTaskCollectionCapabilities, }; diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 267d2115..437eaf1d 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -177,13 +177,16 @@ class _TaskDetailsPaneState extends ConsumerState { )), ) .valueOrNull, - BusyProvider.google || BusyProvider.microsoft => null, + BusyProvider.google || + BusyProvider.microsoft || + BusyProvider.webCal => null, }; final capabilities = switch (account.provider) { BusyProvider.appleICloud || BusyProvider.nextcloud => davCapabilities ?? noTaskCollectionCapabilities, BusyProvider.google || BusyProvider.microsoft => adapterDefaultTaskCapabilities(account.provider), + BusyProvider.webCal => noTaskCollectionCapabilities, }; return StreamBuilder( @@ -776,6 +779,7 @@ String _providerEditorLabel(BuildContext context, BusyProvider provider) { BusyProvider.microsoft => l10n.microsoftTodoProvider, BusyProvider.appleICloud => 'Apple iCloud', BusyProvider.nextcloud => 'Nextcloud Tasks', + BusyProvider.webCal => 'WebCal', }; } diff --git a/lib/src/features/tray/data/tray_presentation_service.dart b/lib/src/features/tray/data/tray_presentation_service.dart new file mode 100644 index 00000000..83c32dda --- /dev/null +++ b/lib/src/features/tray/data/tray_presentation_service.dart @@ -0,0 +1,203 @@ +import '../../../app/app_settings.dart'; +import '../../../schedule/schedule_filters.dart'; +import '../../../schedule/schedule_item.dart'; +import '../../../schedule/schedule_range.dart'; +import '../../../schedule/schedule_repository.dart'; +import '../../../schedule/schedule_source_visibility.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../calendar/data/calendar_repository.dart'; +import '../../connectivity/network_connectivity_service.dart'; +import '../../task_lists/data/task_lists_repository.dart'; +import '../domain/tray_presentation.dart'; + +typedef TrayTaskListsLoader = + Future> Function(AccountEntity account); +typedef TrayTaskListWriteCheck = + Future Function(AccountEntity account, TaskListEntity taskList); + +final class BusyMaxTrayPresentationService { + const BusyMaxTrayPresentationService({ + required AccountsRepository accountsRepository, + required CalendarRepository calendarRepository, + required ScheduleRepository scheduleRepository, + required TrayTaskListsLoader listTaskLists, + required TrayTaskListWriteCheck canCreateTask, + required AppSettings Function() settings, + required NetworkAvailability Function() connectivity, + required bool Function() synchronizationRunning, + DateTime Function()? now, + }) : _accountsRepository = accountsRepository, + _calendarRepository = calendarRepository, + _scheduleRepository = scheduleRepository, + _listTaskLists = listTaskLists, + _canCreateTask = canCreateTask, + _settings = settings, + _connectivity = connectivity, + _synchronizationRunning = synchronizationRunning, + _now = now ?? DateTime.now; + + final AccountsRepository _accountsRepository; + final CalendarRepository _calendarRepository; + final ScheduleRepository _scheduleRepository; + final TrayTaskListsLoader _listTaskLists; + final TrayTaskListWriteCheck _canCreateTask; + final AppSettings Function() _settings; + final NetworkAvailability Function() _connectivity; + final bool Function() _synchronizationRunning; + final DateTime Function() _now; + + Future load() async { + final now = _now(); + final accounts = await _accountsRepository.watchAccounts().first; + final accountIds = accounts.map((account) => account.id).toList(); + final sources = await _calendarRepository + .watchSourcesForAccounts(accountIds) + .first; + final taskLists = []; + for (final account in accounts.where((account) => account.isTaskCapable)) { + taskLists.addAll(await _listTaskLists(account)); + } + final settings = _settings(); + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: sources, + taskLists: taskLists, + settings: settings, + ); + final items = await _scheduleRepository.listItems( + range: ScheduleRange.day(now), + filters: ScheduleFilters( + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibility.visibleTaskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + showCompletedTasks: false, + showNoDateTasks: false, + ), + ); + final writableTaskLists = {}; + final accountsById = {for (final account in accounts) account.id: account}; + for (final taskList in taskLists) { + final key = ScheduleTaskListKey( + accountId: taskList.accountId, + taskListId: taskList.id, + ); + if (!visibility.visibleTaskListKeys.contains(key)) continue; + final account = accountsById[taskList.accountId]; + if (account != null && await _canCreateTask(account, taskList)) { + writableTaskLists.add(key); + } + } + return projectBusyMaxTrayPresentation( + accounts: accounts, + calendarSources: sources, + taskLists: taskLists, + scheduleItems: items, + writableTaskListKeys: writableTaskLists, + settings: settings, + connectivity: _connectivity(), + synchronizationRunning: _synchronizationRunning(), + now: now, + ); + } +} + +BusyMaxTrayPresentation projectBusyMaxTrayPresentation({ + required List accounts, + required List calendarSources, + required List taskLists, + required List scheduleItems, + required Set writableTaskListKeys, + required AppSettings settings, + required NetworkAvailability connectivity, + required bool synchronizationRunning, + required DateTime now, +}) { + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: calendarSources, + taskLists: taskLists, + settings: settings, + ); + final eventEntries = []; + var taskCount = 0; + for (final item in scheduleItems) { + if (item is CalendarScheduleItem) { + if (eventEntries.length >= 3 || + !visibility.visibleCalendarSourceIds.contains(item.sourceId)) { + continue; + } + final start = item.start; + if (start == null || !_trayEventIsRelevant(item, now)) continue; + eventEntries.add( + BusyMaxTrayEventEntry( + eventId: item.id, + accountId: item.accountId, + calendarSourceId: item.sourceId, + title: item.title, + start: start, + end: item.end, + allDay: item.allDay, + ), + ); + continue; + } + if (item is TaskScheduleItem && + !item.completed && + item.start != null && + visibility.visibleTaskListKeys.contains( + ScheduleTaskListKey( + accountId: item.accountId, + taskListId: item.sourceId, + ), + )) { + taskCount += 1; + } + } + + final visibleWritableTaskList = writableTaskListKeys.any( + visibility.visibleTaskListKeys.contains, + ); + final syncEligible = accounts.where((account) => account.isSyncEligible); + DateTime? oldestSuccessfulSync; + var allAccountsHaveSynchronized = true; + var hasSyncEligibleAccount = false; + for (final account in syncEligible) { + hasSyncEligibleAccount = true; + final synchronizedAt = account.lastSuccessfulSyncAtUtc; + if (synchronizedAt == null) { + allAccountsHaveSynchronized = false; + continue; + } + if (oldestSuccessfulSync == null || + synchronizedAt.isBefore(oldestSuccessfulSync)) { + oldestSuccessfulSync = synchronizedAt; + } + } + return BusyMaxTrayPresentation( + connectivity: connectivity, + synchronizationRunning: synchronizationRunning, + lastSuccessfulSynchronizationUtc: allAccountsHaveSynchronized + ? oldestSuccessfulSync + : null, + events: List.unmodifiable(eventEntries), + incompleteTasksDueToday: taskCount, + canCreateEvent: calendarSources.any( + (source) => + visibility.visibleCalendarSourceIds.contains(source.id) && + source.capabilities.canCreateEvents, + ), + canCreateTask: visibleWritableTaskList, + hasSyncEligibleAccount: hasSyncEligibleAccount, + notificationDetailLevel: settings.notificationDetailLevel, + localNow: now, + ); +} + +bool _trayEventIsRelevant(CalendarScheduleItem event, DateTime now) { + if (event.allDay) return true; + final start = event.start; + if (start == null) return false; + final end = event.end; + if (end == null) return !start.isBefore(now); + return end.isAfter(now); +} diff --git a/lib/src/features/tray/domain/tray_presentation.dart b/lib/src/features/tray/domain/tray_presentation.dart new file mode 100644 index 00000000..a8dc369b --- /dev/null +++ b/lib/src/features/tray/domain/tray_presentation.dart @@ -0,0 +1,214 @@ +import 'package:collection/collection.dart'; + +import '../../../app/app_settings.dart'; +import '../../connectivity/network_connectivity_service.dart'; + +final class BusyMaxTrayEventEntry { + const BusyMaxTrayEventEntry({ + required this.eventId, + required this.accountId, + required this.calendarSourceId, + required this.title, + required this.start, + required this.end, + required this.allDay, + }); + + final String eventId; + final String accountId; + final String calendarSourceId; + final String title; + final DateTime start; + final DateTime? end; + final bool allDay; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayEventEntry && + other.eventId == eventId && + other.accountId == accountId && + other.calendarSourceId == calendarSourceId && + other.title == title && + other.start == start && + other.end == end && + other.allDay == allDay; + + @override + int get hashCode => Object.hash( + eventId, + accountId, + calendarSourceId, + title, + start, + end, + allDay, + ); +} + +final class BusyMaxTrayPresentation { + const BusyMaxTrayPresentation({ + required this.connectivity, + required this.synchronizationRunning, + required this.lastSuccessfulSynchronizationUtc, + required this.events, + required this.incompleteTasksDueToday, + required this.canCreateEvent, + required this.canCreateTask, + required this.hasSyncEligibleAccount, + required this.notificationDetailLevel, + required this.localNow, + }); + + final NetworkAvailability connectivity; + final bool synchronizationRunning; + final DateTime? lastSuccessfulSynchronizationUtc; + final List events; + final int incompleteTasksDueToday; + final bool canCreateEvent; + final bool canCreateTask; + final bool hasSyncEligibleAccount; + final NotificationDetailLevel notificationDetailLevel; + final DateTime localNow; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayPresentation && + other.connectivity == connectivity && + other.synchronizationRunning == synchronizationRunning && + other.lastSuccessfulSynchronizationUtc == + lastSuccessfulSynchronizationUtc && + const ListEquality().equals( + other.events, + events, + ) && + other.incompleteTasksDueToday == incompleteTasksDueToday && + other.canCreateEvent == canCreateEvent && + other.canCreateTask == canCreateTask && + other.hasSyncEligibleAccount == hasSyncEligibleAccount && + other.notificationDetailLevel == notificationDetailLevel && + other.localNow == localNow; + + @override + int get hashCode => Object.hash( + connectivity, + synchronizationRunning, + lastSuccessfulSynchronizationUtc, + const ListEquality().hash(events), + incompleteTasksDueToday, + canCreateEvent, + canCreateTask, + hasSyncEligibleAccount, + notificationDetailLevel, + localNow, + ); +} + +final class BusyMaxTrayEventRowPresentation { + const BusyMaxTrayEventRowPresentation({ + required this.event, + required this.label, + }); + + final BusyMaxTrayEventEntry event; + final String label; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayEventRowPresentation && + other.event == event && + other.label == label; + + @override + int get hashCode => Object.hash(event, label); +} + +final class BusyMaxTrayMenuPresentation { + const BusyMaxTrayMenuPresentation({ + required this.showBusyMaxLabel, + required this.newEventLabel, + required this.newTaskLabel, + required this.todayLabel, + required this.eventRows, + required this.taskSummaryLabel, + required this.emptyStateLabel, + required this.openTodayAgendaLabel, + required this.syncNowLabel, + required this.synchronizationStatusLabel, + required this.settingsLabel, + required this.quitBusyMaxLabel, + required this.offlineTitle, + required this.offlineDescription, + required this.canCreateEvent, + required this.canCreateTask, + required this.canSynchronize, + required this.offline, + }); + + final String showBusyMaxLabel; + final String newEventLabel; + final String newTaskLabel; + final String todayLabel; + final List eventRows; + final String? taskSummaryLabel; + final String emptyStateLabel; + final String openTodayAgendaLabel; + final String syncNowLabel; + final String synchronizationStatusLabel; + final String settingsLabel; + final String quitBusyMaxLabel; + final String offlineTitle; + final String offlineDescription; + final bool canCreateEvent; + final bool canCreateTask; + final bool canSynchronize; + final bool offline; + + bool get showEmptyState => eventRows.isEmpty && taskSummaryLabel == null; + + @override + bool operator ==(Object other) => + other is BusyMaxTrayMenuPresentation && + other.showBusyMaxLabel == showBusyMaxLabel && + other.newEventLabel == newEventLabel && + other.newTaskLabel == newTaskLabel && + other.todayLabel == todayLabel && + const ListEquality().equals( + other.eventRows, + eventRows, + ) && + other.taskSummaryLabel == taskSummaryLabel && + other.emptyStateLabel == emptyStateLabel && + other.openTodayAgendaLabel == openTodayAgendaLabel && + other.syncNowLabel == syncNowLabel && + other.synchronizationStatusLabel == synchronizationStatusLabel && + other.settingsLabel == settingsLabel && + other.quitBusyMaxLabel == quitBusyMaxLabel && + other.offlineTitle == offlineTitle && + other.offlineDescription == offlineDescription && + other.canCreateEvent == canCreateEvent && + other.canCreateTask == canCreateTask && + other.canSynchronize == canSynchronize && + other.offline == offline; + + @override + int get hashCode => Object.hashAll([ + showBusyMaxLabel, + newEventLabel, + newTaskLabel, + todayLabel, + const ListEquality().hash(eventRows), + taskSummaryLabel, + emptyStateLabel, + openTodayAgendaLabel, + syncNowLabel, + synchronizationStatusLabel, + settingsLabel, + quitBusyMaxLabel, + offlineTitle, + offlineDescription, + canCreateEvent, + canCreateTask, + canSynchronize, + offline, + ]); +} diff --git a/lib/src/features/tray/domain/tray_presentation_formatter.dart b/lib/src/features/tray/domain/tray_presentation_formatter.dart new file mode 100644 index 00000000..757a8742 --- /dev/null +++ b/lib/src/features/tray/domain/tray_presentation_formatter.dart @@ -0,0 +1,156 @@ +import '../../../app/app_settings.dart'; +import '../../connectivity/network_connectivity_service.dart'; +import 'tray_presentation.dart'; + +final class BusyMaxTrayPresentationStrings { + const BusyMaxTrayPresentationStrings({ + required this.showBusyMax, + required this.newEvent, + required this.newTask, + required this.today, + required this.allDay, + required this.now, + required this.calendarEvent, + required this.untitledEvent, + required this.nothingElseToday, + required this.openTodayAgenda, + required this.syncNow, + required this.syncing, + required this.notConnected, + required this.notYetSynced, + required this.settings, + required this.quitBusyMax, + required this.offline, + required this.offlineDescription, + required this.formatTime, + required this.tasksDueToday, + required this.lastSyncedJustNow, + required this.lastSyncedMinutesAgo, + required this.lastSyncedHoursAgo, + required this.lastSyncedDaysAgo, + }); + + final String showBusyMax; + final String newEvent; + final String newTask; + final String today; + final String allDay; + final String now; + final String calendarEvent; + final String untitledEvent; + final String nothingElseToday; + final String openTodayAgenda; + final String syncNow; + final String syncing; + final String notConnected; + final String notYetSynced; + final String settings; + final String quitBusyMax; + final String offline; + final String offlineDescription; + final String Function(DateTime value) formatTime; + final String Function(int count) tasksDueToday; + final String lastSyncedJustNow; + final String Function(int count) lastSyncedMinutesAgo; + final String Function(int count) lastSyncedHoursAgo; + final String Function(int count) lastSyncedDaysAgo; +} + +final class BusyMaxTrayPresentationFormatter { + const BusyMaxTrayPresentationFormatter(this.strings); + + final BusyMaxTrayPresentationStrings strings; + + BusyMaxTrayMenuPresentation format(BusyMaxTrayPresentation presentation) { + final eventRows = [ + for (final event in presentation.events) + BusyMaxTrayEventRowPresentation( + event: event, + label: _eventLabel(event, presentation), + ), + ]; + final offline = presentation.connectivity == NetworkAvailability.offline; + return BusyMaxTrayMenuPresentation( + showBusyMaxLabel: strings.showBusyMax, + newEventLabel: strings.newEvent, + newTaskLabel: strings.newTask, + todayLabel: strings.today, + eventRows: List.unmodifiable(eventRows), + taskSummaryLabel: presentation.incompleteTasksDueToday == 0 + ? null + : strings.tasksDueToday(presentation.incompleteTasksDueToday), + emptyStateLabel: strings.nothingElseToday, + openTodayAgendaLabel: strings.openTodayAgenda, + syncNowLabel: strings.syncNow, + synchronizationStatusLabel: _synchronizationStatus(presentation), + settingsLabel: strings.settings, + quitBusyMaxLabel: strings.quitBusyMax, + offlineTitle: strings.offline, + offlineDescription: strings.offlineDescription, + canCreateEvent: presentation.canCreateEvent, + canCreateTask: presentation.canCreateTask, + canSynchronize: + presentation.connectivity == NetworkAvailability.online && + presentation.hasSyncEligibleAccount && + !presentation.synchronizationRunning, + offline: offline, + ); + } + + String _eventLabel( + BusyMaxTrayEventEntry event, + BusyMaxTrayPresentation presentation, + ) { + final prefix = event.allDay + ? strings.allDay + : _isInProgress(event, presentation.localNow) + ? strings.now + : strings.formatTime(event.start); + final title = + presentation.notificationDetailLevel == NotificationDetailLevel.private + ? strings.calendarEvent + : _sanitizeTitle(event.title); + return _truncate('$prefix · $title', 100); + } + + String _sanitizeTitle(String source) { + final withoutControls = source.replaceAll( + RegExp(r'[\u0000-\u001f\u007f-\u009f]'), + ' ', + ); + final collapsed = withoutControls.replaceAll(RegExp(r'\s+'), ' ').trim(); + return collapsed.isEmpty ? strings.untitledEvent : collapsed; + } + + String _synchronizationStatus(BusyMaxTrayPresentation presentation) { + if (presentation.connectivity == NetworkAvailability.offline) { + return '${strings.offline} — ${strings.offlineDescription}'; + } + if (presentation.synchronizationRunning) return strings.syncing; + if (!presentation.hasSyncEligibleAccount) return strings.notConnected; + final lastSync = presentation.lastSuccessfulSynchronizationUtc; + if (lastSync == null) return strings.notYetSynced; + final elapsed = presentation.localNow.toUtc().difference(lastSync); + if (elapsed <= Duration.zero || elapsed.inMinutes < 1) { + return strings.lastSyncedJustNow; + } + if (elapsed.inHours < 1) { + return strings.lastSyncedMinutesAgo(elapsed.inMinutes); + } + if (elapsed.inDays < 1) { + return strings.lastSyncedHoursAgo(elapsed.inHours); + } + return strings.lastSyncedDaysAgo(elapsed.inDays); + } +} + +bool _isInProgress(BusyMaxTrayEventEntry event, DateTime now) { + final end = event.end; + return !event.start.isAfter(now) && end != null && end.isAfter(now); +} + +String _truncate(String value, int maximumRunes) { + final runes = value.runes.toList(growable: false); + if (runes.length <= maximumRunes) return value; + return '${String.fromCharCodes(runes.take(maximumRunes - 1))}…'; +} diff --git a/lib/src/ical/ical_event_projection.dart b/lib/src/ical/ical_event_projection.dart new file mode 100644 index 00000000..091793ad --- /dev/null +++ b/lib/src/ical/ical_event_projection.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +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 'ical_ingestion.dart'; + +const sharedIcalEventProjectionVersion = 1; + +final class ProjectedIcalEvent { + const ProjectedIcalEvent({ + required this.uid, + required this.occurrenceKey, + required this.recurrenceIdKey, + required this.title, + required this.description, + required this.location, + required this.allDay, + required this.startDate, + required this.startDateTime, + required this.startTimeZone, + required this.endDate, + required this.endDateTime, + required this.endTimeZone, + required this.recurrenceJson, + required this.remindersJson, + required this.attendeesJson, + required this.categoriesJson, + required this.organizerJson, + required this.visibility, + required this.transparency, + required this.status, + required this.cancelled, + required this.recurring, + required this.webLink, + required this.attachmentsJson, + required this.createdAtServer, + required this.updatedAtServer, + required this.rawJson, + }); + + final String uid; + final String occurrenceKey; + final String? recurrenceIdKey; + final String title; + final String? description; + final String? location; + final bool allDay; + final String? startDate; + final String? startDateTime; + final String? startTimeZone; + final String? endDate; + final String? endDateTime; + final String? endTimeZone; + final String recurrenceJson; + final String remindersJson; + final String attendeesJson; + final String categoriesJson; + final String? organizerJson; + final String? visibility; + final String? transparency; + final String? status; + final bool cancelled; + final bool recurring; + final String? webLink; + final String attachmentsJson; + final String? createdAtServer; + final String? updatedAtServer; + final String rawJson; +} + +final class IcalEventProjector { + IcalEventProjector({IcalRecurrenceExpander? recurrenceExpander}) + : _recurrenceExpander = recurrenceExpander ?? IcalRecurrenceExpander(); + + final IcalRecurrenceExpander _recurrenceExpander; + + List project( + IcalRecurrenceSet set, { + required DateTime rangeStartUtc, + required DateTime rangeEndUtc, + required String transport, + }) { + return projectSemantic( + set.semantic, + rangeStartUtc: rangeStartUtc, + rangeEndUtc: rangeEndUtc, + transport: transport, + ); + } + + List projectSemantic( + IcalSemanticDocument semantic, { + required DateTime rangeStartUtc, + required DateTime rangeEndUtc, + required String transport, + }) { + final occurrences = _recurrenceExpander.expand( + semantic, + rangeStartUtc: rangeStartUtc, + rangeEndUtc: rangeEndUtc, + ); + final resolver = IcalTimeZoneResolver.fromDocument(semantic); + final recurringUids = { + for (final component in semantic.components) + if (component.recurrenceId != null || + component.recurrenceRules.isNotEmpty || + component.recurrenceDates.isNotEmpty || + component.exceptionDates.isNotEmpty) + component.uid, + }; + return [ + for (final occurrence in occurrences) + _projectOccurrence( + occurrence, + resolver, + transport, + recurring: recurringUids.contains(occurrence.master.uid), + ), + ]; + } +} + +ProjectedIcalEvent _projectOccurrence( + IcalOccurrence occurrence, + IcalTimeZoneResolver resolver, + String transport, { + required bool recurring, +}) { + final component = occurrence.effectiveComponent; + final master = occurrence.master; + final identity = occurrence.identityComponent; + final allDay = occurrence.start.kind == IcalTemporalKind.date; + final attendees = component.attendees.isEmpty + ? master.attendees + : component.attendees; + final organizers = component.organizers.isEmpty + ? master.organizers + : component.organizers; + final categories = component.categories.isEmpty + ? master.categories + : component.categories; + final alarms = component.alarms.isEmpty ? master.alarms : component.alarms; + final startUtc = _resolvedUtc(occurrence.start, resolver); + final endUtc = occurrence.end == null + ? null + : _resolvedUtc(occurrence.end!, resolver); + final attachments = _propertyRawValues(component, 'ATTACH'); + final masterAttachments = _propertyRawValues(master, 'ATTACH'); + return ProjectedIcalEvent( + uid: component.uid!, + occurrenceKey: occurrence.occurrenceKey, + recurrenceIdKey: identity.recurrenceIdKey, + title: occurrence.summary ?? '', + description: occurrence.description, + location: occurrence.location, + allDay: allDay, + startDate: allDay ? _storageTemporal(occurrence.start) : null, + startDateTime: allDay ? null : _storageTemporal(occurrence.start), + startTimeZone: _timeZoneName(occurrence.start), + endDate: allDay && occurrence.end != null + ? _storageTemporal(occurrence.end!) + : null, + endDateTime: !allDay && occurrence.end != null + ? _storageTemporal(occurrence.end!) + : null, + endTimeZone: occurrence.end == null ? null : _timeZoneName(occurrence.end!), + recurrenceJson: jsonEncode({ + 'rules': master.recurrenceRules, + 'dates': master.recurrenceDates, + 'excludedDates': master.exceptionDates, + }), + remindersJson: jsonEncode({ + 'minutes': _eventReminderMinutes(alarms), + 'alarms': _alarmProjection(alarms), + }), + attendeesJson: jsonEncode(attendees), + categoriesJson: jsonEncode(categories), + organizerJson: organizers.isEmpty ? null : jsonEncode(organizers.first), + visibility: component.classification ?? master.classification, + transparency: component.transparency ?? master.transparency, + status: component.status ?? master.status, + cancelled: occurrence.isCancelled, + recurring: recurring, + webLink: _propertyRaw(component, 'URL') ?? _propertyRaw(master, 'URL'), + attachmentsJson: jsonEncode( + attachments.isEmpty ? masterAttachments : attachments, + ), + createdAtServer: _storageTemporalNullable( + component.created ?? master.created, + ), + updatedAtServer: _storageTemporalNullable( + component.lastModified ?? master.lastModified, + ), + rawJson: jsonEncode({ + 'transport': transport, + 'uid': component.uid, + 'occurrenceKey': occurrence.occurrenceKey, + 'nativeStart': _temporalJson(occurrence.start), + if (occurrence.end != null) 'nativeEnd': _temporalJson(occurrence.end!), + if (startUtc != null) 'startUtc': startUtc.toIso8601String(), + if (endUtc != null) 'endUtc': endUtc.toIso8601String(), + if (transport == 'caldav') + 'extensionProperties': { + ...master.extensionProperties, + ...component.extensionProperties, + }, + }), + ); +} + +String? _storageTemporalNullable(IcalTemporalValue? value) => + value == null ? null : _storageTemporal(value); + +String? _propertyRaw(IcalSemanticComponent component, String name) => + component.documentComponent.firstProperty(name)?.rawValue; + +List _propertyRawValues(IcalSemanticComponent component, String name) => + component.documentComponent + .propertiesNamed(name) + .map((property) => property.rawValue) + .toList(growable: false); + +String _storageTemporal(IcalTemporalValue value) { + String two(int number) => number.toString().padLeft(2, '0'); + final wall = value.localValue; + final date = + '${wall.year.toString().padLeft(4, '0')}-' + '${two(wall.month)}-${two(wall.day)}'; + if (value.kind == IcalTemporalKind.date) return date; + if (value.kind == IcalTemporalKind.utcDateTime) { + return icalTemporalToUtc(value).toIso8601String(); + } + return '${date}T${two(wall.hour)}:${two(wall.minute)}:${two(wall.second)}'; +} + +String? _timeZoneName(IcalTemporalValue value) => switch (value.kind) { + IcalTemporalKind.utcDateTime => 'UTC', + IcalTemporalKind.tzidDateTime => value.timeZoneId, + IcalTemporalKind.date || IcalTemporalKind.floatingDateTime => null, +}; + +DateTime? _resolvedUtc(IcalTemporalValue value, IcalTimeZoneResolver resolver) { + return switch (value.kind) { + IcalTemporalKind.utcDateTime || IcalTemporalKind.tzidDateTime => + icalTemporalToUtc(value, resolver: resolver), + IcalTemporalKind.date || IcalTemporalKind.floatingDateTime => null, + }; +} + +Map _temporalJson(IcalTemporalValue value) => { + 'raw': value.rawValue, + 'kind': value.kind.name, + if (value.timeZoneId != null) 'timeZoneId': value.timeZoneId, +}; + +List> _alarmProjection(List alarms) => [ + for (final alarm in alarms) + { + 'properties': [ + for (final property in alarm.properties) + { + 'name': property.name, + 'value': property.rawValue, + if (property.parameters.isNotEmpty) + 'parameters': [ + for (final parameter in property.parameters) + {'name': parameter.name, 'values': parameter.values}, + ], + }, + ], + }, +]; + +List _eventReminderMinutes(List alarms) { + final result = []; + for (final alarm in alarms) { + final action = alarm.firstProperty('ACTION')?.rawValue.toUpperCase(); + if (action != 'DISPLAY' && action != 'AUDIO') continue; + final trigger = alarm.firstProperty('TRIGGER'); + if (trigger == null || + trigger.parameterValue('RELATED')?.toUpperCase() == 'END') { + continue; + } + try { + final duration = parseIcalDuration(trigger.rawValue.toUpperCase()); + if (duration == null || !duration.negative) continue; + final before = -duration.duration; + if (before.inSeconds <= 0 || before.inSeconds % 60 != 0) continue; + if (!result.contains(before.inMinutes)) result.add(before.inMinutes); + } on DavException { + // Unsupported trigger forms remain available in the alarm projection. + } + } + return result; +} diff --git a/lib/src/ical/ical_import_service.dart b/lib/src/ical/ical_import_service.dart new file mode 100644 index 00000000..dfcb5aef --- /dev/null +++ b/lib/src/ical/ical_import_service.dart @@ -0,0 +1,445 @@ +import 'package:drift/drift.dart'; + +import '../dav/dav_errors.dart'; +import '../dav/ical/ical_document.dart'; +import '../dav/ical/ical_semantics.dart'; +import '../dav/ical/ical_timezone.dart'; +import '../db/app_database.dart'; +import '../features/calendar/data/calendar_repository.dart'; +import '../features/calendar/presentation/event_editor_draft.dart'; +import '../features/recurrence/domain/event_recurrence_codec.dart'; +import '../providers/busy_provider.dart'; +import 'ical_ingestion.dart'; + +final class IcalImportPreview { + const IcalImportPreview({ + required this.ingestion, + required this.eventCount, + required this.invalidEventCount, + required this.fieldsThatWillBeOmitted, + }); + + final IcalIngestionResult ingestion; + final int eventCount; + final int invalidEventCount; + final Set fieldsThatWillBeOmitted; +} + +final class IcalImportSkippedSet { + const IcalImportSkippedSet({required this.uid, required this.reason}); + + final String uid; + final String reason; +} + +final class IcalImportReport { + const IcalImportReport({ + required this.queued, + required this.duplicatesSkipped, + required this.unsupportedRecurrenceSets, + required this.invalidEvents, + required this.fieldsIntentionallyOmitted, + }); + + final int queued; + final int duplicatesSkipped; + final List unsupportedRecurrenceSets; + final int invalidEvents; + final Set fieldsIntentionallyOmitted; +} + +final class IcalImportService { + IcalImportService({ + required AppDatabase database, + required CalendarRepository calendarRepository, + }) : _database = database, + _calendarRepository = calendarRepository; + + final AppDatabase _database; + final CalendarRepository _calendarRepository; + + IcalImportPreview parsePreview(List bytes) { + final ingestion = IcalIngestion.parseBytes( + bytes, + policy: IcalIngestionPolicy.fileImport, + ); + final omitted = {}; + if (ingestion.document.root.firstProperty('METHOD') != null) { + omitted.add('scheduling method'); + } + for (final set in ingestion.recurrenceSets) { + for (final event in set.semantic.components) { + if (event.attendees.isNotEmpty) omitted.add('attendees'); + if (event.organizers.isNotEmpty) omitted.add('organizer'); + if (event.url != null) omitted.add('URL'); + if (event.documentComponent.propertiesNamed('ATTACH').isNotEmpty) { + omitted.add('attachments'); + } + if (event.documentComponent.propertiesNamed('IMAGE').isNotEmpty) { + omitted.add('images'); + } + if (event.documentComponent.propertiesNamed('SOURCE').isNotEmpty) { + omitted.add('source links'); + } + if (event.documentComponent.propertiesNamed('CONFERENCE').isNotEmpty) { + omitted.add('conference links'); + } + if (event.alarms.any((alarm) => _alarmMinutes([alarm]).isEmpty)) { + omitted.add('unsupported alarms'); + } + } + } + for (final componentType in ingestion.unprojectedComponentTypes) { + omitted.add('$componentType components'); + } + return IcalImportPreview( + ingestion: ingestion, + eventCount: ingestion.recurrenceSets.length, + invalidEventCount: ingestion.rejectedEvents.length, + fieldsThatWillBeOmitted: Set.unmodifiable(omitted), + ); + } + + Future> writableDestinations() async { + final rows = + await (_database.select(_database.calendarSources)..where( + (row) => + row.readOnly.equals(false) & + row.isDeleted.equals(false) & + row.provider.equals(BusyProvider.webCal.storageValue).not(), + )) + .get(); + final pendingIds = { + for (final op + in await (_database.select(_database.pendingOps)..where( + (row) => + row.operationType.equals('calendar.create') & + row.state.isIn(const ['pending', 'retry', 'in_progress']), + )) + .get()) + if (op.calendarSourceId != null) op.calendarSourceId!, + }; + return [ + for (final row in rows) + CalendarSourceEntity.fromRow( + row, + pendingCreate: pendingIds.contains(row.id), + ), + ]; + } + + Future importPreview({ + required IcalImportPreview preview, + required CalendarSourceEntity destination, + }) async { + if (!destination.capabilities.canCreateEvents || + destination.provider == BusyProvider.webCal) { + throw ArgumentError('The selected calendar is not writable.'); + } + final receipts = await (_database.select( + _database.icalImportReceipts, + )..where((row) => row.calendarSourceId.equals(destination.id))).get(); + final importedUids = {for (final receipt in receipts) receipt.icalUid}; + final drafts = <({String icalUid, EventEditorDraft draft})>[]; + final unsupported = []; + var duplicates = 0; + final omitted = {...preview.fieldsThatWillBeOmitted}; + for (final set in preview.ingestion.recurrenceSets) { + if (importedUids.contains(set.uid)) { + duplicates += 1; + continue; + } + final prepared = _prepareDraft(set, destination); + if (prepared.reason != null) { + unsupported.add( + IcalImportSkippedSet(uid: set.uid, reason: prepared.reason!), + ); + continue; + } + omitted.addAll(prepared.omittedFields); + drafts.add((icalUid: set.uid, draft: prepared.draft!)); + } + await _calendarRepository.createImportedEventsBatch( + destination: destination, + events: drafts, + ); + return IcalImportReport( + queued: drafts.length, + duplicatesSkipped: duplicates, + unsupportedRecurrenceSets: List.unmodifiable(unsupported), + invalidEvents: preview.invalidEventCount, + fieldsIntentionallyOmitted: Set.unmodifiable(omitted), + ); + } +} + +_PreparedImportDraft _prepareDraft( + IcalRecurrenceSet set, + CalendarSourceEntity destination, +) { + final semantic = set.semantic; + final masters = semantic.components + .where((component) => component.recurrenceId == null) + .toList(); + if (masters.length != 1) { + return const _PreparedImportDraft.unsupported( + 'The recurrence set does not contain exactly one master event.', + ); + } + if (semantic.components.length != 1) { + return const _PreparedImportDraft.unsupported( + 'Detached recurrence exceptions are not supported by this destination import.', + ); + } + final master = masters.single; + final title = master.summary?.trim(); + if (title == null || title.isEmpty) { + return const _PreparedImportDraft.unsupported( + 'BusyMax requires an event summary.', + ); + } + final startValue = master.start; + if (startValue == null) { + return const _PreparedImportDraft.unsupported( + 'The event does not contain DTSTART.', + ); + } + if (startValue.kind == IcalTemporalKind.floatingDateTime) { + return const _PreparedImportDraft.unsupported( + 'Floating date-times cannot be imported without guessing a timezone.', + ); + } + final resolver = IcalTimeZoneResolver.fromDocument(semantic); + final providerTimeZones = IcalTimeZoneResolver.system(); + try { + if (startValue.kind == IcalTemporalKind.tzidDateTime) { + resolver.toUtc(startValue); + providerTimeZones.toUtc(startValue); + } + if (master.end?.kind == IcalTemporalKind.tzidDateTime) { + resolver.toUtc(master.end!); + providerTimeZones.toUtc(master.end!); + } + } on DavException catch (error) { + if (error.code == 'IcalUnknownTimeZone') { + return const _PreparedImportDraft.unsupported( + 'The destination cannot represent this embedded custom timezone.', + ); + } + return _PreparedImportDraft.unsupported(error.safeMessage); + } + + final allDay = startValue.kind == IcalTemporalKind.date; + final start = _draftDateTime(startValue); + final endValue = master.end; + final DateTime end; + String? endTimeZone; + if (endValue != null) { + if (endValue.kind == IcalTemporalKind.floatingDateTime) { + return const _PreparedImportDraft.unsupported( + 'Floating date-times cannot be imported without guessing a timezone.', + ); + } + if ((endValue.kind == IcalTemporalKind.date) != allDay) { + return const _PreparedImportDraft.unsupported( + 'The event start and end use incompatible value types.', + ); + } + end = _draftDateTime(endValue); + endTimeZone = _timeZone(endValue); + } else if (master.duration != null) { + end = _addWallDuration(startValue, master.duration!.duration); + endTimeZone = _timeZone(startValue); + } else if (allDay) { + end = DateTime(start.year, start.month, start.day + 1); + } else { + return const _PreparedImportDraft.unsupported( + 'Zero-duration date-time events cannot be represented by BusyMax.', + ); + } + if (!end.isAfter(start)) { + return const _PreparedImportDraft.unsupported( + 'The event has a non-positive duration.', + ); + } + + final recurrenceMap = { + 'rules': master.recurrenceRules, + 'dates': master.recurrenceDates, + 'excludedDates': master.exceptionDates, + }; + Object? recurrence; + if (master.recurrenceRules.isNotEmpty || + master.recurrenceDates.isNotEmpty || + master.exceptionDates.isNotEmpty) { + if (destination.provider == BusyProvider.microsoft && + (master.recurrenceDates.isNotEmpty || + master.exceptionDates.isNotEmpty)) { + return const _PreparedImportDraft.unsupported( + 'Microsoft cannot represent this event’s RDATE or EXDATE set.', + ); + } + final rule = EventRecurrenceCodec.decode( + BusyProvider.google, + recurrenceMap, + baseDate: start, + ); + if (!rule.repeats || + !EventRecurrenceCodec.canEncode(destination.provider, rule)) { + return _PreparedImportDraft.unsupported( + '${destination.provider.displayName} cannot represent this recurrence rule.', + ); + } + recurrence = EventRecurrenceCodec.encode( + destination.provider, + rule, + baseDate: start, + allDay: allDay, + timeZone: _timeZone(startValue), + original: recurrenceMap, + ); + } + + final reminderMinutes = _alarmMinutes(master.alarms); + final reminders = switch (destination.provider) { + BusyProvider.microsoft when reminderMinutes.isNotEmpty => { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': reminderMinutes.first, + }, + BusyProvider.google || + BusyProvider.appleICloud || + BusyProvider.nextcloud when reminderMinutes.isNotEmpty => { + 'useDefault': false, + 'overrides': [ + for (final minutes in reminderMinutes) + {'method': 'popup', 'minutes': minutes}, + ], + }, + _ => null, + }; + final omitted = {}; + if (destination.provider == BusyProvider.microsoft && + reminderMinutes.length > 1) { + omitted.add('additional reminders'); + } + if (destination.provider == BusyProvider.google && + master.categories.isNotEmpty) { + omitted.add('categories'); + } + final categories = destination.provider == BusyProvider.google + ? const [] + : master.categories; + return _PreparedImportDraft( + draft: + EventEditorDraft.newEvent( + accountId: destination.accountId, + sourceId: destination.id, + providerCalendarId: destination.providerCalendarId, + start: start, + end: end, + ).copyWith( + title: title, + allDay: allDay, + startTimeZone: _timeZone(startValue), + endTimeZone: endTimeZone, + description: master.description, + location: master.location, + recurrence: recurrence, + recurrenceChanged: recurrence != null, + reminders: reminders, + remindersChanged: reminders != null, + attendees: const [], + attendeesChanged: false, + showAs: _transparency(master.transparency, destination.provider), + visibilityOrSensitivity: master.classification?.toLowerCase(), + categories: categories, + categoriesChanged: categories.isNotEmpty, + createConference: false, + clearConference: true, + isOrganizer: true, + ), + reason: null, + omittedFields: omitted, + ); +} + +DateTime _draftDateTime(IcalTemporalValue value) { + final wall = value.localValue; + if (value.kind == IcalTemporalKind.utcDateTime) return wall.toUtc(); + return DateTime( + wall.year, + wall.month, + wall.day, + wall.hour, + wall.minute, + wall.second, + ); +} + +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, + IcalTemporalKind.date || IcalTemporalKind.floatingDateTime => null, +}; + +String? _transparency(String? value, BusyProvider provider) { + final normalized = value?.toUpperCase(); + if (provider == BusyProvider.microsoft) { + return normalized == 'TRANSPARENT' ? 'free' : 'busy'; + } + return switch (normalized) { + 'TRANSPARENT' => 'transparent', + 'OPAQUE' => 'opaque', + _ => null, + }; +} + +List _alarmMinutes(List alarms) { + final values = []; + for (final alarm in alarms) { + final action = alarm.firstProperty('ACTION')?.rawValue.toUpperCase(); + final trigger = alarm.firstProperty('TRIGGER'); + if ((action != 'DISPLAY' && action != 'AUDIO') || trigger == null) continue; + if (trigger.parameterValue('RELATED')?.toUpperCase() == 'END') continue; + try { + final duration = parseIcalDuration(trigger.rawValue.toUpperCase()); + if (duration == null || !duration.negative) continue; + final before = -duration.duration; + if (before.inSeconds <= 0 || before.inSeconds % 60 != 0) continue; + if (!values.contains(before.inMinutes)) values.add(before.inMinutes); + } on DavException { + // Unsupported alarms are intentionally omitted and reported by preview. + } + } + return values; +} + +final class _PreparedImportDraft { + const _PreparedImportDraft({ + required this.draft, + required this.reason, + this.omittedFields = const {}, + }); + + const _PreparedImportDraft.unsupported(String reason) + : draft = null, + reason = reason, + omittedFields = const {}; + + final EventEditorDraft? draft; + final String? reason; + final Set omittedFields; +} diff --git a/lib/src/ical/ical_ingestion.dart b/lib/src/ical/ical_ingestion.dart new file mode 100644 index 00000000..22a9aa60 --- /dev/null +++ b/lib/src/ical/ical_ingestion.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../dav/dav_errors.dart'; +import '../dav/ical/ical_document.dart'; +import '../dav/ical/ical_semantics.dart'; + +const icalIngestionDecodedBodyLimit = 16 * 1024 * 1024; + +enum IcalIngestionPolicy { fileImport, webCal } + +final class IcalIngestionException implements Exception { + const IcalIngestionException(this.code, this.safeMessage); + + final String code; + final String safeMessage; + + @override + String toString() => 'IcalIngestionException($code: $safeMessage)'; +} + +final class IcalRejectedEvent { + const IcalRejectedEvent({required this.uid, required this.code}); + + final String? uid; + final String code; +} + +final class IcalRecurrenceSet { + const IcalRecurrenceSet({required this.uid, required this.semantic}); + + final String uid; + final IcalSemanticDocument semantic; +} + +final class IcalIngestionResult { + const IcalIngestionResult({ + required this.document, + required this.recurrenceSets, + required this.rejectedEvents, + required this.unprojectedComponentTypes, + required this.serverRefreshIntervalSeconds, + }); + + final IcalDocument document; + final List recurrenceSets; + final List rejectedEvents; + final Set unprojectedComponentTypes; + final int? serverRefreshIntervalSeconds; +} + +abstract final class IcalIngestion { + static IcalIngestionResult parseBytes( + List input, { + required IcalIngestionPolicy policy, + }) { + if (input.length > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + var bytes = Uint8List.fromList(input); + if (bytes.length >= 3 && + bytes[0] == 0xef && + bytes[1] == 0xbb && + bytes[2] == 0xbf) { + bytes = Uint8List.sublistView(bytes, 3); + } + final String source; + try { + source = utf8.decode(bytes, allowMalformed: false); + } on FormatException { + throw const IcalIngestionException( + 'IcalInvalidUtf8', + 'The calendar data is not valid UTF-8.', + ); + } + return parseString(source, policy: policy); + } + + static IcalIngestionResult parseString( + String source, { + required IcalIngestionPolicy policy, + }) { + if (utf8.encode(source).length > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + final IcalDocument document; + try { + document = IcalDocument.parse(source); + } on DavException catch (error) { + throw IcalIngestionException(error.code, error.safeMessage); + } + _validateCalendarEnvelope(document); + + final timeZones = document.calendarComponents + .where((component) => component.name == 'VTIMEZONE') + .toList(growable: false); + final grouped = >{}; + final rejected = []; + final unknown = {}; + for (final component in document.calendarComponents) { + if (component.name != 'VEVENT') { + if (component.name != 'VTIMEZONE') unknown.add(component.name); + continue; + } + try { + final semantic = IcalSemanticComponent.fromDocumentComponent(component); + final uid = semantic.uid!; + grouped.putIfAbsent(uid, () => []).add(semantic); + } on DavException catch (error) { + if (policy == IcalIngestionPolicy.webCal) { + throw IcalIngestionException(error.code, error.safeMessage); + } + rejected.add( + IcalRejectedEvent( + uid: component.firstProperty('UID')?.rawValue.trim(), + code: error.code, + ), + ); + } + } + + final sets = []; + for (final entry in grouped.entries) { + final keys = {}; + var duplicate = false; + for (final component in entry.value) { + final key = component.recurrenceId?.recurrenceKey ?? ''; + if (!keys.add(key)) duplicate = true; + } + if (duplicate) { + if (policy == IcalIngestionPolicy.webCal) { + throw const IcalIngestionException( + 'IcalDuplicateLogicalComponent', + 'The calendar contains duplicate event recurrence components.', + ); + } + rejected.add( + IcalRejectedEvent( + uid: entry.key, + code: 'IcalDuplicateLogicalComponent', + ), + ); + continue; + } + try { + final recurrenceDocument = IcalDocument.create( + components: [ + for (final zone in timeZones) zone.deepCopy(), + for (final component in entry.value) + component.documentComponent.deepCopy(), + ], + ); + sets.add( + IcalRecurrenceSet( + uid: entry.key, + semantic: IcalSemanticDocument.fromDocument(recurrenceDocument), + ), + ); + } on DavException catch (error) { + if (policy == IcalIngestionPolicy.webCal) { + throw IcalIngestionException(error.code, error.safeMessage); + } + rejected.add(IcalRejectedEvent(uid: entry.key, code: error.code)); + } + } + return IcalIngestionResult( + document: document, + recurrenceSets: List.unmodifiable(sets), + rejectedEvents: List.unmodifiable(rejected), + unprojectedComponentTypes: Set.unmodifiable(unknown), + serverRefreshIntervalSeconds: _refreshIntervalSeconds(document), + ); + } +} + +void _validateCalendarEnvelope(IcalDocument document) { + final versions = document.root.propertiesNamed('VERSION').toList(); + if (versions.length != 1 || versions.single.rawValue.trim() != '2.0') { + throw const IcalIngestionException( + 'IcalInvalidVersion', + 'The calendar must contain exactly one VERSION:2.0 property.', + ); + } + final productIds = document.root.propertiesNamed('PRODID').toList(); + if (productIds.length != 1 || productIds.single.rawValue.trim().isEmpty) { + throw const IcalIngestionException( + 'IcalInvalidProductIdentifier', + 'The calendar must contain exactly one non-empty PRODID property.', + ); + } + if (document.calendarComponents.isEmpty) { + throw const IcalIngestionException( + 'IcalCalendarHasNoComponents', + 'The calendar contains no calendar components.', + ); + } +} + +int? _refreshIntervalSeconds(IcalDocument document) { + final property = document.root.firstProperty('REFRESH-INTERVAL'); + if (property == null || + property.parameterValue('VALUE')?.toUpperCase() != 'DURATION') { + return null; + } + try { + final duration = parseIcalDuration(property.rawValue.toUpperCase()); + if (duration == null || + duration.negative || + duration.duration <= Duration.zero) { + return null; + } + return duration.duration.inSeconds; + } on DavException { + return null; + } +} + +String sanitizedCanonicalSnapshot( + IcalDocument input, { + required Iterable secretUris, +}) { + final document = input.deepCopy(); + final secrets = secretUris.map((uri) => uri.toString()).toSet(); + void sanitize(IcalComponent component, {required bool topLevel}) { + component.children.removeWhere((node) { + if (node is! IcalProperty) return false; + if (topLevel && (node.name == 'SOURCE' || node.name == 'URL')) { + return true; + } + final value = node.rawValue.trim(); + return secrets.any(value.contains); + }); + for (final child in component.components) { + sanitize(child, topLevel: false); + } + component.structurallyDirty = true; + } + + sanitize(document.root, topLevel: true); + return document.serialize(canonicalizeUntouched: true); +} diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index fe1efc6e..e0e84310 100644 --- a/lib/src/platform/busymax_tray_service.dart +++ b/lib/src/platform/busymax_tray_service.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:dbus/dbus.dart'; @@ -6,68 +5,174 @@ import 'package:logging/logging.dart'; import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; import '../core/logging/redacting_logger.dart'; -import 'linux_window_service.dart'; +import '../features/tray/domain/tray_presentation.dart'; const String busyMaxApplicationId = 'io.busystack.busymax'; const String busyMaxTrayMenuPath = '/StatusNotifierItem/menu'; -const int _busyMaxTrayRootMenuId = 0; -const int _busyMaxTrayOpenMenuId = 1; -const int _busyMaxTrayAgendaMenuId = 2; -const int _busyMaxTrayQuitMenuId = 3; -const int _busyMaxTrayOfflineMenuId = 4; - -class BusyMaxTrayLabels { - const BusyMaxTrayLabels({ - required this.openBusyMax, - required this.agenda, + +abstract final class BusyMaxTrayMenuIds { + static const root = 0; + static const showBusyMax = 1; + static const firstSeparator = 2; + static const newEvent = 3; + static const newTask = 4; + static const secondSeparator = 5; + static const today = 6; + static const firstEvent = 7; + static const secondEvent = 8; + static const thirdEvent = 9; + static const tasksDueToday = 10; + static const emptyToday = 11; + static const openTodayAgenda = 12; + static const thirdSeparator = 13; + static const syncNow = 14; + static const synchronizationStatus = 15; + static const fourthSeparator = 16; + static const settings = 17; + static const quitBusyMax = 18; + + static const orderedChildren = [ + showBusyMax, + firstSeparator, + newEvent, + newTask, + secondSeparator, + today, + firstEvent, + secondEvent, + thirdEvent, + tasksDueToday, + emptyToday, + openTodayAgenda, + thirdSeparator, + syncNow, + synchronizationStatus, + fourthSeparator, + settings, + quitBusyMax, + ]; +} + +final class BusyMaxTrayActions { + const BusyMaxTrayActions({ + required this.showBusyMax, + required this.newEvent, + required this.newTask, + required this.openEvent, + required this.openTasksDueToday, + required this.openTodayAgenda, + required this.synchronize, + required this.openSettings, required this.quitBusyMax, - required this.offline, - required this.offlineDescription, }); - final String openBusyMax; - final String agenda; - final String quitBusyMax; - final String offline; - final String offlineDescription; - - @override - bool operator ==(Object other) { - return other is BusyMaxTrayLabels && - other.openBusyMax == openBusyMax && - other.agenda == agenda && - other.quitBusyMax == quitBusyMax && - other.offline == offline && - other.offlineDescription == offlineDescription; - } + final Future Function() showBusyMax; + final Future Function() newEvent; + final Future Function() newTask; + final Future Function(BusyMaxTrayEventEntry event) openEvent; + final Future Function() openTasksDueToday; + final Future Function() openTodayAgenda; + final Future Function() synchronize; + final Future Function() openSettings; + final Future Function() quitBusyMax; +} - @override - int get hashCode => Object.hash( - openBusyMax, - agenda, - quitBusyMax, - offline, - offlineDescription, +final class BusyMaxTrayServiceConfiguration { + const BusyMaxTrayServiceConfiguration({ + required this.initialPresentation, + required this.loadPresentation, + required this.actions, + }); + + final BusyMaxTrayMenuPresentation initialPresentation; + final Future Function() loadPresentation; + final BusyMaxTrayActions actions; +} + +typedef BusyMaxTrayMenuUpdater = + Future Function( + DBusMenuItem menu, + BusyMaxTrayMenuPresentation presentation, + ); + +final class BusyMaxTrayMenuController { + BusyMaxTrayMenuController({ + required BusyMaxTrayMenuPresentation initialPresentation, + required Future Function() loadPresentation, + required BusyMaxTrayActions actions, + void Function(Object error)? onLoadFailure, + }) : _presentation = initialPresentation, + _loadPresentation = loadPresentation, + _actions = actions, + _onLoadFailure = onLoadFailure; + + final Future Function() _loadPresentation; + final BusyMaxTrayActions _actions; + final void Function(Object error)? _onLoadFailure; + BusyMaxTrayMenuPresentation _presentation; + BusyMaxTrayMenuUpdater? _updater; + Future? _activeRefresh; + + BusyMaxTrayMenuPresentation get presentation => _presentation; + + DBusMenuItem get menu => buildBusyMaxTrayMenu( + presentation: _presentation, + actions: _actions, + onAboutToShow: refresh, ); + + void attachUpdater(BusyMaxTrayMenuUpdater? updater) { + _updater = updater; + } + + Future refresh() { + final active = _activeRefresh; + if (active != null) return active; + final refresh = _loadAndPublish(); + _activeRefresh = refresh; + return refresh.whenComplete(() { + if (identical(_activeRefresh, refresh)) _activeRefresh = null; + }); + } + + Future _loadAndPublish() async { + try { + final next = await _loadPresentation(); + if (next == _presentation) return false; + final menu = buildBusyMaxTrayMenu( + presentation: next, + actions: _actions, + onAboutToShow: refresh, + ); + await _updater?.call(menu, next); + _presentation = next; + return true; + } on Object catch (error) { + _onLoadFailure?.call(error); + return false; + } + } } class BusyMaxTrayService { - BusyMaxTrayService({ - required LinuxWindowService windowService, - required BusyMaxTrayLabels labels, - required Future Function() onOpenAgenda, - }) : _windowService = windowService, - _labels = labels, - _onOpenAgenda = onOpenAgenda; - - final LinuxWindowService _windowService; - final Future Function() _onOpenAgenda; - BusyMaxTrayLabels _labels; + BusyMaxTrayService({required BusyMaxTrayServiceConfiguration configuration}) + : _actions = configuration.actions { + _menuControllerWithLogging = BusyMaxTrayMenuController( + initialPresentation: configuration.initialPresentation, + loadPresentation: configuration.loadPresentation, + actions: configuration.actions, + onLoadFailure: _logPresentationLoadFailure, + ); + } + + final BusyMaxTrayActions _actions; + late final BusyMaxTrayMenuController _menuControllerWithLogging; final RedactingLogger _logger = RedactingLogger(Logger('BusyMaxTrayService')); StatusNotifierItemClient? _client; bool _available = false; - bool _offline = false; + + BusyMaxTrayMenuController get _controller => _menuControllerWithLogging; bool get available => _available; @@ -77,77 +182,36 @@ class BusyMaxTrayService { _logger.fine('Tray initialization skipped: existing_client=true'); return; } - final iconName = _trayIconName(offline: _offline); - final initialLabels = _labels; - final initialOffline = _offline; - _logger.fine( - 'DBus menu creation starting: path=$busyMaxTrayMenuPath ' - 'root_id=$_busyMaxTrayRootMenuId ' - 'open_id=$_busyMaxTrayOpenMenuId agenda_id=$_busyMaxTrayAgendaMenuId ' - 'quit_id=$_busyMaxTrayQuitMenuId', - ); - final menu = buildBusyMaxTrayMenu( - labels: _labels, - offline: _offline, - onOpenBusyMax: _show, - onOpenAgenda: _showAgenda, - onQuit: _quit, - ); + await _controller.refresh(); + final exportedPresentation = _controller.presentation; + final menu = _controller.menu; + final iconName = _trayIconName(offline: exportedPresentation.offline); _logger.fine( 'DBus menu creation completed: path=$busyMaxTrayMenuPath ' 'items=${menu.children.length} ' 'ids=${menu.children.map((item) => item.id).join(',')}', ); - _logger.fine( - 'Tray initialization starting: snap=${_isRunningInSnap()} ' - 'icon=${_sanitizeIconForLog(iconName)} menu_items=${menu.children.length}', - ); final client = StatusNotifierItemClient( id: busyMaxApplicationId, - title: _trayTitle, + title: _trayTitle(exportedPresentation), iconName: iconName, - toolTipTitle: _trayTitle, - toolTipDescription: _trayToolTipDescription, + toolTipTitle: _trayTitle(exportedPresentation), + toolTipDescription: _trayToolTipDescription(exportedPresentation), itemIsMenu: true, menuPath: DBusObjectPath(busyMaxTrayMenuPath), menu: menu, diagnosticLog: (message) => _logger.fine(_sanitizeForLog('StatusNotifier diagnostic: $message')), - onContextMenu: (x, y) async { - _logger.fine( - 'Tray context menu callback fired: snap=${_isRunningInSnap()} ' - 'x=$x y=$y fallback=showWindow', - ); - await _showFromStatusNotifierActivation( - action: 'StatusNotifierItem context menu callback', - ); - }, - onActivate: (x, y) { - _logger.fine( - 'Tray activation callback fired: snap=${_isRunningInSnap()} ' - 'x=$x y=$y', - ); - return _showFromStatusNotifierActivation( - action: 'StatusNotifierItem activation callback', - ); - }, - onSecondaryActivate: (x, y) async { - _logger.fine( - 'Tray secondary activation callback fired: snap=${_isRunningInSnap()} ' - 'x=$x y=$y fallback=showWindow', - ); - await _showFromStatusNotifierActivation( - action: 'StatusNotifierItem secondary activation callback', - ); - }, + onActivate: (x, y) => handleActivate(), + onSecondaryActivate: (x, y) => handleSecondaryActivate(), ); try { await client.connect(); _client = client; _available = true; - if (_labels != initialLabels || _offline != initialOffline) { - await _updateClientPresentation(); - await _updateMenu(); + _controller.attachUpdater(_publishPresentation); + if (_controller.presentation != exportedPresentation) { + await _publishPresentation(_controller.menu, _controller.presentation); } final menuVerified = await _verifyMenuExported(); _logger.fine( @@ -157,115 +221,66 @@ class BusyMaxTrayService { 'menu_items=${menu.children.length}', ); } on Object catch (error) { + _controller.attachUpdater(null); _client = null; _available = false; _logger.warning( 'StatusNotifier registration failed: snap=${_isRunningInSnap()} ' - 'error=${_sanitizeForLog(error)}', + 'error_type=${error.runtimeType}', ); await client.close(); } } Future stop() async { + _controller.attachUpdater(null); final client = _client; _client = null; _available = false; await client?.close(); } - Future updateLabels(BusyMaxTrayLabels labels) async { - if (_labels == labels) { - return; - } - _labels = labels; - _logger.fine('Tray labels updating'); - await _updateClientPresentation(); - await _updateMenu(); - } - - Future updateOfflineState(bool offline) async { - if (_offline == offline) { - return; - } - _offline = offline; - _logger.fine('Tray connectivity presentation updating: offline=$offline'); - await _updateClientPresentation(); - await _updateMenu(); - } + Future refreshPresentation() => _controller.refresh(); - String get _trayTitle => - _offline ? 'BusyMax — ${_labels.offline}' : 'BusyMax'; + Future handleActivate() => _runLoggedTrayAction( + logger: _logger, + action: 'StatusNotifierItem activation callback', + callback: _actions.showBusyMax, + ); - String get _trayToolTipDescription => - _offline ? _labels.offlineDescription : ''; + Future handleSecondaryActivate() => _runLoggedTrayAction( + logger: _logger, + action: 'StatusNotifierItem secondary activation callback', + callback: _actions.openTodayAgenda, + ); - Future _updateClientPresentation() async { + Future _publishPresentation( + DBusMenuItem menu, + BusyMaxTrayMenuPresentation presentation, + ) async { final client = _client; - if (client == null) { - return; - } + if (client == null) return; + await client.updateMenu(menu); await client.updatePresentation( - title: _trayTitle, - iconName: _trayIconName(offline: _offline), - toolTipTitle: _trayTitle, - toolTipDescription: _trayToolTipDescription, + title: _trayTitle(presentation), + iconName: _trayIconName(offline: presentation.offline), + toolTipTitle: _trayTitle(presentation), + toolTipDescription: _trayToolTipDescription(presentation), ); } - Future _updateMenu() async { - final client = _client; - if (client == null) { - return; - } - final menu = buildBusyMaxTrayMenu( - labels: _labels, - offline: _offline, - onOpenBusyMax: _show, - onOpenAgenda: _showAgenda, - onQuit: _quit, - ); - await client.updateMenu(menu); - _logger.fine( - 'Tray menu update completed: menu_items=${menu.children.length}', - ); - } + String _trayTitle(BusyMaxTrayMenuPresentation presentation) => + presentation.offline + ? 'BusyMax — ${presentation.offlineTitle}' + : 'BusyMax'; - Future _showFromStatusNotifierActivation({required String action}) { - return _runLoggedTrayAction( - logger: _logger, - action: action, - callback: _show, - ); - } + String _trayToolTipDescription(BusyMaxTrayMenuPresentation presentation) => + presentation.offline ? presentation.offlineDescription : ''; - Future _show() { - _logger.fine( - 'Tray restore requested: action=showWindow target=main_window', + void _logPresentationLoadFailure(Object error) { + _logger.warning( + 'Tray presentation refresh failed: error_type=${error.runtimeType}', ); - return _windowService.showWindow(); - } - - Future _showAgenda() { - return _onOpenAgenda(); - } - - Future _quit() async { - _logger.fine('Tray quit requested: action=quitApp'); - unawaited(_stopAfterQuitRequest()); - await _windowService.quitApp(); - } - - Future _stopAfterQuitRequest() async { - try { - await stop(); - _logger.fine('Tray client close completed after quit request'); - } on Object catch (error) { - _logger.warning( - 'Tray client close failed after quit request: ' - 'error=${_sanitizeForLog(error)}', - ); - } } Future _verifyMenuExported() async { @@ -298,7 +313,7 @@ class BusyMaxTrayService { } on Object catch (error) { _logger.warning( 'Tray menu DBus verification failed: path=$busyMaxTrayMenuPath ' - 'error=${_sanitizeForLog(error)}', + 'error_type=${error.runtimeType}', ); return false; } finally { @@ -315,77 +330,149 @@ List _asMenuLayoutStruct(DBusValue value) { } DBusMenuItem buildBusyMaxTrayMenu({ - required BusyMaxTrayLabels labels, - bool offline = false, - required Future Function() onOpenBusyMax, - required Future Function() onOpenAgenda, - required Future Function() onQuit, + required BusyMaxTrayMenuPresentation presentation, + required BusyMaxTrayActions actions, + Future Function()? onAboutToShow, }) { + final eventRows = presentation.eventRows; return DBusMenuItem( - id: _busyMaxTrayRootMenuId, + id: BusyMaxTrayMenuIds.root, enabled: true, visible: true, + onAboutToShow: onAboutToShow, children: [ - if (offline) - DBusMenuItem( - id: _busyMaxTrayOfflineMenuId, - enabled: false, - visible: true, - label: '${labels.offline} — ${labels.offlineDescription}', - ), + _actionItem( + id: BusyMaxTrayMenuIds.showBusyMax, + label: presentation.showBusyMaxLabel, + callback: actions.showBusyMax, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.firstSeparator), + _actionItem( + id: BusyMaxTrayMenuIds.newEvent, + label: presentation.newEventLabel, + enabled: presentation.canCreateEvent, + callback: actions.newEvent, + ), + _actionItem( + id: BusyMaxTrayMenuIds.newTask, + label: presentation.newTaskLabel, + enabled: presentation.canCreateTask, + callback: actions.newTask, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.secondSeparator), DBusMenuItem( - id: _busyMaxTrayOpenMenuId, - enabled: true, + id: BusyMaxTrayMenuIds.today, + enabled: false, visible: true, - label: labels.openBusyMax, - onClicked: () => _runLoggedTrayAction( - logger: RedactingLogger(Logger('BusyMaxTrayService')), - action: 'Tray menu "Open BusyMax" callback', - callback: onOpenBusyMax, + label: presentation.todayLabel, + ), + for (var index = 0; index < 3; index += 1) + _eventSlot( + id: BusyMaxTrayMenuIds.firstEvent + index, + row: index < eventRows.length ? eventRows[index] : null, + actions: actions, ), + _actionItem( + id: BusyMaxTrayMenuIds.tasksDueToday, + label: presentation.taskSummaryLabel ?? '', + visible: presentation.taskSummaryLabel != null, + enabled: presentation.taskSummaryLabel != null, + callback: actions.openTasksDueToday, ), DBusMenuItem( - id: _busyMaxTrayAgendaMenuId, - enabled: true, - visible: true, - label: labels.agenda, - onClicked: () => _runLoggedTrayAction( - logger: RedactingLogger(Logger('BusyMaxTrayService')), - action: 'Tray menu "Agenda" callback', - callback: onOpenAgenda, - ), + id: BusyMaxTrayMenuIds.emptyToday, + enabled: false, + visible: presentation.showEmptyState, + label: presentation.emptyStateLabel, + ), + _actionItem( + id: BusyMaxTrayMenuIds.openTodayAgenda, + label: presentation.openTodayAgendaLabel, + callback: actions.openTodayAgenda, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.thirdSeparator), + _actionItem( + id: BusyMaxTrayMenuIds.syncNow, + label: presentation.syncNowLabel, + enabled: presentation.canSynchronize, + callback: actions.synchronize, ), DBusMenuItem( - id: _busyMaxTrayQuitMenuId, - enabled: true, + id: BusyMaxTrayMenuIds.synchronizationStatus, + enabled: false, visible: true, - label: labels.quitBusyMax, - onClicked: () => _runLoggedTrayAction( - logger: RedactingLogger(Logger('BusyMaxTrayService')), - action: 'Tray menu "Quit" callback', - callback: onQuit, - ), + label: presentation.synchronizationStatusLabel, + ), + DBusMenuItem.separator(id: BusyMaxTrayMenuIds.fourthSeparator), + _actionItem( + id: BusyMaxTrayMenuIds.settings, + label: presentation.settingsLabel, + callback: actions.openSettings, + ), + _actionItem( + id: BusyMaxTrayMenuIds.quitBusyMax, + label: presentation.quitBusyMaxLabel, + callback: actions.quitBusyMax, ), ], ); } +DBusMenuItem _eventSlot({ + required int id, + required BusyMaxTrayEventRowPresentation? row, + required BusyMaxTrayActions actions, +}) { + return DBusMenuItem( + id: id, + enabled: row != null, + visible: row != null, + label: row?.label ?? '', + onClicked: row == null + ? null + : () => _runLoggedTrayAction( + logger: RedactingLogger(Logger('BusyMaxTrayService')), + action: 'Tray event slot callback', + callback: () => actions.openEvent(row.event), + ), + ); +} + +DBusMenuItem _actionItem({ + required int id, + required String label, + required Future Function() callback, + bool enabled = true, + bool visible = true, +}) { + return DBusMenuItem( + id: id, + enabled: enabled, + visible: visible, + label: label, + onClicked: enabled && visible + ? () => _runLoggedTrayAction( + logger: RedactingLogger(Logger('BusyMaxTrayService')), + action: 'Tray menu callback', + callback: callback, + ) + : null, + ); +} + Future _runLoggedTrayAction({ required RedactingLogger logger, required String action, required Future Function() callback, }) async { - logger.fine( - 'Tray callback fired: action="$action" snap=${_isRunningInSnap()}', - ); + logger.fine('Tray callback fired: action="$action"'); try { await callback(); logger.fine('Tray callback completed: action="$action"'); } on Object catch (error) { logger.warning( - 'Tray callback failed: action="$action" error=${_sanitizeForLog(error)}', + 'Tray callback failed: action="$action" error_type=${error.runtimeType}', ); - rethrow; } } @@ -396,16 +483,12 @@ String _trayIconName({required bool offline}) { '${executableDir.path}/data/flutter_assets/assets/branding/' 'busymax-logo-offline.svg', ); - if (bundledOfflineLogo.existsSync()) { - return bundledOfflineLogo.path; - } + if (bundledOfflineLogo.existsSync()) return bundledOfflineLogo.path; } final bundledLogo = File( '${executableDir.path}/data/flutter_assets/assets/branding/busymax-logo.svg', ); - if (bundledLogo.existsSync()) { - return bundledLogo.path; - } + if (bundledLogo.existsSync()) return bundledLogo.path; return busyMaxApplicationId; } @@ -414,11 +497,3 @@ bool _isRunningInSnap() => Platform.environment['SNAP']?.isNotEmpty ?? false; String _sanitizeForLog(Object? value) { return redactForLog(value).replaceAll(RegExp(r'\s+'), ' ').trim(); } - -String _sanitizeIconForLog(String iconName) { - if (iconName == busyMaxApplicationId) { - return busyMaxApplicationId; - } - final basename = iconName.split(Platform.pathSeparator).last; - return basename.isEmpty ? '' : basename; -} diff --git a/lib/src/platform/external_calendar_open_service.dart b/lib/src/platform/external_calendar_open_service.dart new file mode 100644 index 00000000..4a3e9357 --- /dev/null +++ b/lib/src/platform/external_calendar_open_service.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +const externalCalendarOpenChannelName = + 'io.busystack.busymax/external_calendar_open'; + +enum ExternalCalendarOpenKind { webCal, icsFile } + +final class ExternalCalendarOpenRequest { + const ExternalCalendarOpenRequest({required this.kind, required this.value}); + + final ExternalCalendarOpenKind kind; + final String value; +} + +final class ExternalCalendarOpenService { + ExternalCalendarOpenService({ + MethodChannel channel = const MethodChannel( + externalCalendarOpenChannelName, + ), + }) : _channel = channel; + + final MethodChannel _channel; + final _requests = StreamController(); + bool _initialized = false; + + Stream get requests => _requests.stream; + + Future initialize() async { + if (_initialized) return; + _initialized = true; + _channel.setMethodCallHandler((call) async { + if (call.method != 'openItem' || call.arguments is! Map) return; + final arguments = Map.from(call.arguments as Map); + final kind = arguments['kind']; + final value = arguments['value']; + if (value is! String || value.isEmpty) return; + switch (kind) { + case 'webcal': + _requests.add( + ExternalCalendarOpenRequest( + kind: ExternalCalendarOpenKind.webCal, + value: value, + ), + ); + case 'ics': + _requests.add( + ExternalCalendarOpenRequest( + kind: ExternalCalendarOpenKind.icsFile, + value: value, + ), + ); + } + }); + await _channel.invokeMethod('ready'); + } + + Future dispose() async { + _channel.setMethodCallHandler(null); + await _requests.close(); + } +} diff --git a/lib/src/providers/account_authority.dart b/lib/src/providers/account_authority.dart index 640833af..69394e30 100644 --- a/lib/src/providers/account_authority.dart +++ b/lib/src/providers/account_authority.dart @@ -30,7 +30,8 @@ String normalizeProviderAccountId(BusyProvider provider, String value) { BusyProvider.appleICloud => trimmed.toLowerCase(), BusyProvider.google || BusyProvider.microsoft || - BusyProvider.nextcloud => trimmed, + BusyProvider.nextcloud || + BusyProvider.webCal => trimmed, }; } @@ -49,9 +50,36 @@ String normalizeAccountAuthority( 'Nextcloud requires the canonical server returned by Login Flow v2.', )), ), + BusyProvider.webCal => _safeHttpsOrigin( + authority ?? + (throw const InvalidAccountAuthorityException( + 'WebCal requires a safe HTTPS origin.', + )), + ), }; } +String _safeHttpsOrigin(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + throw InvalidAccountAuthorityException( + 'A WebCal authority must be a safe HTTPS origin.', + value, + ); + } + return Uri( + scheme: 'https', + host: uri.host.toLowerCase(), + port: uri.hasPort && uri.port != 443 ? uri.port : null, + ).toString(); +} + String normalizeNextcloudServerAuthority(String value) { final trimmed = value.trim(); final parsed = Uri.tryParse(trimmed); diff --git a/lib/src/providers/busy_provider.dart b/lib/src/providers/busy_provider.dart index 2c9b0f53..4e13952b 100644 --- a/lib/src/providers/busy_provider.dart +++ b/lib/src/providers/busy_provider.dart @@ -1,5 +1,5 @@ /// Stable account provider identities persisted by BusyMax. -enum BusyProvider { google, microsoft, appleICloud, nextcloud } +enum BusyProvider { google, microsoft, appleICloud, nextcloud, webCal } extension BusyProviderValue on BusyProvider { String get storageValue => switch (this) { @@ -7,6 +7,7 @@ extension BusyProviderValue on BusyProvider { BusyProvider.microsoft => 'microsoft', BusyProvider.appleICloud => 'apple_icloud', BusyProvider.nextcloud => 'nextcloud', + BusyProvider.webCal => 'webcal', }; String get displayName => switch (this) { @@ -14,6 +15,7 @@ extension BusyProviderValue on BusyProvider { BusyProvider.microsoft => 'Microsoft', BusyProvider.appleICloud => 'Apple iCloud', BusyProvider.nextcloud => 'Nextcloud', + BusyProvider.webCal => 'WebCal', }; } @@ -70,6 +72,7 @@ abstract final class BusyProviderCodec { 'microsoft' => const SupportedBusyProvider(BusyProvider.microsoft), 'apple_icloud' => const SupportedBusyProvider(BusyProvider.appleICloud), 'nextcloud' => const SupportedBusyProvider(BusyProvider.nextcloud), + 'webcal' => const SupportedBusyProvider(BusyProvider.webCal), _ => UnsupportedStoredProvider(value), }; } diff --git a/lib/src/schedule/schedule_projection.dart b/lib/src/schedule/schedule_projection.dart index 21f3e9d2..3d40ab37 100644 --- a/lib/src/schedule/schedule_projection.dart +++ b/lib/src/schedule/schedule_projection.dart @@ -146,6 +146,7 @@ class ScheduleProjection { BusyProvider.microsoft => _dedupeProvider('Microsoft To Do', listName), BusyProvider.appleICloud => _dedupeProvider('Apple iCloud', listName), BusyProvider.nextcloud => _dedupeProvider('Nextcloud Tasks', listName), + BusyProvider.webCal => listName, }; } return 'BusyMax'; diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 96b5dc72..17c2fc05 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -1181,7 +1181,9 @@ bool? _eventIsOrganizer( return switch (provider) { BusyProvider.google => organizer?['self'] as bool?, BusyProvider.microsoft => raw['isOrganizer'] as bool?, - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; } @@ -1190,6 +1192,7 @@ bool _eventAllowsFullEditing( bool? isOrganizer, Map raw, ) { + if (provider == BusyProvider.webCal) return false; if (provider != BusyProvider.google) return true; return raw['locked'] != true && (isOrganizer == true || raw['guestsCanModify'] == true); @@ -1241,7 +1244,9 @@ String? _eventJoinMeetingUrl( final fallback = switch (provider) { BusyProvider.google => raw['hangoutLink']?.toString().trim(), BusyProvider.microsoft => raw['onlineMeetingUrl']?.toString().trim(), - BusyProvider.appleICloud || BusyProvider.nextcloud => null, + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, }; return _isWebUrl(fallback) ? fallback : null; } @@ -1285,7 +1290,8 @@ List _eventReminderMinutes( _ => const [], }, BusyProvider.appleICloud || - BusyProvider.nextcloud => switch (map['minutes'] ?? map['overrides']) { + BusyProvider.nextcloud || + BusyProvider.webCal => switch (map['minutes'] ?? map['overrides']) { final List values => values, final int value => [value], _ => const [], diff --git a/lib/src/webcal/webcal_http_client.dart b/lib/src/webcal/webcal_http_client.dart new file mode 100644 index 00000000..88c64db7 --- /dev/null +++ b/lib/src/webcal/webcal_http_client.dart @@ -0,0 +1,258 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../ical/ical_ingestion.dart'; + +final class WebCalHttpException implements Exception { + const WebCalHttpException(this.code, {this.httpStatus}); + + final String code; + final int? httpStatus; + + @override + String toString() => + 'WebCalHttpException($code${httpStatus == null ? '' : ', status: $httpStatus'})'; +} + +final class WebCalHttpValidators { + const WebCalHttpValidators({this.etag, this.lastModified}); + + final String? etag; + final String? lastModified; + + bool get isEmpty => etag == null && lastModified == null; +} + +final class WebCalHttpResponse { + const WebCalHttpResponse({ + required this.statusCode, + required this.finalUri, + required this.body, + required this.etag, + required this.lastModified, + required this.contentType, + required this.conditionalRequestSent, + }); + + final int statusCode; + final Uri finalUri; + final Uint8List? body; + final String? etag; + final String? lastModified; + final String? contentType; + final bool conditionalRequestSent; +} + +abstract interface class WebCalHttpTransport { + Future get( + Uri uri, { + WebCalHttpValidators validators = const WebCalHttpValidators(), + Uri? validatorTarget, + }); +} + +final class WebCalHttpLimits { + const WebCalHttpLimits({ + this.connectTimeout = const Duration(seconds: 15), + this.streamInactivityTimeout = const Duration(seconds: 30), + this.totalDeadline = const Duration(minutes: 2), + this.maximumDecodedBodyBytes = icalIngestionDecodedBodyLimit, + this.maximumRedirects = 5, + }); + + final Duration connectTimeout; + final Duration streamInactivityTimeout; + final Duration totalDeadline; + final int maximumDecodedBodyBytes; + final int maximumRedirects; +} + +final class IoWebCalHttpTransport implements WebCalHttpTransport { + IoWebCalHttpTransport({ + HttpClient Function()? clientFactory, + this.limits = const WebCalHttpLimits(), + }) : _clientFactory = clientFactory ?? HttpClient.new; + + final HttpClient Function() _clientFactory; + final WebCalHttpLimits limits; + + @override + Future get( + Uri uri, { + WebCalHttpValidators validators = const WebCalHttpValidators(), + Uri? validatorTarget, + }) async { + final deadline = DateTime.now().add(limits.totalDeadline); + final visited = {}; + var current = uri; + var redirects = 0; + var allowValidators = validatorTarget == current && !validators.isEmpty; + while (true) { + _requireHttpsTarget(current); + if (!visited.add(current.toString())) { + throw const WebCalHttpException('WebCalRedirectLoop'); + } + if (redirects > limits.maximumRedirects) { + throw const WebCalHttpException('WebCalRedirectLimitExceeded'); + } + final response = await _singleGet( + current, + validators: allowValidators ? validators : const WebCalHttpValidators(), + deadline: deadline, + ); + if (!_redirectStatuses.contains(response.statusCode)) return response; + final location = response.redirectLocation; + if (location == null) { + throw WebCalHttpException( + 'WebCalRedirectMissingLocation', + httpStatus: response.statusCode, + ); + } + final next = current.resolveUri(location); + _requireHttpsTarget(next); + current = next; + allowValidators = false; + redirects += 1; + } + } + + Future<_SingleResponse> _singleGet( + Uri target, { + required WebCalHttpValidators validators, + required DateTime deadline, + }) async { + final client = _clientFactory() + ..connectionTimeout = limits.connectTimeout + ..autoUncompress = true; + try { + final remaining = _remaining(deadline); + final request = await client + .getUrl(target) + .timeout( + remaining, + onTimeout: () => + throw const WebCalHttpException('WebCalTotalTimeout'), + ); + request + ..followRedirects = false + ..maxRedirects = 0; + request.headers + ..set(HttpHeaders.acceptHeader, 'text/calendar') + ..removeAll(HttpHeaders.authorizationHeader) + ..removeAll(HttpHeaders.cookieHeader) + ..removeAll(HttpHeaders.refererHeader) + ..removeAll('Origin'); + if (validators.etag != null) { + request.headers.set(HttpHeaders.ifNoneMatchHeader, validators.etag!); + } + if (validators.lastModified != null) { + request.headers.set( + HttpHeaders.ifModifiedSinceHeader, + validators.lastModified!, + ); + } + final response = await request.close().timeout( + _remaining(deadline), + onTimeout: () => throw const WebCalHttpException('WebCalTotalTimeout'), + ); + final redirectLocation = switch (response.headers.value( + HttpHeaders.locationHeader, + )) { + final value? => Uri.tryParse(value), + null => null, + }; + Uint8List? body; + if (response.statusCode != HttpStatus.notModified && + !_redirectStatuses.contains(response.statusCode)) { + body = await _readLimitedBody( + response, + deadline: deadline, + inactivityTimeout: limits.streamInactivityTimeout, + maximumBytes: limits.maximumDecodedBodyBytes, + ); + } else { + await response.drain(); + } + return _SingleResponse( + statusCode: response.statusCode, + finalUri: target, + body: body, + etag: response.headers.value(HttpHeaders.etagHeader), + lastModified: response.headers.value(HttpHeaders.lastModifiedHeader), + contentType: response.headers.value(HttpHeaders.contentTypeHeader), + conditionalRequestSent: !validators.isEmpty, + redirectLocation: redirectLocation, + ); + } on WebCalHttpException { + rethrow; + } on TimeoutException { + throw const WebCalHttpException('WebCalNetworkTimeout'); + } on SocketException { + throw const WebCalHttpException('WebCalNetworkFailure'); + } on HttpException { + throw const WebCalHttpException('WebCalNetworkFailure'); + } finally { + client.close(force: true); + } + } +} + +const _redirectStatuses = {301, 302, 303, 307, 308}; + +void _requireHttpsTarget(Uri uri) { + if (uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasFragment) { + throw const WebCalHttpException('WebCalRedirectTargetNotAllowed'); + } +} + +Future _readLimitedBody( + HttpClientResponse response, { + required DateTime deadline, + required Duration inactivityTimeout, + required int maximumBytes, +}) async { + final builder = BytesBuilder(copy: false); + final stream = response.timeout( + inactivityTimeout, + onTimeout: (sink) => sink.addError( + const WebCalHttpException('WebCalStreamInactivityTimeout'), + ), + ); + await for (final chunk in stream) { + if (DateTime.now().isAfter(deadline)) { + throw const WebCalHttpException('WebCalTotalTimeout'); + } + if (builder.length + chunk.length > maximumBytes) { + throw const WebCalHttpException('WebCalBodyTooLarge'); + } + builder.add(chunk); + } + return builder.takeBytes(); +} + +Duration _remaining(DateTime deadline) { + final value = deadline.difference(DateTime.now()); + if (value <= Duration.zero) { + throw const WebCalHttpException('WebCalTotalTimeout'); + } + return value; +} + +final class _SingleResponse extends WebCalHttpResponse { + const _SingleResponse({ + required super.statusCode, + required super.finalUri, + required super.body, + required super.etag, + required super.lastModified, + required super.contentType, + required super.conditionalRequestSent, + required this.redirectLocation, + }); + + final Uri? redirectLocation; +} diff --git a/lib/src/webcal/webcal_subscription_service.dart b/lib/src/webcal/webcal_subscription_service.dart new file mode 100644 index 00000000..d61ae14f --- /dev/null +++ b/lib/src/webcal/webcal_subscription_service.dart @@ -0,0 +1,855 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../core/secrets/secret_store.dart'; +import '../dav/dav_errors.dart'; +import '../db/app_database.dart'; +import '../features/accounts/data/accounts_repository.dart'; +import '../features/notifications/notification_schedule_service.dart'; +import '../ical/ical_event_projection.dart'; +import '../ical/ical_ingestion.dart'; +import '../providers/busy_provider.dart'; +import 'webcal_http_client.dart'; +import 'webcal_uri.dart'; + +const webCalParserVersion = 1; +const webCalProjectionVersion = 1; + +enum WebCalRefreshMode { automatic, hourly, sixHours, daily } + +extension WebCalRefreshModeStorage on WebCalRefreshMode { + String get storageValue => switch (this) { + WebCalRefreshMode.automatic => 'automatic', + WebCalRefreshMode.hourly => 'hourly', + WebCalRefreshMode.sixHours => 'six_hours', + WebCalRefreshMode.daily => 'daily', + }; + + static WebCalRefreshMode parse(String value) => switch (value) { + 'hourly' => WebCalRefreshMode.hourly, + 'six_hours' => WebCalRefreshMode.sixHours, + 'daily' => WebCalRefreshMode.daily, + _ => WebCalRefreshMode.automatic, + }; +} + +final class WebCalSubscriptionEntity { + const WebCalSubscriptionEntity({ + required this.id, + required this.accountId, + required this.calendarSourceId, + required this.name, + required this.color, + required this.safeOrigin, + required this.refreshMode, + required this.nextRefreshAtUtc, + required this.lastSuccessfulSyncAtUtc, + required this.lastFailureCode, + required this.lastFailureHttpStatus, + required this.consecutiveFailureCount, + }); + + final String id; + final String accountId; + final String calendarSourceId; + final String name; + final String? color; + final String safeOrigin; + final WebCalRefreshMode refreshMode; + final DateTime nextRefreshAtUtc; + final DateTime? lastSuccessfulSyncAtUtc; + final String? lastFailureCode; + final int? lastFailureHttpStatus; + final int consecutiveFailureCount; +} + +final class WebCalSubscriptionException implements Exception { + const WebCalSubscriptionException(this.code, {this.httpStatus}); + + final String code; + final int? httpStatus; + + @override + String toString() => 'WebCalSubscriptionException($code)'; +} + +final class WebCalSubscriptionService { + WebCalSubscriptionService({ + required AppDatabase database, + required SecretStore secretStore, + required WebCalHttpTransport httpTransport, + NotificationScheduleService? notificationScheduleService, + Future Function()? onNotificationScheduleChanged, + DateTime Function()? nowUtc, + String Function()? idFactory, + IcalEventProjector? eventProjector, + }) : _database = database, + _secretStore = secretStore, + _httpTransport = httpTransport, + _notificationScheduleService = + notificationScheduleService ?? + NotificationScheduleService(database: database), + _onNotificationScheduleChanged = onNotificationScheduleChanged, + _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()), + _idFactory = idFactory ?? const Uuid().v4, + _eventProjector = eventProjector ?? IcalEventProjector(); + + final AppDatabase _database; + final SecretStore _secretStore; + final WebCalHttpTransport _httpTransport; + final NotificationScheduleService _notificationScheduleService; + final Future Function()? _onNotificationScheduleChanged; + final DateTime Function() _nowUtc; + final String Function() _idFactory; + final IcalEventProjector _eventProjector; + final Map> _refreshTails = {}; + + Stream> watchSubscriptions() { + final query = _database.select(_database.webCalSubscriptions).join([ + innerJoin( + _database.calendarSources, + _database.calendarSources.id.equalsExp( + _database.webCalSubscriptions.calendarSourceId, + ), + ), + ])..orderBy([OrderingTerm.asc(_database.calendarSources.summary)]); + return query.watch().map( + (rows) => [ + for (final row in rows) + _entity( + row.readTable(_database.webCalSubscriptions), + row.readTable(_database.calendarSources), + ), + ], + ); + } + + Future> listSubscriptions() async { + final rows = await (_database.select(_database.webCalSubscriptions).join([ + innerJoin( + _database.calendarSources, + _database.calendarSources.id.equalsExp( + _database.webCalSubscriptions.calendarSourceId, + ), + ), + ])..orderBy([OrderingTerm.asc(_database.calendarSources.summary)])).get(); + return [ + for (final row in rows) + _entity( + row.readTable(_database.webCalSubscriptions), + row.readTable(_database.calendarSources), + ), + ]; + } + + Future addSubscription({ + required String subscriptionUrl, + String? localName, + String? color, + WebCalRefreshMode refreshMode = WebCalRefreshMode.automatic, + }) async { + final normalized = normalizeWebCalUri(subscriptionUrl); + final duplicate = + await (_database.select(_database.webCalSubscriptions)..where( + (row) => row.feedFingerprint.equals(normalized.fingerprint), + )) + .getSingleOrNull(); + if (duplicate != null) { + throw const WebCalSubscriptionException('WebCalDuplicateSubscription'); + } + final response = await _httpTransport.get(normalized.uri); + final candidate = _prepareAcceptedResponse( + response, + secretUris: {normalized.uri, response.finalUri}, + ); + final subscriptionId = _idFactory(); + final accountId = 'webcal-account-$subscriptionId'; + final sourceId = 'webcal-calendar-$subscriptionId'; + final name = _subscriptionName(localName); + final now = _nowUtc(); + final targetFingerprint = webCalUriFingerprint(response.finalUri); + final secret = WebCalSecretRecord( + normalizedSubscriptionUri: normalized.credentialUri, + validatorTargetUri: response.finalUri, + ); + await _secretStore.saveCredential(accountId, secret); + try { + await _database.transaction(() async { + await _database + .into(_database.accounts) + .insert( + AccountsCompanion.insert( + id: accountId, + provider: BusyProvider.webCal.storageValue, + authority: normalized.safeOrigin.toString(), + providerAccountId: normalized.fingerprint, + credentialKind: CredentialKind.webCalSubscription.storageValue, + displayName: Value(name), + email: const Value(null), + tenantId: const Value(null), + authState: const Value(accountAuthStateSignedIn), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: now.toIso8601String(), + updatedAtUtc: now.toIso8601String(), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + ), + ); + await _database + .into(_database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: sourceId, + accountId: accountId, + provider: BusyProvider.webCal.storageValue, + providerCalendarId: subscriptionId, + summary: name, + primaryCalendar: const Value(false), + selected: const Value(true), + hidden: const Value(false), + readOnly: const Value(true), + backgroundColor: Value(_normalizedColor(color)), + accessRole: const Value('reader'), + rawJson: Value(jsonEncode(const {'transport': 'webcal'})), + createdAtLocal: now.millisecondsSinceEpoch, + updatedAtLocal: now.millisecondsSinceEpoch, + ), + ); + await _database + .into(_database.webCalSubscriptions) + .insert( + WebCalSubscriptionsCompanion.insert( + id: subscriptionId, + accountId: accountId, + calendarSourceId: sourceId, + feedFingerprint: normalized.fingerprint, + safeOrigin: normalized.safeOrigin.toString(), + validatorTargetFingerprint: Value(targetFingerprint), + etag: Value(response.etag), + lastModified: Value(response.lastModified), + contentType: Value(response.contentType), + snapshotIcsBody: candidate.snapshot, + rawBodyHash: candidate.rawBodyHash, + semanticHash: candidate.semanticHash, + refreshMode: refreshMode.storageValue, + serverRefreshIntervalSeconds: Value( + candidate.ingestion.serverRefreshIntervalSeconds, + ), + nextRefreshAtUtc: _nextRefresh( + now, + mode: refreshMode, + serverSeconds: + candidate.ingestion.serverRefreshIntervalSeconds, + subscriptionId: subscriptionId, + ).toIso8601String(), + lastCheckedAtUtc: Value(now.toIso8601String()), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + lastChangedAtUtc: Value(now.toIso8601String()), + parserVersion: const Value(webCalParserVersion), + projectionVersion: const Value(webCalProjectionVersion), + createdAtUtc: now.toIso8601String(), + updatedAtUtc: now.toIso8601String(), + ), + ); + await _replaceEvents( + subscriptionId: subscriptionId, + accountId: accountId, + sourceId: sourceId, + color: _normalizedColor(color), + projections: candidate.projections, + now: now, + ); + }); + } on Object { + await _secretStore.deleteCredential(accountId); + rethrow; + } + await _rebuildNotifications(accountId); + return subscriptionId; + } + + Future refreshSubscription( + String subscriptionId, { + bool force = false, + }) { + final previous = _refreshTails[subscriptionId] ?? Future.value(); + final next = previous + .catchError((Object _) {}) + .then((_) => _refreshSubscription(subscriptionId, force: force)); + _refreshTails[subscriptionId] = next; + return next.whenComplete(() { + if (identical(_refreshTails[subscriptionId], next)) { + _refreshTails.remove(subscriptionId); + } + }); + } + + Future refreshAccount(String accountId, {required bool force}) async { + final subscription = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.accountId.equals(accountId))).getSingleOrNull(); + if (subscription == null) return; + await refreshSubscription(subscription.id, force: force); + } + + Future _refreshSubscription( + String subscriptionId, { + required bool force, + }) async { + final subscription = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscriptionId))).getSingleOrNull(); + if (subscription == null) return; + final now = _nowUtc(); + final due = DateTime.tryParse(subscription.nextRefreshAtUtc)?.toUtc(); + if (!force && due != null && due.isAfter(now)) return; + final secret = await _secretStore.readCredential(subscription.accountId); + if (secret is! WebCalSecretRecord) { + await _recordFailure( + subscription, + const WebCalSubscriptionException('WebCalSecretUnavailable'), + ); + return; + } + final target = secret.validatorTargetUri; + final subscriptionUri = Uri.parse(secret.normalizedSubscriptionUri); + final targetFingerprint = target == null + ? null + : webCalUriFingerprint(target); + final validatorsBound = + target != null && + targetFingerprint == subscription.validatorTargetFingerprint; + try { + final response = await _httpTransport.get( + validatorsBound ? target : subscriptionUri, + validators: validatorsBound + ? WebCalHttpValidators( + etag: subscription.etag, + lastModified: subscription.lastModified, + ) + : const WebCalHttpValidators(), + validatorTarget: validatorsBound ? target : null, + ); + if (response.statusCode == 304) { + if (!response.conditionalRequestSent || + !validatorsBound || + response.finalUri != target || + subscription.snapshotIcsBody.isEmpty) { + throw const WebCalSubscriptionException('WebCalInvalidNotModified'); + } + await _database.transaction(() async { + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + etag: Value(response.etag ?? subscription.etag), + lastModified: Value( + response.lastModified ?? subscription.lastModified, + ), + lastCheckedAtUtc: Value(now.toIso8601String()), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + consecutiveFailureCount: const Value(0), + lastFailureCode: const Value(null), + lastFailureHttpStatus: const Value(null), + nextRefreshAtUtc: Value( + _nextRefresh( + now, + mode: WebCalRefreshModeStorage.parse( + subscription.refreshMode, + ), + serverSeconds: subscription.serverRefreshIntervalSeconds, + subscriptionId: subscription.id, + ).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + await (_database.update( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).write( + AccountsCompanion( + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + }); + return; + } + final candidate = _prepareAcceptedResponse( + response, + secretUris: {subscriptionUri, response.finalUri}, + ); + final source = + await (_database.select(_database.calendarSources) + ..where((row) => row.id.equals(subscription.calendarSourceId))) + .getSingle(); + final nextSecret = secret.copyWithValidatorTarget(response.finalUri); + await _secretStore.saveCredential(subscription.accountId, nextSecret); + try { + await _database.transaction(() async { + await _replaceEvents( + subscriptionId: subscription.id, + accountId: subscription.accountId, + sourceId: subscription.calendarSourceId, + color: source.backgroundColor, + projections: candidate.projections, + now: now, + ); + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + validatorTargetFingerprint: Value( + webCalUriFingerprint(response.finalUri), + ), + etag: Value(response.etag), + lastModified: Value(response.lastModified), + contentType: Value(response.contentType), + snapshotIcsBody: Value(candidate.snapshot), + rawBodyHash: Value(candidate.rawBodyHash), + semanticHash: Value(candidate.semanticHash), + serverRefreshIntervalSeconds: Value( + candidate.ingestion.serverRefreshIntervalSeconds, + ), + lastCheckedAtUtc: Value(now.toIso8601String()), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + lastChangedAtUtc: Value( + candidate.semanticHash == subscription.semanticHash + ? subscription.lastChangedAtUtc + : now.toIso8601String(), + ), + consecutiveFailureCount: const Value(0), + lastFailureCode: const Value(null), + lastFailureHttpStatus: const Value(null), + generation: Value(subscription.generation + 1), + nextRefreshAtUtc: Value( + _nextRefresh( + now, + mode: WebCalRefreshModeStorage.parse( + subscription.refreshMode, + ), + serverSeconds: + candidate.ingestion.serverRefreshIntervalSeconds, + subscriptionId: subscription.id, + ).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + await (_database.update( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).write( + AccountsCompanion( + authState: const Value(accountAuthStateSignedIn), + lastSuccessfulSyncAtUtc: Value(now.toIso8601String()), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + }); + } on Object { + await _secretStore.saveCredential(subscription.accountId, secret); + rethrow; + } + await _rebuildNotifications(subscription.accountId); + } on Object catch (error) { + await _recordFailure(subscription, error); + rethrow; + } + } + + Future renameSubscription(String subscriptionId, String name) async { + final trimmed = _subscriptionName(name); + final subscription = await _subscription(subscriptionId); + final now = _nowUtc(); + await _database.transaction(() async { + await (_database.update( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).write( + AccountsCompanion( + displayName: Value(trimmed), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(subscription.calendarSourceId))).write( + CalendarSourcesCompanion( + summary: Value(trimmed), + updatedAtLocal: Value(now.millisecondsSinceEpoch), + ), + ); + }); + } + + Future changeSubscriptionColor( + String subscriptionId, + String? color, + ) async { + final subscription = await _subscription(subscriptionId); + final normalized = _normalizedColor(color); + final now = _nowUtc().millisecondsSinceEpoch; + await _database.transaction(() async { + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(subscription.calendarSourceId))).write( + CalendarSourcesCompanion( + backgroundColor: Value(normalized), + updatedAtLocal: Value(now), + ), + ); + await (_database.update(_database.calendarEvents)..where( + (row) => row.calendarSourceId.equals(subscription.calendarSourceId), + )) + .write( + CalendarEventsCompanion( + colorHex: Value(normalized), + updatedAtLocal: Value(now), + ), + ); + }); + } + + Future changeRefreshMode( + String subscriptionId, + WebCalRefreshMode mode, + ) async { + final subscription = await _subscription(subscriptionId); + final now = _nowUtc(); + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + refreshMode: Value(mode.storageValue), + nextRefreshAtUtc: Value( + _nextRefresh( + now, + mode: mode, + serverSeconds: subscription.serverRefreshIntervalSeconds, + subscriptionId: subscription.id, + ).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + } + + Future unsubscribe(String subscriptionId) async { + final subscription = await _subscription(subscriptionId); + final secret = await _secretStore.readCredential(subscription.accountId); + await _secretStore.deleteCredential(subscription.accountId); + try { + await _database.transaction(() async { + await (_database.delete( + _database.accounts, + )..where((row) => row.id.equals(subscription.accountId))).go(); + }); + } on Object { + if (secret != null) { + await _secretStore.saveCredential(subscription.accountId, secret); + } + rethrow; + } + await _onNotificationScheduleChanged?.call(); + } + + Future _subscription(String id) async { + final value = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + if (value == null) { + throw const WebCalSubscriptionException('WebCalSubscriptionNotFound'); + } + return value; + } + + _PreparedCandidate _prepareAcceptedResponse( + WebCalHttpResponse response, { + required Iterable secretUris, + }) { + if (response.finalUri.scheme != 'https' || + response.finalUri.host.isEmpty || + response.finalUri.userInfo.isNotEmpty || + response.finalUri.hasFragment) { + throw const WebCalSubscriptionException('WebCalUnsafeResponseTarget'); + } + if (response.statusCode != 200 || response.body == null) { + throw WebCalSubscriptionException( + 'WebCalHttpStatus', + httpStatus: response.statusCode, + ); + } + final contentType = response.contentType + ?.split(';') + .first + .trim() + .toLowerCase(); + if (contentType == 'text/html' || + contentType == 'application/json' || + (contentType != null && + contentType.isNotEmpty && + contentType != 'text/calendar' && + contentType != 'text/plain' && + contentType != 'application/octet-stream')) { + throw const WebCalSubscriptionException('WebCalContentTypeRejected'); + } + final body = response.body!; + final leading = utf8 + .decode( + body.take(math.min(body.length, 64)).toList(), + allowMalformed: true, + ) + .trimLeft() + .toLowerCase(); + if (leading.startsWith('[]; + try { + for (final set in ingestion.recurrenceSets) { + projections.addAll( + _eventProjector.project( + set, + rangeStartUtc: rangeStart, + rangeEndUtc: rangeEnd, + transport: 'webcal', + ), + ); + } + } on DavException catch (error) { + throw WebCalSubscriptionException(error.code); + } + return _PreparedCandidate( + ingestion: ingestion, + projections: projections, + snapshot: snapshot, + rawBodyHash: sha256.convert(body).toString(), + semanticHash: sha256.convert(utf8.encode(snapshot)).toString(), + ); + } + + Future _replaceEvents({ + required String subscriptionId, + required String accountId, + required String sourceId, + required String? color, + required List projections, + required DateTime now, + }) async { + await (_database.delete( + _database.calendarEvents, + )..where((row) => row.calendarSourceId.equals(sourceId))).go(); + final timestamp = now.millisecondsSinceEpoch; + for (final projected in projections) { + final itemId = _stableId( + 'webcal-item', + '$subscriptionId\u0000${projected.uid}', + ); + final eventId = _stableId( + 'webcal-event', + '$itemId\u0000${_webCalOccurrenceIdentity(projected)}', + ); + await _database + .into(_database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: eventId, + accountId: accountId, + calendarSourceId: sourceId, + provider: BusyProvider.webCal.storageValue, + providerCalendarId: subscriptionId, + providerEventId: itemId, + icalUid: Value(projected.uid), + recurrenceIdKey: Value(projected.recurrenceIdKey), + occurrenceKey: Value(projected.occurrenceKey), + projectionVersion: const Value(webCalProjectionVersion), + providerRecurringEventId: Value( + projected.recurring ? itemId : null, + ), + providerOriginalStartKey: Value(projected.occurrenceKey), + status: Value(projected.status), + title: projected.title, + description: Value(projected.description), + location: Value(projected.location), + allDay: Value(projected.allDay), + startDate: Value(projected.startDate), + startDateTime: Value(projected.startDateTime), + startTimeZone: Value(projected.startTimeZone), + endDate: Value(projected.endDate), + endDateTime: Value(projected.endDateTime), + endTimeZone: Value(projected.endTimeZone), + recurrenceJson: Value(projected.recurrenceJson), + remindersJson: Value(projected.remindersJson), + attendeesJson: Value(projected.attendeesJson), + categoriesJson: Value(projected.categoriesJson), + organizerJson: Value(projected.organizerJson), + colorHex: Value(color), + visibility: Value(projected.visibility), + transparencyOrShowAs: Value(projected.transparency), + webLink: Value(projected.webLink), + attachmentsJson: Value(projected.attachmentsJson), + isCancelled: Value(projected.cancelled), + isDeleted: const Value(false), + rawJson: Value(projected.rawJson), + createdAtLocal: timestamp, + updatedAtLocal: timestamp, + syncStatus: const Value('synced'), + baselineRawJson: Value(projected.rawJson), + ), + ); + } + } + + Future _recordFailure( + WebCalSubscription subscription, + Object error, + ) async { + final now = _nowUtc(); + final failures = subscription.consecutiveFailureCount + 1; + final code = switch (error) { + WebCalHttpException(:final code) => code, + WebCalSubscriptionException(:final code) => code, + IcalIngestionException(:final code) => code, + _ => 'WebCalRefreshFailed', + }; + final status = switch (error) { + WebCalHttpException(:final httpStatus) => httpStatus, + WebCalSubscriptionException(:final httpStatus) => httpStatus, + _ => null, + }; + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + lastCheckedAtUtc: Value(now.toIso8601String()), + consecutiveFailureCount: Value(failures), + lastFailureCode: Value(code), + lastFailureHttpStatus: Value(status), + nextRefreshAtUtc: Value( + now.add(_failureBackoff(failures)).toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + } + + Future _rebuildNotifications(String accountId) async { + await _notificationScheduleService.rebuildUpcomingEventNotifications( + accountId, + ); + await _onNotificationScheduleChanged?.call(); + } +} + +WebCalSubscriptionEntity _entity( + WebCalSubscription subscription, + CalendarSource source, +) => WebCalSubscriptionEntity( + id: subscription.id, + accountId: subscription.accountId, + calendarSourceId: subscription.calendarSourceId, + name: source.summary, + color: source.backgroundColor, + safeOrigin: subscription.safeOrigin, + refreshMode: WebCalRefreshModeStorage.parse(subscription.refreshMode), + nextRefreshAtUtc: DateTime.parse(subscription.nextRefreshAtUtc).toUtc(), + lastSuccessfulSyncAtUtc: DateTime.tryParse( + subscription.lastSuccessfulSyncAtUtc ?? '', + )?.toUtc(), + lastFailureCode: subscription.lastFailureCode, + lastFailureHttpStatus: subscription.lastFailureHttpStatus, + consecutiveFailureCount: subscription.consecutiveFailureCount, +); + +final class _PreparedCandidate { + const _PreparedCandidate({ + required this.ingestion, + required this.projections, + required this.snapshot, + required this.rawBodyHash, + required this.semanticHash, + }); + + final IcalIngestionResult ingestion; + final List projections; + final String snapshot; + final String rawBodyHash; + final String semanticHash; +} + +String _subscriptionName(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Calendar subscription'; + if (trimmed.length > 200) { + throw const WebCalSubscriptionException('WebCalNameTooLong'); + } + return trimmed; +} + +String? _normalizedColor(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + if (!RegExp(r'^#[0-9A-Fa-f]{6}$').hasMatch(trimmed)) { + throw const WebCalSubscriptionException('WebCalColorInvalid'); + } + return trimmed.toUpperCase(); +} + +String _stableId(String prefix, String source) => + '$prefix-${sha256.convert(utf8.encode(source))}'; + +String _webCalOccurrenceIdentity(ProjectedIcalEvent event) => + event.recurring ? event.occurrenceKey : 'master'; + +DateTime _nextRefresh( + DateTime now, { + required WebCalRefreshMode mode, + required int? serverSeconds, + required String subscriptionId, +}) { + final selected = switch (mode) { + WebCalRefreshMode.automatic || + WebCalRefreshMode.sixHours => const Duration(hours: 6), + WebCalRefreshMode.hourly => const Duration(hours: 1), + WebCalRefreshMode.daily => const Duration(days: 1), + }; + final server = serverSeconds == null + ? Duration.zero + : Duration(seconds: serverSeconds); + final effective = [ + const Duration(hours: 1), + selected, + server, + ].reduce((left, right) => left > right ? left : right); + final seed = sha256.convert(utf8.encode(subscriptionId)).bytes.first; + final jitterSeconds = (effective.inSeconds * (seed / 255) * 0.1).round(); + return now.add(effective).add(Duration(seconds: jitterSeconds)); +} + +Duration _failureBackoff(int failures) { + final hours = math.min(24, 1 << math.min(4, math.max(0, failures - 1))); + return Duration(hours: hours); +} diff --git a/lib/src/webcal/webcal_uri.dart b/lib/src/webcal/webcal_uri.dart new file mode 100644 index 00000000..141fa4ce --- /dev/null +++ b/lib/src/webcal/webcal_uri.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +final class WebCalUriException implements FormatException { + const WebCalUriException(this.code); + + final String code; + + @override + String get message => 'The calendar subscription URL is not valid.'; + + @override + int? get offset => null; + + @override + String? get source => null; +} + +final class NormalizedWebCalUri { + const NormalizedWebCalUri._({ + required this.uri, + required this.credentialUri, + required this.safeOrigin, + required this.fingerprint, + }); + + final Uri uri; + final String credentialUri; + final Uri safeOrigin; + final String fingerprint; +} + +NormalizedWebCalUri normalizeWebCalUri(String input) { + final trimmed = input.trim(); + final parsed = Uri.tryParse(trimmed); + if (parsed == null) throw const WebCalUriException('WebCalUriMalformed'); + final scheme = parsed.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'webcal') { + throw const WebCalUriException('WebCalUriSchemeNotAllowed'); + } + if (parsed.host.isEmpty || parsed.userInfo.isNotEmpty || parsed.hasFragment) { + throw const WebCalUriException('WebCalUriAuthorityNotAllowed'); + } + final authorityStart = trimmed.indexOf('://') + 3; + final tailStart = _authorityEnd(trimmed, authorityStart); + final authority = trimmed.substring(authorityStart, tailStart); + final explicitPortText = _explicitPortText(authority); + final explicitPort = explicitPortText == null + ? null + : int.tryParse(explicitPortText); + if (explicitPortText != null && + (explicitPort == null || explicitPort < 1 || explicitPort > 65535)) { + throw const WebCalUriException('WebCalUriAuthorityNotAllowed'); + } + final normalizedAuthority = parsed.host.contains(':') + ? '[${parsed.host.toLowerCase()}]' + : parsed.host.toLowerCase(); + final normalizedText = + 'https://$normalizedAuthority' + '${explicitPort == null ? '' : ':$explicitPort'}' + '${trimmed.substring(tailStart)}'; + final normalized = Uri.parse(normalizedText); + final safeOrigin = Uri( + scheme: 'https', + host: normalized.host.toLowerCase(), + port: normalized.hasPort && normalized.port != 443 ? normalized.port : null, + ); + return NormalizedWebCalUri._( + uri: normalized, + credentialUri: normalizedText, + safeOrigin: safeOrigin, + fingerprint: webCalTextFingerprint(normalizedText), + ); +} + +String webCalUriFingerprint(Uri uri) => + sha256.convert(utf8.encode(uri.toString())).toString(); + +String webCalTextFingerprint(String uri) => + sha256.convert(utf8.encode(uri)).toString(); + +int _authorityEnd(String value, int start) { + for (var index = start; index < value.length; index += 1) { + final code = value.codeUnitAt(index); + if (code == 0x2f || code == 0x3f || code == 0x23) return index; + } + return value.length; +} + +String? _explicitPortText(String authority) { + if (authority.startsWith('[')) { + final end = authority.indexOf(']'); + if (end < 0 || end == authority.length - 1) return null; + if (authority.codeUnitAt(end + 1) != 0x3a) return null; + return authority.substring(end + 2); + } + final separator = authority.lastIndexOf(':'); + return separator < 0 ? null : authority.substring(separator + 1); +} diff --git a/linux/io.busystack.busymax.desktop b/linux/io.busystack.busymax.desktop index 7499fc66..d8a0a2c2 100644 --- a/linux/io.busystack.busymax.desktop +++ b/linux/io.busystack.busymax.desktop @@ -36,9 +36,10 @@ Name[zh_Hans]=BusyMax Comment[zh_Hans]=日历与任务管理工具 Name[zh_Hant]=BusyMax Comment[zh_Hant]=行事曆與待辦事項管理工具 -Exec=busymax +Exec=busymax %U Icon=io.busystack.busymax Terminal=false +MimeType=text/calendar;x-scheme-handler/webcal; Categories=Office;Calendar;ProjectManagement; StartupNotify=true StartupWMClass=io.busystack.busymax diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 4133a5c2..8d11899a 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -66,6 +66,9 @@ BusyStack io.busystack.busymax.desktop + + text/calendar + io.busystack.busymax Office diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index e2d24e8f..39e9e27d 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -18,6 +18,8 @@ constexpr char kNativeMenuChannel[] = "busymax/native_menus"; constexpr char kWindowChannel[] = "io.busystack.busymax/window"; constexpr char kHeaderBarChannel[] = "io.busystack.busymax/headerbar"; constexpr char kGtkSettingsChannel[] = "io.busystack.busymax/gtk_settings"; +constexpr char kExternalCalendarOpenChannel[] = + "io.busystack.busymax/external_calendar_open"; constexpr char kGtkFontSettingsEventChannel[] = "io.busystack.busymax/gtk_font_settings"; constexpr char kGtkThemeColorsEventChannel[] = @@ -98,6 +100,9 @@ struct _MyApplication { FlMethodChannel* window_channel; FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; + FlMethodChannel* external_calendar_open_channel; + GQueue* pending_external_opens; + gboolean external_calendar_open_ready; FlEventChannel* gtk_font_settings_event_channel; FlEventChannel* gtk_theme_colors_event_channel; gulong gtk_font_settings_signal_id; @@ -203,6 +208,19 @@ struct _MyApplication { gint header_onboarding_content_width; }; +struct PendingExternalOpen { + gchar* kind; + gchar* value; +}; + +static void pending_external_open_free(gpointer data) { + auto* item = static_cast(data); + if (item == nullptr) return; + g_free(item->kind); + g_free(item->value); + g_free(item); +} + G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) static void schedule_header_bar_focus_state_refresh(MyApplication* self); @@ -4895,6 +4913,77 @@ static void window_method_call_cb(FlMethodChannel* channel, } } +static void flush_external_calendar_opens(MyApplication* self) { + if (!self->external_calendar_open_ready || + self->external_calendar_open_channel == nullptr || + self->pending_external_opens == nullptr) { + return; + } + while (!g_queue_is_empty(self->pending_external_opens)) { + auto* item = static_cast( + g_queue_pop_head(self->pending_external_opens)); + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "kind", fl_value_new_string(item->kind)); + fl_value_set_string_take(args, "value", fl_value_new_string(item->value)); + fl_method_channel_invoke_method(self->external_calendar_open_channel, + "openItem", args, nullptr, nullptr, + nullptr); + pending_external_open_free(item); + } +} + +static void external_calendar_open_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + auto* self = MY_APPLICATION(user_data); + const gchar* method = fl_method_call_get_name(method_call); + if (g_strcmp0(method, "ready") != 0) { + fl_method_call_respond_not_implemented(method_call, nullptr); + return; + } + self->external_calendar_open_ready = TRUE; + fl_method_call_respond_success(method_call, nullptr, nullptr); + flush_external_calendar_opens(self); +} + +static void register_external_calendar_open_channel(MyApplication* self, + FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->external_calendar_open_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kExternalCalendarOpenChannel, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + self->external_calendar_open_channel, + external_calendar_open_method_call_cb, self, nullptr); +} + +static void queue_external_calendar_open(MyApplication* self, + const gchar* kind, + const gchar* value) { + if (kind == nullptr || value == nullptr || value[0] == '\0') return; + auto* item = g_new0(PendingExternalOpen, 1); + item->kind = g_strdup(kind); + item->value = g_strdup(value); + g_queue_push_tail(self->pending_external_opens, item); + flush_external_calendar_opens(self); +} + +static gboolean queue_supported_external_file(MyApplication* self, + GFile* file) { + g_autofree gchar* uri = g_file_get_uri(file); + if (uri != nullptr && g_ascii_strncasecmp(uri, "webcal:", 7) == 0) { + queue_external_calendar_open(self, "webcal", uri); + return TRUE; + } + if (!g_file_is_native(file)) return FALSE; + g_autofree gchar* path = g_file_get_path(file); + if (path == nullptr) return FALSE; + g_autofree gchar* lower = g_ascii_strdown(path, -1); + if (!g_str_has_suffix(lower, ".ics")) return FALSE; + queue_external_calendar_open(self, "ics", path); + return TRUE; +} + static void register_window_channel(MyApplication* self, FlView* view) { g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); self->window_channel = fl_method_channel_new( @@ -4973,6 +5062,7 @@ static void my_application_activate(GApplication* application) { register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); register_native_menus(self, view); + register_external_calendar_open_channel(self, view); register_window_channel(self, view); register_header_bar_channel(self, view); register_gtk_settings_channel(self, view); @@ -4981,19 +5071,49 @@ static void my_application_activate(GApplication* application) { schedule_header_bar_focus_state_refresh(self); } +// Implements GApplication::open. +static void my_application_open(GApplication* application, + GFile** files, + gint file_count, + const gchar*) { + MyApplication* self = MY_APPLICATION(application); + gboolean accepted = FALSE; + for (gint index = 0; index < file_count; index++) { + accepted = queue_supported_external_file(self, files[index]) || accepted; + } + if (!accepted) { + if (self->main_window == nullptr) { + g_application_activate(application); + } + return; + } + self->start_minimized = FALSE; + if (self->main_window == nullptr) { + g_application_activate(application); + } else { + restore_main_window(self); + } +} + // Implements GApplication::local_command_line. static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); self->start_minimized = FALSE; + g_autoptr(GPtrArray) files = g_ptr_array_new_with_free_func(g_object_unref); for (gchar** argument = *arguments + 1; *argument != nullptr; argument++) { if (g_strcmp0(*argument, "--start-minimized") == 0) { self->start_minimized = TRUE; - break; + continue; } + if ((*argument)[0] == '-') continue; + g_ptr_array_add(files, g_file_new_for_commandline_arg(*argument)); + } + self->dart_entrypoint_arguments = g_new0(gchar*, 2); + if (self->start_minimized) { + self->dart_entrypoint_arguments[0] = g_strdup("--start-minimized"); } g_autoptr(GError) error = nullptr; @@ -5003,7 +5123,12 @@ static gboolean my_application_local_command_line(GApplication* application, return TRUE; } - g_application_activate(application); + if (files->len > 0) { + g_application_open( + application, reinterpret_cast(files->pdata), files->len, ""); + } else { + g_application_activate(application); + } *exit_status = 0; return TRUE; @@ -5040,6 +5165,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); g_clear_object(&self->gtk_settings_channel); + g_clear_object(&self->external_calendar_open_channel); disconnect_gtk_font_settings_signal(self); g_clear_object(&self->gtk_font_settings_event_channel); g_clear_object(&self->gtk_theme_colors_event_channel); @@ -5129,11 +5255,17 @@ static void my_application_dispose(GObject* object) { g_clear_pointer(&self->header_keyboard_shortcuts_shortcut, g_free); g_clear_pointer(&self->header_search_query, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + if (self->pending_external_opens != nullptr) { + g_queue_free_full( + self->pending_external_opens, pending_external_open_free); + self->pending_external_opens = nullptr; + } G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->open = my_application_open; G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; G_APPLICATION_CLASS(klass)->startup = my_application_startup; @@ -5149,6 +5281,9 @@ static void my_application_init(MyApplication* self) { self->window_channel = nullptr; self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; + self->external_calendar_open_channel = nullptr; + self->pending_external_opens = g_queue_new(); + self->external_calendar_open_ready = FALSE; self->gtk_font_settings_event_channel = nullptr; self->gtk_theme_colors_event_channel = nullptr; self->gtk_font_settings_signal_id = 0; @@ -5266,6 +5401,6 @@ MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", - static_cast(0), + G_APPLICATION_HANDLES_OPEN, nullptr)); } diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 3e32ecd1..1cde78d3 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -290,24 +290,23 @@ void main() { }, ); - test('tray DBus menu labels come from injected labels', () { + test('tray DBus menu labels come from its injected presentation', () { final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); final logo = File('assets/branding/busymax-logo.svg').readAsStringSync(); - expect(source, contains('class BusyMaxTrayLabels')); + expect(source, contains('BusyMaxTrayMenuPresentation presentation')); expect(source, contains('buildBusyMaxTrayMenu')); + expect(source, contains('label: presentation.showBusyMaxLabel')); + expect(source, contains('label: presentation.todayLabel')); + expect(source, contains('static const orderedChildren')); expect(source, contains('busyMaxApplicationId')); expect(source, contains('io.busystack.busymax')); expect(source, contains('assets/branding/busymax-logo.svg')); expect(logo, contains('width="512" height="512"')); expect(logo, contains('viewBox="106 108 300 300"')); expect(logo, isNot(contains('viewBox="254 120 232 272"'))); - expect(source, isNot(contains('BusyMaxTrayAgendaSnapshot'))); - expect(source, isNot(contains('BusyMaxTrayAgendaEntry'))); - expect(source, isNot(contains('_buildAgendaSubmenuItems'))); - expect(source, isNot(contains('busyMaxTrayAgendaSlotCount'))); expect(source, isNot(contains("iconName: 'busymax-symbolic'"))); expect(source, isNot(contains("label: 'Show BusyMax'"))); expect(source, isNot(contains("label: 'Today'"))); @@ -383,7 +382,12 @@ void main() { expect(app, contains("ref.read(appRouterProvider).go('/schedule')")); expect(commands, contains('agenda,')); expect(workspace, contains('case ScheduleWorkspaceCommandKind.agenda:')); - expect(workspace, contains('_setMode(ScheduleViewMode.agenda);')); + expect( + workspace, + contains( + '_setMode(ScheduleViewMode.agenda, agendaDate: command.date);', + ), + ); }); test('native headerbar keeps sidebar branded with GTK-owned centering', () { diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index b9f3c6c9..b8805b72 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -11,6 +11,7 @@ import 'package:yaru/theme.dart'; import 'package:yaru/widgets.dart' show YaruInfoType; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/app_router.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/app/app_theme.dart'; import 'package:busymax/src/app/busymax_app.dart'; @@ -18,6 +19,8 @@ import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/sync/all_accounts_sync_scheduler.dart'; +import 'package:busymax/src/features/tray/domain/tray_presentation.dart'; import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; @@ -1860,7 +1863,7 @@ void main() { addTearDown(database.close); final store = _DelayedSettingsStore(); final windowService = _RecordingWindowService(); - final trayService = _RecordingTrayService(windowService); + _RecordingTrayService? trayService; await tester.pumpWidget( ProviderScope( @@ -1871,18 +1874,14 @@ void main() { linuxWindowServiceProvider.overrideWithValue(windowService), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService, + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); await tester.pump(); - expect(trayService.startCalls, 0); + expect(trayService?.startCalls ?? 0, 0); expect(windowService.hideWindowCalls, 0); store.completeLoad({ @@ -1893,13 +1892,13 @@ void main() { await tester.pump(); await tester.pump(); - expect(trayService.startCalls, 1); + expect(trayService!.startCalls, 1); expect(windowService.hideWindowCalls, 1); // A rebuild with the same loaded settings must not restart the tray or // hide an already-running window a second time. await tester.pump(); - expect(trayService.startCalls, 1); + expect(trayService!.startCalls, 1); expect(windowService.hideWindowCalls, 1); }); @@ -1920,15 +1919,8 @@ void main() { linuxWindowServiceProvider.overrideWithValue(windowService), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService = _RecordingTrayService( - windowService, - openAgendaCallback: onOpenAgenda, - ), + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); @@ -1944,13 +1936,113 @@ void main() { final command = container.read(scheduleWorkspaceCommandProvider); expect(windowService.showWindowCalls, 1); expect(command?.kind, ScheduleWorkspaceCommandKind.agenda); + expect(command?.date, isNotNull); + expect(command!.date!.year, DateTime.now().year); + expect(command.date!.month, DateTime.now().month); + expect(command.date!.day, DateTime.now().day); + }); + + testWidgets('tray application actions use shared routing and commands', ( + tester, + ) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + var syncEnumerations = 0; + final syncScheduler = AllAccountsSyncScheduler( + listSyncEligibleAccounts: () async { + syncEnumerations += 1; + return const []; + }, + syncAccount: (_) async {}, + onSyncFailure: (_) async {}, + interval: Duration.zero, + ); + addTearDown(syncScheduler.dispose); + late _RecordingTrayService trayService; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + syncSchedulerProvider.overrideWithValue(syncScheduler), + ], + child: BusyMaxApp( + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), + ), + ), + ); + await tester.pump(); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(BusyMaxApp)), + ); + final router = container.read(appRouterProvider); + final initialRoute = router.state.uri.toString(); + + await trayService.configuration.actions.showBusyMax(); + expect(router.state.uri.toString(), initialRoute); + + await trayService.configuration.actions.newEvent(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.newEvent, + ); + + await trayService.configuration.actions.newTask(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.newTask, + ); + + final event = BusyMaxTrayEventEntry( + eventId: 'event-id', + accountId: 'account-id', + calendarSourceId: 'source-id', + title: 'Event', + start: DateTime(2026, 8, 29, 10), + end: DateTime(2026, 8, 29, 11), + allDay: false, + ); + await trayService.configuration.actions.openEvent(event); + final openEvent = container.read(scheduleWorkspaceCommandProvider); + expect(openEvent?.kind, ScheduleWorkspaceCommandKind.openCalendarEvent); + expect(openEvent?.itemId, 'event-id'); + expect(openEvent?.accountId, 'account-id'); + expect(openEvent?.sourceId, 'source-id'); + + await trayService.configuration.actions.openTasksDueToday(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.agenda, + ); + await trayService.configuration.actions.openTodayAgenda(); + expect( + container.read(scheduleWorkspaceCommandProvider)?.kind, + ScheduleWorkspaceCommandKind.agenda, + ); + + await trayService.configuration.actions.openSettings(); + expect(router.state.uri.path, '/settings'); + + final showsBeforeSync = windowService.showWindowCalls; + await trayService.configuration.actions.synchronize(); + expect(syncEnumerations, 1); + expect(windowService.showWindowCalls, showsBeforeSync); + + await trayService.configuration.actions.quitBusyMax(); + expect(windowService.quitAppCalls, 1); }); testWidgets('demo mode retains the local tray entry', (tester) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); final windowService = _RecordingWindowService(); - final trayService = _RecordingTrayService(windowService); + late _RecordingTrayService trayService; await tester.pumpWidget( ProviderScope( @@ -1961,12 +2053,8 @@ void main() { linuxWindowServiceProvider.overrideWithValue(windowService), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService, + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); @@ -1995,7 +2083,7 @@ void main() { await changes.close(); }); final windowService = _RecordingWindowService(); - final trayService = _RecordingTrayService(windowService); + late _RecordingTrayService trayService; await tester.pumpWidget( ProviderScope( @@ -2007,24 +2095,73 @@ void main() { networkConnectivityMonitorProvider.overrideWithValue(monitor), ], child: BusyMaxApp( - trayServiceFactory: - ({ - required windowService, - required labels, - required onOpenAgenda, - }) => trayService, + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), ), ), ); await tester.pump(); await tester.pump(); expect(trayService.offlineStates, contains(true)); + final refreshCallsBeforeConnectivityChange = trayService.refreshCalls; changes.add([ConnectivityResult.wifi]); + for (var attempt = 0; attempt < 10; attempt += 1) { + await tester.pump(const Duration(milliseconds: 10)); + if (trayService.offlineStates.isNotEmpty && + trayService.offlineStates.last == false) { + break; + } + } + + expect( + trayService.refreshCalls, + greaterThan(refreshCallsBeforeConnectivityChange), + ); + final currentPresentation = await tester.runAsync( + trayService.configuration.loadPresentation, + ); + expect(currentPresentation!.offline, isFalse); + }); + + testWidgets('tray refreshes after privacy and locale changes', ( + tester, + ) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + final windowService = _RecordingWindowService(); + late _RecordingTrayService trayService; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxWindowServiceProvider.overrideWithValue(windowService), + ], + child: BusyMaxApp( + trayServiceFactory: (configuration) => + trayService = _RecordingTrayService(configuration), + ), + ), + ); await tester.pump(); await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(BusyMaxApp)), + ); + final settings = container.read(appSettingsControllerProvider.notifier); - expect(trayService.offlineStates.last, isFalse); + var previousRefreshCalls = trayService.refreshCalls; + await settings.setNotificationDetailLevel(NotificationDetailLevel.private); + await tester.pump(); + expect(trayService.refreshCalls, greaterThan(previousRefreshCalls)); + + previousRefreshCalls = trayService.refreshCalls; + await settings.setLocaleTag('de'); + await tester.pump(); + expect(trayService.refreshCalls, greaterThan(previousRefreshCalls)); }); test('production sources avoid forbidden hardcoded accent colors', () { @@ -2324,6 +2461,7 @@ class _DelayedSettingsStore implements LocalSettingsStore { class _RecordingWindowService extends LinuxWindowService { var hideWindowCalls = 0; var showWindowCalls = 0; + var quitAppCalls = 0; @override Future hideWindow() async { @@ -2335,30 +2473,23 @@ class _RecordingWindowService extends LinuxWindowService { showWindowCalls += 1; } + @override + Future quitApp() async { + quitAppCalls += 1; + } + @override Future setHideOnClose(bool enabled) async {} } class _RecordingTrayService extends BusyMaxTrayService { - _RecordingTrayService( - LinuxWindowService windowService, { - Future Function() openAgendaCallback = _noop, - }) : _onOpenAgenda = openAgendaCallback, - super( - windowService: windowService, - labels: const BusyMaxTrayLabels( - openBusyMax: 'Open BusyMax', - agenda: 'Agenda', - quitBusyMax: 'Exit', - offline: 'Offline', - offlineDescription: 'Changes will sync when connected.', - ), - onOpenAgenda: openAgendaCallback, - ); - - final Future Function() _onOpenAgenda; + _RecordingTrayService(this.configuration) + : super(configuration: configuration); + + final BusyMaxTrayServiceConfiguration configuration; var startCalls = 0; + var refreshCalls = 0; var _available = false; final offlineStates = []; @@ -2369,6 +2500,7 @@ class _RecordingTrayService extends BusyMaxTrayService { Future start() async { startCalls += 1; _available = true; + offlineStates.add(configuration.initialPresentation.offline); } @override @@ -2377,18 +2509,16 @@ class _RecordingTrayService extends BusyMaxTrayService { } @override - Future updateLabels(BusyMaxTrayLabels labels) async {} - - @override - Future updateOfflineState(bool offline) async { - offlineStates.add(offline); + Future refreshPresentation() async { + refreshCalls += 1; + final presentation = await configuration.loadPresentation(); + offlineStates.add(presentation.offline); + return true; } - Future openAgenda() => _onOpenAgenda(); + Future openAgenda() => configuration.actions.openTodayAgenda(); } -Future _noop() async {} - const _missingConfig = BuildConfig( googleOAuthClientId: '', googleOAuthClientSecret: '', diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index 35e92e47..8c4b79a1 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -21,7 +21,7 @@ void main() { await database.close(); }); - test('opens schema version 10 and creates required indexes', () async { + test('opens schema version 11 and creates required indexes', () async { final version = await database .customSelect('PRAGMA user_version') .getSingle(); @@ -39,7 +39,7 @@ void main() { .customSelect('PRAGMA table_info(calendar_sources)') .get(); - expect(version.data['user_version'], 10); + expect(version.data['user_version'], 11); expect( taskColumns.map((row) => row.read('name')), contains('microsoft_checklist_items_json'), @@ -75,9 +75,67 @@ void main() { 'idx_sync_cursors_scope', 'idx_pending_ops_dav_replay', 'idx_notification_schedule_due', + 'idx_webcal_subscriptions_due', }); }); + test('schema 10 migration preserves accounts and enables WebCal', () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v10-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaTenDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaTenDatabase); + await schemaTenDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('DROP TABLE ical_import_receipts'); + raw.execute('DROP TABLE web_cal_subscriptions'); + raw.execute('PRAGMA user_version = 10'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final existing = await (database.select( + database.accounts, + )..where((row) => row.id.equals('account'))).getSingle(); + expect(existing.provider, 'google'); + expect( + (await database.customSelect('PRAGMA user_version').getSingle()) + .read('user_version'), + 11, + ); + + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'webcal-account-subscription', + provider: 'webcal', + authority: 'https://calendar.example.test', + providerAccountId: 'fingerprint', + credentialKind: 'webcal_subscription', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }); + test( 'schema 9 migration adds calendar ownership columns without data loss', () async { @@ -123,7 +181,7 @@ void main() { database.calendarSources, )..where((row) => row.id.equals('calendar'))).getSingle(); - expect(version.read('user_version'), 10); + expect(version.read('user_version'), 11); expect( columns.map((row) => row.read('name')), containsAll(['data_owner', 'is_removable']), @@ -323,7 +381,7 @@ void main() { final version = await database .customSelect('PRAGMA user_version') .getSingle(); - expect(version.read('user_version'), 10); + expect(version.read('user_version'), 11); final accounts = await database.select(database.accounts).get(); expect(accounts, hasLength(2)); @@ -524,7 +582,7 @@ void main() { .getSingle(); final op = await database.pendingOpsDao.getOp('op-1'); - expect(version.data['user_version'], 10); + expect(version.data['user_version'], 11); expect(op, isNot(equals(null))); expect(op!.baselineRawJson, equals(null)); diff --git a/test/features/accounts/data/accounts_repository_test.dart b/test/features/accounts/data/accounts_repository_test.dart index a429e285..fff2d973 100644 --- a/test/features/accounts/data/accounts_repository_test.dart +++ b/test/features/accounts/data/accounts_repository_test.dart @@ -1,5 +1,9 @@ import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -54,4 +58,64 @@ void main() { expect(account.selectorLabel, 'Google'); }); + + test( + 'subscription predicates separate auth, task, schedule, and sync use', + () { + const account = AccountEntity( + id: 'webcal-account-sub', + provider: BusyProvider.webCal, + authority: 'https://calendar.example.test', + providerAccountId: 'fingerprint', + credentialKind: CredentialKind.webCalSubscription, + authState: accountAuthStateSignedIn, + displayName: 'Subscription', + tasksEnabled: false, + ); + + expect(account.isSubscription, isTrue); + expect(account.isAuthenticationAccount, isFalse); + expect(account.isTaskCapable, isFalse); + expect(account.isScheduleVisible, isTrue); + expect(account.isSyncEligible, isTrue); + }, + ); + + test( + 'subscription is sync-visible but excluded from auth account queries', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'webcal-account-sub', + provider: 'webcal', + authority: 'https://calendar.example.test', + providerAccountId: 'fingerprint', + credentialKind: 'webcal_subscription', + authState: const Value(accountAuthStateSignedIn), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: '2026-08-29T00:00:00.000Z', + updatedAtUtc: '2026-08-29T00:00:00.000Z', + ), + ); + final repository = AccountsRepository(database: database); + + expect( + (await repository.watchAccounts().first).single.isSubscription, + isTrue, + ); + expect(await repository.watchVisibleAccounts().first, isEmpty); + expect(await repository.listVisibleAccounts(), isEmpty); + expect(await repository.listSignedInAccounts(), isEmpty); + expect( + (await repository.listSyncEligibleAccounts()).single.provider, + BusyProvider.webCal, + ); + }, + ); } diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index d2cb5c68..7b4898bb 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -625,12 +625,14 @@ Future _insertAccount( BusyProvider.google => 'https://accounts.google.com', BusyProvider.appleICloud => 'https://caldav.icloud.com', BusyProvider.nextcloud => 'https://cloud.example.com', + BusyProvider.webCal => 'https://calendar.example.com', }, providerAccountId: id, credentialKind: switch (provider) { BusyProvider.google || BusyProvider.microsoft => 'oauth', BusyProvider.appleICloud => 'apple_app_specific_password', BusyProvider.nextcloud => 'nextcloud_app_password', + BusyProvider.webCal => 'webcal_subscription', }, authState: const Value('signed_in'), grantedScopes: const Value(''), diff --git a/test/features/schedule/presentation/schedule_sidebar_provider_test.dart b/test/features/schedule/presentation/schedule_sidebar_provider_test.dart index 9105d6b2..dcc793e1 100644 --- a/test/features/schedule/presentation/schedule_sidebar_provider_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_provider_test.dart @@ -111,6 +111,7 @@ AccountEntity _account(BusyProvider provider) { BusyProvider.microsoft => 'https://login.microsoftonline.com/common', BusyProvider.appleICloud => 'https://caldav.icloud.com', BusyProvider.nextcloud => 'https://cloud.example.test/nextcloud', + BusyProvider.webCal => 'https://calendar.example.test', }, providerAccountId: 'account', authState: accountAuthStateSignedIn, diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 5de8919b..5b1bea90 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -3778,7 +3778,7 @@ void main() { expect(sidebar, contains('visibilityButton: _SourceVisibilityButton')); expect(sidebar, contains('menuButton: BusyMaxMenuButton')); expect(sidebar, contains('tooltip: context.l10n.options')); - expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(2)); + expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(3)); expect(sidebar, contains('value ? context.l10n.hide : context.l10n.show')); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceCalendar'))); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceTaskList'))); diff --git a/test/features/sync/account_sync_operations_test.dart b/test/features/sync/account_sync_operations_test.dart new file mode 100644 index 00000000..4551e597 --- /dev/null +++ b/test/features/sync/account_sync_operations_test.dart @@ -0,0 +1,36 @@ +import 'package:busymax/src/features/sync/account_sync_operations.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'routes providers explicitly and never creates a WebCal task sync', + () async { + var provider = BusyProvider.webCal; + final calls = []; + final operations = RoutingAccountSyncOperations( + providerForAccount: (_) async => provider, + syncDav: (_, {required full}) async => calls.add('dav:$full'), + syncWebCal: (_, {required full}) async => calls.add('webcal:$full'), + syncTasksRest: (_, {required full}) async => calls.add('tasks:$full'), + syncCalendarRest: (_, {required full}) async => + calls.add('calendar:$full'), + ); + + await operations.syncAccount('account', full: false); + await operations.syncCalendar('account', full: true); + await operations.syncTasks('account', full: true); + expect(calls, ['webcal:false', 'webcal:true']); + + calls.clear(); + provider = BusyProvider.nextcloud; + await operations.syncAccount('account', full: false); + expect(calls, ['dav:false']); + + calls.clear(); + provider = BusyProvider.google; + await operations.syncAccount('account', full: true); + expect(calls, ['tasks:true', 'calendar:true']); + }, + ); +} diff --git a/test/features/sync/sync_scheduler_test.dart b/test/features/sync/sync_scheduler_test.dart index 3530c9ee..5ab8aa0f 100644 --- a/test/features/sync/sync_scheduler_test.dart +++ b/test/features/sync/sync_scheduler_test.dart @@ -30,7 +30,7 @@ void main() { test('periodic all-account scheduler syncs all signed-in accounts', () async { final synced = []; final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async => [_account('a'), _account('b')], + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], syncAccount: (accountId) async { synced.add(accountId); }, @@ -39,7 +39,7 @@ void main() { ); scheduler.start(); - addTearDown(scheduler.stop); + addTearDown(scheduler.dispose); await _waitFor(() => synced.length >= 2); expect(synced.take(2), ['a', 'b']); @@ -63,7 +63,7 @@ void main() { test('all-account sync does not enumerate accounts while offline', () async { var listCalls = 0; final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async { + listSyncEligibleAccounts: () async { listCalls += 1; return [_account('a')]; }, @@ -72,6 +72,7 @@ void main() { canSync: () async => false, interval: Duration.zero, ); + addTearDown(scheduler.dispose); await scheduler.runNow(); @@ -83,8 +84,8 @@ void main() { final failure = StateError('a failed'); final failures = []; - await runAllSignedInAccountSync( - listSignedInAccounts: () async => [_account('a'), _account('b')], + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], syncAccount: (accountId) async { synced.add(accountId); if (accountId == 'a') { @@ -109,8 +110,8 @@ void main() { ); final failures = []; - await runAllSignedInAccountSync( - listSignedInAccounts: () async => [_account('a'), _account('b')], + await runAllSyncEligibleAccountSync( + listSyncEligibleAccounts: () async => [_account('a'), _account('b')], syncAccount: (accountId) async { synced.add(accountId); if (accountId == 'a') { @@ -136,7 +137,7 @@ void main() { var starts = 0; final release = Completer(); final scheduler = AllAccountsSyncScheduler( - listSignedInAccounts: () async => [_account('a')], + listSyncEligibleAccounts: () async => [_account('a')], syncAccount: (_) async { starts += 1; active += 1; @@ -149,7 +150,7 @@ void main() { ); scheduler.start(); - addTearDown(scheduler.stop); + addTearDown(scheduler.dispose); await _waitFor(() => starts == 1); await Future.delayed(const Duration(milliseconds: 20)); @@ -157,6 +158,31 @@ void main() { expect(maxActive, 1); release.complete(); }); + + test('all-account scheduler exposes its read-only running state', () async { + final release = Completer(); + final states = []; + final scheduler = AllAccountsSyncScheduler( + listSyncEligibleAccounts: () async => [_account('a')], + syncAccount: (_) => release.future, + onSyncFailure: (_) async {}, + interval: Duration.zero, + ); + final subscription = scheduler.watchRunning().listen(states.add); + addTearDown(() async { + await subscription.cancel(); + await scheduler.dispose(); + }); + + await _waitFor(() => states.isNotEmpty); + final run = scheduler.runNow(); + await _waitFor(() => scheduler.isRunning && states.contains(true)); + release.complete(); + await run; + + expect(scheduler.isRunning, isFalse); + expect(states, [false, true, false]); + }); } class _FailingSyncEngine implements SyncEngine { diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 680b2dde..2602e775 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -44,6 +44,7 @@ String _testAuthority(BusyProvider provider) => switch (provider) { BusyProvider.microsoft => 'https://login.microsoftonline.com/common', BusyProvider.appleICloud => 'https://caldav.icloud.com', BusyProvider.nextcloud => 'https://cloud.example.test', + BusyProvider.webCal => 'https://calendar.example.test', }; void main() { diff --git a/test/features/tray/tray_presentation_test.dart b/test/features/tray/tray_presentation_test.dart new file mode 100644 index 00000000..2dd163e0 --- /dev/null +++ b/test/features/tray/tray_presentation_test.dart @@ -0,0 +1,482 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tray/data/tray_presentation_service.dart'; +import 'package:busymax/src/features/tray/domain/tray_presentation.dart'; +import 'package:busymax/src/features/tray/domain/tray_presentation_formatter.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final now = DateTime(2026, 8, 29, 10); + + test('projects all-day, ongoing, later, and missing-end events', () { + final presentation = _project( + now: now, + scheduleItems: [ + _event('all-day', start: DateTime(2026, 8, 29), allDay: true), + _event( + 'ended', + start: DateTime(2026, 8, 29, 8), + end: DateTime(2026, 8, 29, 9), + ), + _event( + 'ongoing', + start: DateTime(2026, 8, 29, 9, 30), + end: DateTime(2026, 8, 29, 10, 30), + ), + _event('missing-end-past', start: DateTime(2026, 8, 29, 9, 45)), + _event('later', start: DateTime(2026, 8, 29, 11)), + _event('missing-end-future', start: DateTime(2026, 8, 29, 12)), + ], + ); + + expect(presentation.events.map((event) => event.eventId), [ + 'all-day', + 'ongoing', + 'later', + ]); + }); + + test('preserves schedule ordering and limits event rows to three', () { + final presentation = _project( + now: now, + scheduleItems: [ + _event('z-first', start: DateTime(2026, 8, 29, 10, 5)), + _event('a-second', start: DateTime(2026, 8, 29, 10, 10)), + _event('third', start: DateTime(2026, 8, 29, 10, 15)), + _event('fourth', start: DateTime(2026, 8, 29, 10, 20)), + ], + ); + + expect(presentation.events.map((event) => event.eventId), [ + 'z-first', + 'a-second', + 'third', + ]); + }); + + test('hidden and deselected calendars are excluded defensively', () { + final presentation = _project( + now: now, + calendarSources: [ + _source(id: 'visible'), + _source(id: 'hidden', hidden: true), + _source(id: 'deselected', selected: false), + ], + scheduleItems: [ + _event('visible-event', sourceId: 'visible', start: now), + _event('hidden-event', sourceId: 'hidden', start: now), + _event('deselected-event', sourceId: 'deselected', start: now), + ], + ); + + expect(presentation.events.single.eventId, 'visible-event'); + }); + + test('task summary respects visibility, completion, and no-date state', () { + const visibleKey = ScheduleTaskListKey( + accountId: 'account', + taskListId: 'visible-list', + ); + final presentation = _project( + now: now, + taskLists: const [ + TaskListEntity( + accountId: 'account', + id: 'visible-list', + title: 'Visible', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + TaskListEntity( + accountId: 'account', + id: 'hidden-list', + title: 'Hidden', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + settings: AppSettings.defaults().copyWith( + taskListScheduleVisibility: const {'account::hidden-list': false}, + ), + writableTaskListKeys: {visibleKey}, + scheduleItems: [ + _task('due', sourceId: 'visible-list', start: now), + _task('done', sourceId: 'visible-list', start: now, completed: true), + _task('no-date', sourceId: 'visible-list'), + _task('hidden', sourceId: 'hidden-list', start: now), + ], + ); + + expect(presentation.incompleteTasksDueToday, 1); + expect(presentation.canCreateTask, isTrue); + }); + + test('read-only WebCal events display without enabling creation', () { + final presentation = _project( + now: now, + accounts: [_account(provider: BusyProvider.webCal)], + calendarSources: [ + _source( + provider: BusyProvider.webCal, + readOnly: true, + accountId: 'account', + ), + ], + scheduleItems: [ + _event('webcal-event', provider: BusyProvider.webCal, start: now), + ], + ); + + expect(presentation.events.single.eventId, 'webcal-event'); + expect(presentation.canCreateEvent, isFalse); + }); + + test('visible capability models control quick creation', () { + const taskKey = ScheduleTaskListKey( + accountId: 'account', + taskListId: 'tasks', + ); + final presentation = _project( + now: now, + calendarSources: [_source()], + taskLists: const [ + TaskListEntity( + accountId: 'account', + id: 'tasks', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + writableTaskListKeys: {taskKey}, + ); + + expect(presentation.canCreateEvent, isTrue); + expect(presentation.canCreateTask, isTrue); + }); + + test('uses the least fresh successful account synchronization', () { + final presentation = _project( + now: now, + accounts: [ + _account(lastSync: DateTime.utc(2026, 8, 29, 9, 50)), + _account(id: 'second', lastSync: DateTime.utc(2026, 8, 29, 8)), + ], + ); + expect( + presentation.lastSuccessfulSynchronizationUtc, + DateTime.utc(2026, 8, 29, 8), + ); + + final neverSynced = _project( + now: now, + accounts: [ + _account(lastSync: DateTime.utc(2026, 8, 29, 9)), + _account(id: 'second'), + ], + ); + expect(neverSynced.lastSuccessfulSynchronizationUtc, isNull); + }); + + test('formats all-day, ongoing, future, fallback, and sanitized labels', () { + final presentation = _project( + now: now, + scheduleItems: [ + _event( + 'all-day', + title: ' Release\n\u0007 freeze ', + start: DateTime(2026, 8, 29), + allDay: true, + ), + _event( + 'ongoing', + title: '', + start: DateTime(2026, 8, 29, 9), + end: DateTime(2026, 8, 29, 11), + ), + _event( + 'future', + title: List.filled(120, 'x').join(), + start: DateTime(2026, 8, 29, 10, 30), + ), + ], + ); + final menu = _formatter.format(presentation); + + expect(menu.eventRows[0].label, 'All day · Release freeze'); + expect(menu.eventRows[1].label, 'Now · Untitled event'); + expect(menu.eventRows[2].label, startsWith('10:30 · ')); + expect(menu.eventRows[2].label, endsWith('…')); + expect(menu.eventRows[2].label.runes.length, 100); + }); + + test('private detail removes every event title', () { + final presentation = _project( + now: now, + settings: AppSettings.defaults().copyWith( + notificationDetailLevel: NotificationDetailLevel.private, + ), + scheduleItems: [ + _event( + 'all-day', + title: 'All-day secret', + start: DateTime(2026, 8, 29), + allDay: true, + ), + _event( + 'ongoing', + title: 'Ongoing secret', + start: DateTime(2026, 8, 29, 9), + end: DateTime(2026, 8, 29, 11), + ), + ], + ); + final labels = _formatter + .format(presentation) + .eventRows + .map((row) => row.label) + .toList(); + + expect(labels, ['All day · Calendar event', 'Now · Calendar event']); + expect(labels.join(' '), isNot(contains('secret'))); + }); + + test('normal Unicode event titles are preserved', () { + final menu = _formatter.format( + _project( + now: now, + scheduleItems: [_event('unicode', title: 'Réunion 東京', start: now)], + ), + ); + + expect(menu.eventRows.single.label, '10:00 · Réunion 東京'); + }); + + test('formats singular, plural, and empty Today states', () { + final one = _formatter.format( + _project( + now: now, + scheduleItems: [_task('one', start: now)], + ), + ); + final many = _formatter.format( + _project( + now: now, + scheduleItems: [ + _task('one', start: now), + _task('two', start: now), + _task('three', start: now), + ], + ), + ); + final empty = _formatter.format(_project(now: now)); + + expect(one.taskSummaryLabel, '1 task due today'); + expect(many.taskSummaryLabel, '3 tasks due today'); + expect(empty.showEmptyState, isTrue); + expect(empty.emptyStateLabel, 'Nothing else today'); + }); + + test('formats synchronization status precedence and relative time', () { + BusyMaxTrayMenuPresentation format({ + NetworkAvailability connectivity = NetworkAvailability.online, + bool syncing = false, + List? accounts, + }) => _formatter.format( + _project( + now: now, + connectivity: connectivity, + synchronizationRunning: syncing, + accounts: + accounts ?? + [ + _account( + lastSync: now.toUtc().subtract(const Duration(minutes: 5)), + ), + ], + ), + ); + + expect( + format( + connectivity: NetworkAvailability.offline, + syncing: true, + ).synchronizationStatusLabel, + 'Offline — Changes will sync when connected.', + ); + expect(format(syncing: true).synchronizationStatusLabel, 'Syncing…'); + expect(format(accounts: []).synchronizationStatusLabel, 'Not connected'); + expect( + format(accounts: [_account()]).synchronizationStatusLabel, + 'Not yet synced', + ); + expect(format().synchronizationStatusLabel, 'Last synced 5 minutes ago'); + expect( + format(connectivity: NetworkAvailability.offline).canSynchronize, + isFalse, + ); + expect(format(syncing: true).canSynchronize, isFalse); + expect(format(accounts: []).canSynchronize, isFalse); + expect(format().canSynchronize, isTrue); + }); +} + +BusyMaxTrayPresentation _project({ + required DateTime now, + List? accounts, + List? calendarSources, + List? taskLists, + List scheduleItems = const [], + Set writableTaskListKeys = const {}, + AppSettings? settings, + NetworkAvailability connectivity = NetworkAvailability.online, + bool synchronizationRunning = false, +}) { + return projectBusyMaxTrayPresentation( + accounts: accounts ?? [_account()], + calendarSources: calendarSources ?? [_source()], + taskLists: + taskLists ?? + const [ + TaskListEntity( + accountId: 'account', + id: 'visible-list', + title: 'Visible', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + scheduleItems: scheduleItems, + writableTaskListKeys: writableTaskListKeys, + settings: settings ?? AppSettings.defaults(), + connectivity: connectivity, + synchronizationRunning: synchronizationRunning, + now: now, + ); +} + +AccountEntity _account({ + String id = 'account', + BusyProvider provider = BusyProvider.google, + DateTime? lastSync, +}) { + return AccountEntity( + id: id, + provider: provider, + authority: 'https://example.test', + providerAccountId: id, + credentialKind: provider == BusyProvider.webCal + ? CredentialKind.webCalSubscription + : CredentialKind.oauth, + authState: accountAuthStateSignedIn, + lastSuccessfulSyncAtUtc: lastSync, + ); +} + +CalendarSourceEntity _source({ + String id = 'calendar', + String accountId = 'account', + BusyProvider provider = BusyProvider.google, + bool selected = true, + bool hidden = false, + bool readOnly = false, +}) { + return CalendarSourceEntity( + id: id, + accountId: accountId, + provider: provider, + providerCalendarId: id, + summary: id, + selected: selected, + hidden: hidden, + readOnly: readOnly, + isDeleted: false, + ); +} + +CalendarScheduleItem _event( + String id, { + String accountId = 'account', + String sourceId = 'calendar', + BusyProvider provider = BusyProvider.google, + String? title, + required DateTime start, + DateTime? end, + bool allDay = false, +}) { + return CalendarScheduleItem( + id: id, + accountId: accountId, + provider: provider, + sourceId: sourceId, + providerCalendarId: sourceId, + title: title ?? id, + allDay: allDay, + start: start, + end: end, + ); +} + +TaskScheduleItem _task( + String id, { + String sourceId = 'visible-list', + DateTime? start, + bool completed = false, +}) { + return TaskScheduleItem( + id: id, + accountId: 'account', + provider: BusyProvider.google, + sourceId: sourceId, + title: id, + completed: completed, + allDay: true, + start: start, + ); +} + +final _formatter = BusyMaxTrayPresentationFormatter( + BusyMaxTrayPresentationStrings( + showBusyMax: 'Show BusyMax', + newEvent: 'New event…', + newTask: 'New task…', + today: 'Today', + allDay: 'All day', + now: 'Now', + calendarEvent: 'Calendar event', + untitledEvent: 'Untitled event', + nothingElseToday: 'Nothing else today', + openTodayAgenda: 'Open today’s agenda', + syncNow: 'Sync now', + syncing: 'Syncing…', + notConnected: 'Not connected', + notYetSynced: 'Not yet synced', + settings: 'Settings', + quitBusyMax: 'Quit BusyMax', + offline: 'Offline', + offlineDescription: 'Changes will sync when connected.', + formatTime: (value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}', + tasksDueToday: (count) => + count == 1 ? '1 task due today' : '$count tasks due today', + lastSyncedJustNow: 'Last synced just now', + lastSyncedMinutesAgo: (count) => + 'Last synced $count minute${count == 1 ? '' : 's'} ago', + lastSyncedHoursAgo: (count) => + 'Last synced $count hour${count == 1 ? '' : 's'} ago', + lastSyncedDaysAgo: (count) => + 'Last synced $count day${count == 1 ? '' : 's'} ago', + ), +); diff --git a/test/ical/ical_import_service_test.dart b/test/ical/ical_import_service_test.dart new file mode 100644 index 00000000..276a7dd0 --- /dev/null +++ b/test/ical/ical_import_service_test.dart @@ -0,0 +1,321 @@ +import 'dart:convert'; + +import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/ical/ical_import_service.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late CalendarRepository calendarRepository; + late IcalImportService importService; + late int notificationRebuilds; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + notificationRebuilds = 0; + calendarRepository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 8, 29), + onNotificationScheduleChanged: () async => notificationRebuilds += 1, + ); + importService = IcalImportService( + database: database, + calendarRepository: calendarRepository, + ); + await _seedAccountAndSource(database); + }); + + tearDown(() async => database.close()); + + test( + 'preview performs no mutation and reports omitted scheduling fields', + () { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +METHOD:REQUEST +BEGIN:VEVENT +UID:invitation-copy +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Meeting copy +ORGANIZER:mailto:organizer@example.test +ATTENDEE:mailto:guest@example.test +URL:https://meeting.example.test/join +ATTACH:https://files.example.test/agenda +END:VEVENT +'''), + ), + ); + + expect(preview.eventCount, 1); + expect( + preview.fieldsThatWillBeOmitted, + containsAll([ + 'scheduling method', + 'attendees', + 'organizer', + 'URL', + 'attachments', + ]), + ); + expect(notificationRebuilds, 0); + }, + ); + + test( + 'queues a private offline copy once and records durable UID receipt', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +METHOD:REQUEST +BEGIN:VEVENT +UID:recurring-import +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Imported series +DESCRIPTION:Description +LOCATION:Room 2 +RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=SU +CATEGORIES:One,Two +CLASS:PRIVATE +TRANSP:TRANSPARENT +ORGANIZER:mailto:organizer@example.test +ATTENDEE;ROLE=REQ-PARTICIPANT:mailto:guest@example.test +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT10M +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +'''), + ), + ); + final destination = (await importService.writableDestinations()).single; + + final report = await importService.importPreview( + preview: preview, + destination: destination, + ); + + expect(report.queued, 1); + expect(report.duplicatesSkipped, 0); + expect(report.unsupportedRecurrenceSets, isEmpty); + expect(notificationRebuilds, 1); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.title, 'Imported series'); + expect(event.attendeesJson, isNull); + expect(event.organizerJson, '{"self":true}'); + expect(event.organizerJson, isNot(contains('organizer@example.test'))); + expect(event.conferenceJson, isNull); + expect(event.syncStatus, 'pending'); + expect(event.recurrenceJson, isNotNull); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; + expect(request[calendarEventGuestUpdatePolicyKey], 'doNotSend'); + expect(request['attendeesJson'], isNull); + expect(request, isNot(contains('organizer'))); + expect(request, isNot(contains('conferenceJson'))); + expect( + (await database.select(database.icalImportReceipts).getSingle()) + .icalUid, + 'recurring-import', + ); + + final repeated = await importService.importPreview( + preview: preview, + destination: destination, + ); + expect(repeated.queued, 0); + expect(repeated.duplicatesSkipped, 1); + expect( + await database.select(database.calendarEvents).get(), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), hasLength(1)); + expect(notificationRebuilds, 1); + }, + ); + + test( + 'skips a whole recurrence set rather than dropping an exception', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:with-exception +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +END:VEVENT +BEGIN:VEVENT +UID:with-exception +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Moved +END:VEVENT +'''), + ), + ); + + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, hasLength(1)); + expect(report.unsupportedRecurrenceSets.single.uid, 'with-exception'); + expect( + report.unsupportedRecurrenceSets.single.reason, + contains('Detached'), + ); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'skips an embedded custom timezone the destination cannot carry', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VTIMEZONE +TZID:Custom/Office +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:-0800 +TZOFFSETTO:-0800 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:custom-timezone +DTSTART;TZID=Custom/Office:20260830T090000 +DTEND;TZID=Custom/Office:20260830T100000 +SUMMARY:Custom zone event +END:VEVENT +'''), + ), + ); + + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, hasLength(1)); + expect( + report.unsupportedRecurrenceSets.single.reason, + contains('custom timezone'), + ); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test('writable destinations exclude read-only and WebCal sources', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'webcal-account-sub', + provider: 'webcal', + authority: 'https://feed.example.test', + providerAccountId: 'fingerprint', + credentialKind: 'webcal_subscription', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'webcal-calendar-sub', + accountId: 'webcal-account-sub', + provider: 'webcal', + providerCalendarId: 'sub', + summary: 'Subscription', + readOnly: const Value(true), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'read-only-google', + accountId: 'google-account', + provider: 'google', + providerCalendarId: 'readonly', + summary: 'Read only', + readOnly: const Value(true), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + + expect( + (await importService.writableDestinations()).map((source) => source.id), + ['google-calendar'], + ); + }); +} + +Future _seedAccountAndSource(AppDatabase database) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'me@example.test', + credentialKind: 'oauth', + email: const Value('me@example.test'), + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'google-calendar', + accountId: 'google-account', + provider: 'google', + providerCalendarId: 'primary', + summary: 'Calendar', + accessRole: const Value('owner'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); +} + +const _now = '2026-08-29T00:00:00.000Z'; + +String _calendar(String body) => + '''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Import Test//EN\r +${body.trim().replaceAll('\n', '\r\n')}\r +END:VCALENDAR\r +'''; diff --git a/test/ical/ical_ingestion_test.dart b/test/ical/ical_ingestion_test.dart new file mode 100644 index 00000000..a17e5f5c --- /dev/null +++ b/test/ical/ical_ingestion_test.dart @@ -0,0 +1,332 @@ +import 'dart:convert'; + +import 'package:busymax/src/dav/ical/ical_semantics.dart'; +import 'package:busymax/src/ical/ical_event_projection.dart'; +import 'package:busymax/src/ical/ical_ingestion.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('shared iCalendar ingestion', () { + test('groups multiple UIDs and retains recurrence exceptions', () { + final result = IcalIngestion.parseString( + _calendar(''' +BEGIN:VEVENT +UID:first +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +RRULE:FREQ=DAILY;COUNT=2 +SUMMARY:Master +END:VEVENT +BEGIN:VEVENT +UID:first +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Moved occurrence +END:VEVENT +BEGIN:VEVENT +UID:second +DTSTART;VALUE=DATE:20260901 +DTEND;VALUE=DATE:20260902 +SUMMARY:All day +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + + expect(result.recurrenceSets.map((set) => set.uid), ['first', 'second']); + expect(result.recurrenceSets.first.semantic.components, hasLength(2)); + expect( + result.recurrenceSets.first.semantic.components.last.recurrenceIdKey, + '20260831T160000Z', + ); + }); + + test('rejects duplicate logical components without choosing one', () { + final source = _calendar(''' +${_event('duplicate', 'First')} +${_event('duplicate', 'Second')} +'''); + + expect( + () => IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalDuplicateLogicalComponent', + ), + ), + ); + final imported = IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.fileImport, + ); + expect(imported.recurrenceSets, isEmpty); + expect(imported.rejectedEvents.single.uid, 'duplicate'); + }); + + test('normalizes recurrence identifiers before duplicate detection', () { + final source = _calendar(''' +BEGIN:VEVENT +UID:duplicate-date +DTSTART;VALUE=DATE:20260830 +DTEND;VALUE=DATE:20260831 +RRULE:FREQ=DAILY;COUNT=2 +SUMMARY:Master +END:VEVENT +BEGIN:VEVENT +UID:duplicate-date +RECURRENCE-ID:20260831 +DTSTART;VALUE=DATE:20260831 +DTEND;VALUE=DATE:20260901 +SUMMARY:First exception +END:VEVENT +BEGIN:VEVENT +UID:duplicate-date +RECURRENCE-ID;VALUE=DATE:20260831 +DTSTART;VALUE=DATE:20260831 +DTEND;VALUE=DATE:20260901 +SUMMARY:Duplicate exception +END:VEVENT +'''); + + expect( + () => IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalDuplicateLogicalComponent', + ), + ), + ); + }); + + test('retains VTIMEZONE in every semantic recurrence set', () { + final result = IcalIngestion.parseString( + _calendar(''' +BEGIN:VTIMEZONE +TZID:Custom/Office +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:-0800 +TZOFFSETTO:-0800 +TZNAME:PST +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:zoned +DTSTART;TZID=Custom/Office:20260830T090000 +DTEND;TZID=Custom/Office:20260830T100000 +SUMMARY:Zoned +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + + expect( + result.recurrenceSets.single.semantic.document.calendarComponents.where( + (component) => component.name == 'VTIMEZONE', + ), + hasLength(1), + ); + final projected = IcalEventProjector().project( + result.recurrenceSets.single, + rangeStartUtc: DateTime.utc(2026, 8, 1), + rangeEndUtc: DateTime.utc(2026, 9, 30), + transport: 'webcal', + ); + expect(projected.single.startTimeZone, 'Custom/Office'); + expect( + (jsonDecode(projected.single.rawJson) as Map)['startUtc'], + '2026-08-30T17:00:00.000Z', + ); + }); + + test('preserves UTC, floating, and all-day value semantics', () { + final result = IcalIngestion.parseString( + _calendar(''' +${_event('utc', 'UTC')} +BEGIN:VEVENT +UID:floating +DTSTART:20260830T090000 +DTEND:20260830T100000 +SUMMARY:Floating +END:VEVENT +BEGIN:VEVENT +UID:date +DTSTART;VALUE=DATE:20260830 +DTEND;VALUE=DATE:20260831 +SUMMARY:Date +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + final projections = [ + for (final set in result.recurrenceSets) + ...IcalEventProjector().project( + set, + rangeStartUtc: DateTime.utc(2026, 8, 1), + rangeEndUtc: DateTime.utc(2026, 9, 30), + transport: 'webcal', + ), + ]; + + final byUid = {for (final event in projections) event.uid: event}; + expect(byUid['utc']!.startTimeZone, 'UTC'); + expect(byUid['floating']!.startTimeZone, isNull); + expect(byUid['floating']!.startDateTime, '2026-08-30T09:00:00'); + expect(byUid['date']!.allDay, isTrue); + expect(byUid['date']!.startDate, '2026-08-30'); + }); + + test('uses fail-fast WebCal and partial file-import policies', () { + final source = _calendar(''' +${_event('valid', 'Valid')} +BEGIN:VEVENT +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Missing UID +END:VEVENT +'''); + + expect( + () => IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.webCal, + ), + throwsA(isA()), + ); + final imported = IcalIngestion.parseString( + source, + policy: IcalIngestionPolicy.fileImport, + ); + expect(imported.recurrenceSets.single.uid, 'valid'); + expect(imported.rejectedEvents, hasLength(1)); + }); + + test('accepts a valid zero-event snapshot but rejects no components', () { + final empty = IcalIngestion.parseString( + _calendar(''' +BEGIN:VTODO +UID:task +DTSTAMP:20260829T000000Z +SUMMARY:Not projected +END:VTODO +'''), + policy: IcalIngestionPolicy.webCal, + ); + expect(empty.recurrenceSets, isEmpty); + expect(empty.unprojectedComponentTypes, contains('VTODO')); + + expect( + () => IcalIngestion.parseString( + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//BusyMax Test//EN\r\nEND:VCALENDAR\r\n', + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalCalendarHasNoComponents', + ), + ), + ); + }); + + test('enforces envelope, BOM, strict UTF-8, and size invariants', () { + final bom = [ + 0xef, + 0xbb, + 0xbf, + ...utf8.encode(_calendar(_event('bom', 'BOM'))), + ]; + expect( + IcalIngestion.parseBytes( + bom, + policy: IcalIngestionPolicy.webCal, + ).recurrenceSets.single.uid, + 'bom', + ); + expect( + () => IcalIngestion.parseBytes([ + 0xc3, + 0x28, + ], policy: IcalIngestionPolicy.webCal), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalInvalidUtf8', + ), + ), + ); + expect( + () => IcalIngestion.parseBytes( + List.filled(icalIngestionDecodedBodyLimit + 1, 0x20), + policy: IcalIngestionPolicy.webCal, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'IcalBodyTooLarge', + ), + ), + ); + }); + + test('sanitized snapshot removes secret-bearing properties', () { + final secret = Uri.parse('https://example.test/private.ics?token=secret'); + final result = IcalIngestion.parseString( + _calendar(''' +SOURCE:${secret.toString()} +${_event('safe', 'Safe')} +'''), + policy: IcalIngestionPolicy.webCal, + ); + final snapshot = sanitizedCanonicalSnapshot( + result.document, + secretUris: [secret], + ); + + expect(snapshot, isNot(contains(secret.toString()))); + expect(snapshot, contains('UID:safe')); + }); + + test('existing DAV semantic parser keeps one-UID invariant', () { + expect( + () => IcalSemanticDocument.parse( + _calendar(''' +${_event('one', 'One')} +${_event('two', 'Two')} +'''), + ), + throwsA(anything), + ); + }); + }); +} + +String _calendar(String components) => + '''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Test//EN\r +${components.trim().replaceAll('\n', '\r\n')}\r +END:VCALENDAR\r +'''; + +String _event(String uid, String summary) => + '''BEGIN:VEVENT +UID:$uid +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:$summary +END:VEVENT'''; diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index c3e1862b..6541873e 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -1,277 +1,292 @@ +import 'dart:async'; import 'dart:io'; +import 'package:busymax/src/features/tray/domain/tray_presentation.dart'; import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; void main() { - test('tray menu contains only app, agenda, and exit actions', () async { - var openedApp = false; - var openedAgenda = false; - var quit = false; + test('every presentation retains the fixed hierarchy and ID sequence', () { + final presentations = [ + _presentation(), + _presentation(events: [_row('one')]), + _presentation( + events: [_row('one'), _row('two'), _row('three')], + taskSummary: '3 tasks due today', + ), + _presentation( + offline: true, + canCreateEvent: false, + canCreateTask: false, + canSynchronize: false, + ), + ]; + + for (final presentation in presentations) { + final menu = buildBusyMaxTrayMenu( + presentation: presentation, + actions: _Actions().value, + ); + expect(menu.id, BusyMaxTrayMenuIds.root); + expect( + menu.children.map((item) => item.id), + BusyMaxTrayMenuIds.orderedChildren, + ); + expect(menu.children, hasLength(18)); + expect( + menu.children + .where((item) => item.type == 'separator') + .map((item) => item.id), + [2, 5, 13, 16], + ); + expect( + menu.children.where((item) => item.type == 'separator'), + everyElement( + isA().having((item) => item.visible, 'visible', isTrue), + ), + ); + expect(_item(menu, BusyMaxTrayMenuIds.today).enabled, isFalse); + expect(_item(menu, BusyMaxTrayMenuIds.today).visible, isTrue); + } + }); - final menu = buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async { - openedApp = true; - }, - onOpenAgenda: () async { - openedAgenda = true; - }, - onQuit: () async { - quit = true; - }, + test('unused slots become invisible and task and empty rows are correct', () { + final oneEvent = buildBusyMaxTrayMenu( + presentation: _presentation(events: [_row('event')]), + actions: _Actions().value, ); + expect(_item(oneEvent, 7).visible, isTrue); + expect(_item(oneEvent, 8).visible, isFalse); + expect(_item(oneEvent, 9).visible, isFalse); + expect(_item(oneEvent, 10).visible, isFalse); + expect(_item(oneEvent, 11).visible, isFalse); + + final taskOnly = buildBusyMaxTrayMenu( + presentation: _presentation(taskSummary: '1 task due today'), + actions: _Actions().value, + ); + expect(_item(taskOnly, 10).visible, isTrue); + expect(_item(taskOnly, 10).enabled, isTrue); + expect(_item(taskOnly, 11).visible, isFalse); - expect(menu.children, hasLength(3)); - expect(menu.id, 0); - expect(menu.children.map((item) => item.id), [1, 2, 3]); - expect(menu.children.map((item) => item.label), [ - 'Open BusyMax', - 'Agenda', - 'Exit', - ]); - expect(menu.enabled, isTrue); - expect(menu.visible, isTrue); - expect(menu.children.every((item) => item.enabled == true), isTrue); - expect(menu.children.every((item) => item.visible == true), isTrue); - expect(menu.children.every((item) => item.children.isEmpty), isTrue); - - await menu.children[0].onClicked?.call(); - await menu.children[1].onClicked?.call(); - await menu.children[2].onClicked?.call(); - - expect(openedApp, isTrue); - expect(openedAgenda, isTrue); - expect(quit, isTrue); + final empty = buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions().value, + ); + expect(_item(empty, 10).visible, isFalse); + expect(_item(empty, 11).visible, isTrue); + expect(_item(empty, 11).enabled, isFalse); }); - test('tray menu id 2 invokes Agenda callback', () async { - var openedAgenda = false; - final menu = buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async {}, - onOpenAgenda: () async { - openedAgenda = true; + test('creation and synchronization enabled states follow presentation', () { + final disabled = buildBusyMaxTrayMenu( + presentation: _presentation( + canCreateEvent: false, + canCreateTask: false, + canSynchronize: false, + ), + actions: _Actions().value, + ); + expect(_item(disabled, 3).enabled, isFalse); + expect(_item(disabled, 4).enabled, isFalse); + expect(_item(disabled, 14).enabled, isFalse); + expect(_item(disabled, 15).enabled, isFalse); + + final enabled = buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions().value, + ); + expect(_item(enabled, 3).enabled, isTrue); + expect(_item(enabled, 4).enabled, isTrue); + expect(_item(enabled, 14).enabled, isTrue); + }); + + test('event callback is rebound to the current slot identity', () async { + final opened = []; + var next = _presentation(events: [_row('first')]); + final actions = _Actions( + onOpenEvent: (event) async { + opened.add(event.eventId); }, - onQuit: () async {}, ); + final controller = BusyMaxTrayMenuController( + initialPresentation: next, + loadPresentation: () async => next, + actions: actions.value, + ); + final object = DBusMenuObject( + DBusObjectPath(busyMaxTrayMenuPath), + controller.menu, + ); + controller.attachUpdater((menu, presentation) => object.update(menu)); - final agendaItem = menu.children.singleWhere((item) => item.id == 2); - await agendaItem.onClicked?.call(); + await _click(object, BusyMaxTrayMenuIds.firstEvent); + next = _presentation(events: [_row('second')]); + expect(await controller.refresh(), isTrue); + await _click(object, BusyMaxTrayMenuIds.firstEvent); - expect(agendaItem.label, 'Agenda'); - expect(agendaItem.children, isEmpty); - expect(openedAgenda, isTrue); + expect(opened, ['first', 'second']); }); - test('offline tray menu exposes a disabled connectivity status', () { - final menu = buildBusyMaxTrayMenu( - labels: _labels, - offline: true, - onOpenBusyMax: () async {}, - onOpenAgenda: () async {}, - onQuit: () async {}, + test( + 'AboutToShow updates properties and unchanged loads return false', + () async { + var loads = 0; + var next = _presentation(); + final controller = BusyMaxTrayMenuController( + initialPresentation: next, + loadPresentation: () async { + loads += 1; + return next; + }, + actions: _Actions().value, + ); + final object = DBusMenuObject( + DBusObjectPath(busyMaxTrayMenuPath), + controller.menu, + ); + var updates = 0; + controller.attachUpdater((menu, presentation) { + updates += 1; + return object.update(menu); + }); + + final unchanged = await _aboutToShow(object); + expect(unchanged, isFalse); + expect(updates, 0); + next = _presentation(events: [_row('updated')]); + final changed = await _aboutToShow(object); + expect(changed, isTrue); + expect(updates, 1); + final label = await _property( + object, + BusyMaxTrayMenuIds.firstEvent, + 'label', + ); + expect(label.asString(), '10:30 · updated'); + expect(loads, 2); + }, + ); + + test('simultaneous presentation loads are coalesced', () async { + var loads = 0; + final completer = Completer(); + final controller = BusyMaxTrayMenuController( + initialPresentation: _presentation(), + loadPresentation: () { + loads += 1; + return completer.future; + }, + actions: _Actions().value, ); - expect(menu.children.map((item) => item.id), [4, 1, 2, 3]); - expect( - menu.children.first.label, - 'Offline — Changes will sync when connected.', - ); - expect(menu.children.first.enabled, isFalse); - expect(menu.children.first.visible, isTrue); + final first = controller.refresh(); + final second = controller.refresh(); + completer.complete(_presentation(events: [_row('loaded')])); + + expect(await first, isTrue); + expect(await second, isTrue); + expect(loads, 1); }); - test('offline tray icon is a bundled grayscale status asset', () { - final asset = File('assets/branding/busymax-logo-offline.svg'); - final pubspec = File('pubspec.yaml').readAsStringSync(); + test('load failure retains the previous menu and core actions', () async { + final failures = []; + final initial = _presentation(events: [_row('retained')]); + final controller = BusyMaxTrayMenuController( + initialPresentation: initial, + loadPresentation: () async => throw StateError('local load failed'), + actions: _Actions().value, + onLoadFailure: failures.add, + ); - expect(asset.existsSync(), isTrue); - final source = asset.readAsStringSync(); - expect(source, contains('BusyMax offline')); - expect(source, isNot(contains('#950BfF'))); - expect(pubspec, contains('assets/branding/busymax-logo-offline.svg')); + expect(await controller.refresh(), isFalse); + expect(controller.presentation, initial); + expect(_item(controller.menu, 1).enabled, isTrue); + expect(_item(controller.menu, 17).enabled, isTrue); + expect(_item(controller.menu, 18).enabled, isTrue); + expect(failures.single, isA()); }); - test('status notifier presentation updates before registration', () async { - final bus = DBusClient( - DBusAddress.unix(path: '/tmp/busymax-status-notifier-test-bus'), + test('AboutToShow performs no synchronization', () async { + var syncs = 0; + final actions = _Actions(onSynchronize: () async => syncs += 1); + final controller = BusyMaxTrayMenuController( + initialPresentation: _presentation(), + loadPresentation: () async => _presentation(), + actions: actions.value, ); - final client = StatusNotifierItemClient( - id: busyMaxApplicationId, - title: 'BusyMax', - iconName: 'busymax-color', - toolTipTitle: 'BusyMax', - toolTipDescription: '', - menu: buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async {}, - onOpenAgenda: () async {}, - onQuit: () async {}, - ), - bus: bus, - ); - addTearDown(bus.close); - - await client.updatePresentation( - title: 'BusyMax — Offline', - iconName: 'busymax-offline', - toolTipTitle: 'BusyMax — Offline', - toolTipDescription: 'Changes will sync when connected.', + final object = DBusMenuObject( + DBusObjectPath(busyMaxTrayMenuPath), + controller.menu, ); - expect(client.title, 'BusyMax — Offline'); - expect(client.iconName, 'busymax-offline'); - expect(client.toolTipTitle, 'BusyMax — Offline'); - expect(client.toolTipDescription, 'Changes will sync when connected.'); - }); + await _aboutToShow(object); - test('application id uses Busystack reverse DNS id', () { - expect(busyMaxApplicationId, 'io.busystack.busymax'); - expect(busyMaxTrayMenuPath, '/StatusNotifierItem/menu'); + expect(syncs, 0); }); - test('Linux desktop identity matches the displayed BusyMax window', () { - final desktop = File( - 'linux/io.busystack.busymax.desktop', - ).readAsStringSync(); - final cmake = File('linux/CMakeLists.txt').readAsStringSync(); - final runner = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(desktop, contains('Name=BusyMax')); - expect(desktop, contains('Icon=io.busystack.busymax')); - expect(desktop, contains('StartupWMClass=io.busystack.busymax')); - expect( - cmake, - contains(r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.desktop"'), - ); - expect( - cmake, - contains( - r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.metainfo.xml"', - ), - ); - expect( - cmake, - contains( - r'"${CMAKE_CURRENT_SOURCE_DIR}/../assets/branding/busymax-logo.svg"', - ), + test('Activate restores and SecondaryActivate opens today agenda', () async { + var shown = 0; + var agendas = 0; + final actions = _Actions( + onShow: () async => shown += 1, + onAgenda: () async => agendas += 1, ); - expect(cmake, contains('share/icons/hicolor/scalable/apps')); - expect(cmake, contains('RENAME "io.busystack.busymax.svg"')); - const pngExtension = 'png'; - expect(cmake, isNot(contains('.$pngExtension'))); - final iconFileName = - '${busyMaxApplicationId.split('.').last}.$pngExtension'; - for (final size in [64, 128, 256]) { - final path = [ - 'linux', - 'runner', - 'resources', - 'icons', - '${size}x$size', - 'apps', - iconFileName, - ].join(Platform.pathSeparator); - expect(File(path).existsSync(), isFalse); - } - expect( - runner, - contains( - 'gtk_window_set_wmclass(window, APPLICATION_ID, APPLICATION_ID);', - ), - ); - expect(runner, contains('static_cast(0)')); - expect(runner, isNot(contains('G_APPLICATION_NON_UNIQUE'))); - expect(runner, contains('static void restore_main_window')); - expect(runner, contains('gtk_widget_show(GTK_WIDGET(self->main_window));')); - expect(runner, contains('gtk_window_deiconify(self->main_window);')); - expect( - runner, - contains( - 'gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME);', - ), - ); - expect(runner, contains('restore_main_window(self);')); - expect(runner, contains('BusyMax native showWindow method call received')); - expect( - runner, - contains('BusyMax native showWindow invoked: main_window_null=%s'), - ); - expect(runner, contains('BusyMax native hideWindow invoked')); - expect( - runner, - contains('BusyMax native hideWindow invocation: source=delete-event'), - ); - expect(runner, contains('BusyMax native quit invocation: method=quitApp')); - expect( - runner, - contains( - 'if (application_icon == nullptr) {\n' - ' gtk_window_set_icon_name(window, APPLICATION_ID);\n' - ' }', + final service = BusyMaxTrayService( + configuration: BusyMaxTrayServiceConfiguration( + initialPresentation: _presentation(), + loadPresentation: () async => _presentation(), + actions: actions.value, ), ); - }); - test('agenda action delegates navigation to the app', () { + await service.handleActivate(); + await service.handleSecondaryActivate(); + + expect(shown, 1); + expect(agendas, 1); final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); + expect(source, isNot(contains('onContextMenu:'))); + }); - expect(source, contains('Future _showAgenda()')); - expect(source, contains('return _onOpenAgenda();')); - expect(source, isNot(contains('BusyMaxTrayAgendaMenu'))); - expect(source, isNot(contains('BusyMaxTrayAgendaEntry'))); - expect(source, contains('StatusNotifierItem activation callback')); - expect(source, contains('Tray service start requested')); - expect(source, contains('Tray initialization starting')); - expect(source, contains('StatusNotifier registration succeeded')); - expect(source, contains('StatusNotifier registration failed')); - expect(source, contains('DBus menu creation starting')); - expect(source, contains('DBus menu creation completed')); - expect(source, contains('menuPath: DBusObjectPath(busyMaxTrayMenuPath)')); - expect(source, contains('itemIsMenu: true')); - expect(source, contains('Tray menu DBus verification succeeded')); - expect(source, contains('Tray callback fired')); - expect(source, contains('Tray menu "Open BusyMax" callback')); - expect(source, contains('Tray menu "Agenda" callback')); - expect(source, contains('Tray menu "Quit" callback')); - expect(source, contains('return _windowService.showWindow();')); - expect( - source, - isNot( - contains( - 'await _windowService.showWindow();\n' - ' await _onOpenAgenda();', - ), - ), + test('Quit invokes the existing application quit callback', () async { + var quits = 0; + final menu = buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions(onQuit: () async => quits += 1).value, ); - expect(source, isNot(contains('onBeforeQuit'))); + + await _item(menu, BusyMaxTrayMenuIds.quitBusyMax).onClicked?.call(); + + expect(quits, 1); }); - test('DBus menu exposes layout, properties, and routes stable IDs', () async { - var openedApp = false; - var quit = false; - final logs = []; + test('a disappeared event fails safely when its row is activated', () async { + final menu = buildBusyMaxTrayMenu( + presentation: _presentation(events: [_row('removed')]), + actions: _Actions( + onOpenEvent: (_) async => throw StateError('event disappeared'), + ).value, + ); + await expectLater( + _item(menu, BusyMaxTrayMenuIds.firstEvent).onClicked?.call(), + completes, + ); + }); + + test('DBus layout exports all fixed IDs and properties', () async { final object = DBusMenuObject( DBusObjectPath(busyMaxTrayMenuPath), buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: () async { - openedApp = true; - }, - onOpenAgenda: () async {}, - onQuit: () async { - quit = true; - }, + presentation: _presentation(events: [_row('event')]), + actions: _Actions().value, ), - diagnosticLog: logs.add, ); - final layout = await object.handleMethodCall( DBusMethodCall( sender: 'test', @@ -284,191 +299,221 @@ void main() { ], ), ); - expect(layout, isA()); final root = layout.returnValues[1].asStruct(); - expect(root[0].asInt32(), 0); final children = root[2] .asArray() .map((child) => child.asVariant().asStruct()) .toList(); - expect(children.map((child) => child[0].asInt32()).toList(), [1, 2, 3]); - for (final child in children) { - final properties = child[1].asStringVariantDict(); - expect(properties['label']?.asString(), isNotEmpty); - expect(properties['enabled']?.asBoolean(), isTrue); - expect(properties['visible']?.asBoolean(), isTrue); - expect(properties, isNot(contains('children-display'))); - } - final groupProperties = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'GetGroupProperties', - values: [ - DBusArray.int32([1, 2, 3]), - DBusArray.string(const []), - ], - ), - ); - expect(groupProperties, isA()); - final groupedItems = groupProperties.returnValues.single.asArray(); - expect(groupedItems.map((item) => item.asStruct()[0].asInt32()).toList(), [ - 1, - 2, - 3, - ]); - - final openProperties = groupedItems.first - .asStruct()[1] - .asStringVariantDict(); - expect(openProperties['label']?.asString(), 'Open BusyMax'); - expect(openProperties['enabled']?.asBoolean(), isTrue); - expect(openProperties['visible']?.asBoolean(), isTrue); - - final labelProperty = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'GetProperty', - values: [const DBusInt32(1), const DBusString('label')], - ), + expect(root[0].asInt32(), BusyMaxTrayMenuIds.root); + expect( + children.map((child) => child[0].asInt32()), + BusyMaxTrayMenuIds.orderedChildren, ); - expect(labelProperty, isA()); expect( - labelProperty.returnValues.single.asVariant().asString(), - 'Open BusyMax', + children + .singleWhere( + (child) => child[0].asInt32() == BusyMaxTrayMenuIds.today, + )[1] + .asStringVariantDict()['enabled'] + ?.asBoolean(), + isFalse, ); + }); - final aboutToShow = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'AboutToShow', - values: [const DBusInt32(1)], - ), - ); - expect(aboutToShow, isA()); - expect(aboutToShow.returnValues.single.asBoolean(), isFalse); + test('offline tray icon remains the bundled grayscale asset', () { + final asset = File('assets/branding/busymax-logo-offline.svg'); + final pubspec = File('pubspec.yaml').readAsStringSync(); - final aboutToShowGroup = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'AboutToShowGroup', - values: [ - DBusArray.int32([1, 2, 99]), - ], - ), - ); - expect(aboutToShowGroup, isA()); - expect(aboutToShowGroup.returnValues[0].asInt32Array(), isEmpty); - expect(aboutToShowGroup.returnValues[1].asInt32Array(), [99]); + expect(asset.existsSync(), isTrue); + expect(asset.readAsStringSync(), contains('BusyMax offline')); + expect(asset.readAsStringSync(), isNot(contains('#950BfF'))); + expect(pubspec, contains('assets/branding/busymax-logo-offline.svg')); + }); - final version = await object.getProperty( - 'com.canonical.dbusmenu', - 'Version', - ); - expect(version, isA()); - expect(version.returnValues.single.asVariant().asUint32(), greaterThan(0)); - final status = await object.getProperty('com.canonical.dbusmenu', 'Status'); - expect(status.returnValues.single.asVariant().asString(), 'normal'); - final textDirection = await object.getProperty( - 'com.canonical.dbusmenu', - 'TextDirection', - ); - expect(textDirection.returnValues.single.asVariant().asString(), 'ltr'); - final iconThemePath = await object.getProperty( - 'com.canonical.dbusmenu', - 'IconThemePath', + test('status notifier presentation can update before registration', () async { + final bus = DBusClient( + DBusAddress.unix(path: '/tmp/busymax-status-notifier-test-bus'), ); - expect( - iconThemePath.returnValues.single.asVariant().asStringArray(), - isEmpty, + final client = StatusNotifierItemClient( + id: busyMaxApplicationId, + title: 'BusyMax', + iconName: 'busymax-color', + toolTipTitle: 'BusyMax', + toolTipDescription: '', + menu: buildBusyMaxTrayMenu( + presentation: _presentation(), + actions: _Actions().value, + ), + bus: bus, ); + addTearDown(bus.close); - final event = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'Event', - values: [ - const DBusInt32(1), - const DBusString('clicked'), - const DBusVariant(DBusInt32(0)), - const DBusUint32(0), - ], - ), + await client.updatePresentation( + title: 'BusyMax — Offline', + iconName: 'busymax-offline', + toolTipTitle: 'BusyMax — Offline', + toolTipDescription: 'Changes will sync when connected.', ); - expect(event, isA()); - expect(openedApp, isTrue); - final eventGroup = await object.handleMethodCall( - DBusMethodCall( - sender: 'test', - interface: 'com.canonical.dbusmenu', - name: 'EventGroup', - values: [ - DBusArray(DBusSignature('(isvu)'), [ - DBusStruct([ - const DBusInt32(3), - const DBusString('clicked'), - const DBusVariant(DBusInt32(0)), - const DBusUint32(0), - ]), - ]), - ], - ), + expect(client.title, 'BusyMax — Offline'); + expect(client.iconName, 'busymax-offline'); + expect(client.toolTipTitle, 'BusyMax — Offline'); + expect(client.toolTipDescription, 'Changes will sync when connected.'); + }); + + test('Linux desktop identity matches the displayed BusyMax window', () { + final desktop = File( + 'linux/io.busystack.busymax.desktop', + ).readAsStringSync(); + final cmake = File('linux/CMakeLists.txt').readAsStringSync(); + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(busyMaxApplicationId, 'io.busystack.busymax'); + expect(busyMaxTrayMenuPath, '/StatusNotifierItem/menu'); + expect(desktop, contains('Name=BusyMax')); + expect(desktop, contains('Icon=io.busystack.busymax')); + expect(desktop, contains('StartupWMClass=io.busystack.busymax')); + expect( + cmake, + contains(r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.desktop"'), ); - expect(eventGroup, isA()); - expect(eventGroup.returnValues.single.asInt32Array(), isEmpty); - expect(quit, isTrue); - expect(logs, contains(startsWith('DBusMenu.GetLayout received'))); - expect(logs, contains(startsWith('DBusMenu.GetGroupProperties received'))); - expect(logs, contains(startsWith('DBusMenu.GetProperty received'))); - expect(logs, contains(startsWith('DBusMenu.AboutToShow received'))); - expect(logs, contains(startsWith('DBusMenu.AboutToShowGroup received'))); - expect(logs, contains(startsWith('DBusMenu.Event received'))); - expect(logs, contains(startsWith('DBusMenu.EventGroup received'))); + expect(runner, contains('G_APPLICATION_HANDLES_OPEN')); + expect(runner, isNot(contains('G_APPLICATION_NON_UNIQUE'))); + expect(runner, contains('static void restore_main_window')); }); - test('patched status notifier and DBus menu handle Ubuntu tray calls', () { + test('vendored DBus implementation retains Ubuntu menu support', () { final source = File( 'third_party/xdg_status_notifier_item/lib/src/dbus_menu_object.dart', ).readAsStringSync(); - final statusNotifier = File( + final notifier = File( 'third_party/xdg_status_notifier_item/lib/src/status_notifier_item_client.dart', ).readAsStringSync(); expect(source, contains('var values = event.asStruct();')); expect(source, contains(r'DBusMenu.${methodCall.name} received')); expect(source, contains('GetGroupProperties')); - expect(source, contains('DBusMenu.GetLayout details')); - expect(source, contains('DBusMenu.GetProperty details')); expect(source, contains('DBusMenu.AboutToShow details')); - expect(source, contains('DBusMenu.AboutToShowGroup details')); - expect(source, contains("DBusString('normal')")); - expect(source, contains("DBusString('ltr')")); - expect(source, contains('DBusArray.string(const [])')); expect(source, contains('_itemsById[id]')); - expect(statusNotifier, contains("'/StatusNotifierItem/menu'")); - expect(statusNotifier, contains('org.kde.StatusNotifierItem')); - expect(statusNotifier, contains('org.freedesktop.StatusNotifierItem')); - expect(statusNotifier, contains("DBusIntrospectSignal('NewIcon')")); - expect(statusNotifier, contains("DBusIntrospectSignal('NewToolTip')")); - expect(statusNotifier, contains("DBusSignature('(sa(iiay)ss)')")); - expect(statusNotifier, contains('Future updatePresentation')); - expect( - statusNotifier, - contains(r'StatusNotifierItem.${methodCall.name} received'), - ); + expect(notifier, contains("'/StatusNotifierItem/menu'")); + expect(notifier, contains('org.kde.StatusNotifierItem')); + expect(notifier, contains('Future updatePresentation')); }); } -const _labels = BusyMaxTrayLabels( - openBusyMax: 'Open BusyMax', - agenda: 'Agenda', - quitBusyMax: 'Exit', - offline: 'Offline', - offlineDescription: 'Changes will sync when connected.', -); +DBusMenuItem _item(DBusMenuItem menu, int id) { + return menu.children.singleWhere((item) => item.id == id); +} + +Future _click(DBusMenuObject object, int id) async { + await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'Event', + values: [ + DBusInt32(id), + const DBusString('clicked'), + const DBusVariant(DBusInt32(0)), + const DBusUint32(0), + ], + ), + ); +} + +Future _aboutToShow(DBusMenuObject object) async { + final response = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'AboutToShow', + values: [const DBusInt32(BusyMaxTrayMenuIds.root)], + ), + ); + return response.returnValues.single.asBoolean(); +} + +Future _property(DBusMenuObject object, int id, String name) async { + final response = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'GetProperty', + values: [DBusInt32(id), DBusString(name)], + ), + ); + return response.returnValues.single.asVariant(); +} + +BusyMaxTrayEventRowPresentation _row(String id) { + return BusyMaxTrayEventRowPresentation( + event: BusyMaxTrayEventEntry( + eventId: id, + accountId: 'account', + calendarSourceId: 'calendar', + title: id, + start: DateTime(2026, 8, 29, 10, 30), + end: DateTime(2026, 8, 29, 11), + allDay: false, + ), + label: '10:30 · $id', + ); +} + +BusyMaxTrayMenuPresentation _presentation({ + List events = const [], + String? taskSummary, + bool canCreateEvent = true, + bool canCreateTask = true, + bool canSynchronize = true, + bool offline = false, +}) { + return BusyMaxTrayMenuPresentation( + showBusyMaxLabel: 'Show BusyMax', + newEventLabel: 'New event…', + newTaskLabel: 'New task…', + todayLabel: 'Today', + eventRows: events, + taskSummaryLabel: taskSummary, + emptyStateLabel: 'Nothing else today', + openTodayAgendaLabel: 'Open today’s agenda', + syncNowLabel: 'Sync now', + synchronizationStatusLabel: offline + ? 'Offline — Changes will sync when connected.' + : 'Last synced just now', + settingsLabel: 'Settings', + quitBusyMaxLabel: 'Quit BusyMax', + offlineTitle: 'Offline', + offlineDescription: 'Changes will sync when connected.', + canCreateEvent: canCreateEvent, + canCreateTask: canCreateTask, + canSynchronize: canSynchronize, + offline: offline, + ); +} + +final class _Actions { + _Actions({ + Future Function()? onShow, + Future Function(BusyMaxTrayEventEntry event)? onOpenEvent, + Future Function()? onAgenda, + Future Function()? onSynchronize, + Future Function()? onQuit, + }) : value = BusyMaxTrayActions( + showBusyMax: onShow ?? _noop, + newEvent: _noop, + newTask: _noop, + openEvent: onOpenEvent ?? _noopEvent, + openTasksDueToday: _noop, + openTodayAgenda: onAgenda ?? _noop, + synchronize: onSynchronize ?? _noop, + openSettings: _noop, + quitBusyMax: onQuit ?? _noop, + ); + + final BusyMaxTrayActions value; +} + +Future _noop() async {} +Future _noopEvent(BusyMaxTrayEventEntry event) async {} diff --git a/test/platform/external_calendar_open_service_test.dart b/test/platform/external_calendar_open_service_test.dart new file mode 100644 index 00000000..5dc1226f --- /dev/null +++ b/test/platform/external_calendar_open_service_test.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:busymax/src/platform/external_calendar_open_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('signals readiness and preserves accepted open arrival order', () async { + const channel = MethodChannel(externalCalendarOpenChannelName); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final outbound = []; + messenger.setMockMethodCallHandler(channel, (call) async { + outbound.add(call); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final service = ExternalCalendarOpenService(channel: channel); + addTearDown(service.dispose); + + await service.initialize(); + expect(outbound.single.method, 'ready'); + final received = service.requests.take(3).toList(); + await _deliver( + messenger, + const MethodCall('openItem', { + 'kind': 'webcal', + 'value': 'webcal://calendar.example.test/feed', + }), + ); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'ics', 'value': '/tmp/first.ics'}), + ); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'ics', 'value': '/tmp/second.ics'}), + ); + + final requests = await received; + expect(requests.map((request) => request.kind), [ + ExternalCalendarOpenKind.webCal, + ExternalCalendarOpenKind.icsFile, + ExternalCalendarOpenKind.icsFile, + ]); + expect(requests.map((request) => request.value), [ + 'webcal://calendar.example.test/feed', + '/tmp/first.ics', + '/tmp/second.ics', + ]); + }); + + test('ignores unrelated methods, kinds, and empty values', () async { + const channel = MethodChannel(externalCalendarOpenChannelName); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (_) async => null); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final service = ExternalCalendarOpenService(channel: channel); + addTearDown(service.dispose); + await service.initialize(); + final received = []; + final subscription = service.requests.listen(received.add); + addTearDown(subscription.cancel); + + await _deliver(messenger, const MethodCall('unrelated')); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'https', 'value': 'https://x'}), + ); + await _deliver( + messenger, + const MethodCall('openItem', {'kind': 'ics', 'value': ''}), + ); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); +} + +Future _deliver(TestDefaultBinaryMessenger messenger, MethodCall call) { + final done = Completer(); + messenger.handlePlatformMessage( + externalCalendarOpenChannelName, + const StandardMethodCodec().encodeMethodCall(call), + (_) => done.complete(), + ); + return done.future; +} diff --git a/test/providers/busy_provider_test.dart b/test/providers/busy_provider_test.dart index 29773450..5af56101 100644 --- a/test/providers/busy_provider_test.dart +++ b/test/providers/busy_provider_test.dart @@ -6,18 +6,20 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('provider identity has exactly four stable storage values', () { + test('provider identity has stable storage values', () { expect(BusyProvider.values, [ BusyProvider.google, BusyProvider.microsoft, BusyProvider.appleICloud, BusyProvider.nextcloud, + BusyProvider.webCal, ]); expect(BusyProvider.values.map((provider) => provider.storageValue), [ 'google', 'microsoft', 'apple_icloud', 'nextcloud', + 'webcal', ]); }); diff --git a/test/webcal/webcal_http_client_test.dart b/test/webcal/webcal_http_client_test.dart new file mode 100644 index 00000000..1899918c --- /dev/null +++ b/test/webcal/webcal_http_client_test.dart @@ -0,0 +1,314 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:busymax/src/webcal/webcal_http_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'follows safe redirects without forwarding validators or credentials', + () async { + final first = _FakeResponse(302, headers: {'location': '/final.ics'}); + final second = _FakeResponse( + 200, + chunks: const [ + [1, 2, 3], + ], + ); + final client = _FakeHttpClient([first, second]); + final transport = IoWebCalHttpTransport(clientFactory: () => client); + final start = Uri.parse('https://calendar.example.test/start'); + + final result = await transport.get( + start, + validators: const WebCalHttpValidators( + etag: '"etag"', + lastModified: 'Sat, 29 Aug 2026 20:00:00 GMT', + ), + validatorTarget: start, + ); + + expect( + result.finalUri.toString(), + 'https://calendar.example.test/final.ics', + ); + expect(result.body, [1, 2, 3]); + expect(client.requests, hasLength(2)); + final firstHeaders = client.requests.first.recordedHeaders; + expect(firstHeaders['accept'], 'text/calendar'); + expect(firstHeaders['if-none-match'], '"etag"'); + expect( + firstHeaders['if-modified-since'], + 'Sat, 29 Aug 2026 20:00:00 GMT', + ); + expect(firstHeaders, isNot(contains('authorization'))); + expect(firstHeaders, isNot(contains('cookie'))); + expect(firstHeaders, isNot(contains('referer'))); + expect(firstHeaders, isNot(contains('origin'))); + final redirectedHeaders = client.requests.last.recordedHeaders; + expect(redirectedHeaders, isNot(contains('if-none-match'))); + expect(redirectedHeaders, isNot(contains('if-modified-since'))); + }, + ); + + test( + 'rejects redirect downgrade, user information, fragments, and loops', + () async { + for (final target in [ + 'http://calendar.example.test/feed', + 'https://user:secret@calendar.example.test/feed', + 'https://calendar.example.test/feed#fragment', + ]) { + final client = _FakeHttpClient([ + _FakeResponse(302, headers: {'location': target}), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => client, + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalRedirectTargetNotAllowed', + ), + ), + ); + expect(client.requests, hasLength(1)); + } + + final loop = _FakeHttpClient([ + _FakeResponse(302, headers: {'location': '/start'}), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => loop, + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalRedirectLoop', + ), + ), + ); + }, + ); + + test('enforces decoded body and redirect limits', () async { + final oversized = _FakeHttpClient([ + _FakeResponse( + 200, + chunks: const [ + [1, 2, 3, 4, 5], + ], + ), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => oversized, + limits: const WebCalHttpLimits(maximumDecodedBodyBytes: 4), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalBodyTooLarge', + ), + ), + ); + + final redirects = _FakeHttpClient([ + _FakeResponse(302, headers: {'location': '/two'}), + _FakeResponse(302, headers: {'location': '/three'}), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => redirects, + limits: const WebCalHttpLimits(maximumRedirects: 1), + ).get(Uri.parse('https://calendar.example.test/one')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalRedirectLimitExceeded', + ), + ), + ); + }); + + test('enforces stream inactivity and total deadlines', () async { + final idleController = StreamController>(); + addTearDown(idleController.close); + final idle = _FakeHttpClient([ + _FakeResponse(200, stream: idleController.stream), + ]); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => idle, + limits: const WebCalHttpLimits( + streamInactivityTimeout: Duration(milliseconds: 5), + totalDeadline: Duration(seconds: 1), + ), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalStreamInactivityTimeout', + ), + ), + ); + + final total = _FakeHttpClient(const [], neverConnect: true); + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => total, + limits: const WebCalHttpLimits( + totalDeadline: Duration(milliseconds: 5), + ), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalTotalTimeout', + ), + ), + ); + }); + + test('marks a response conditional only when validators were sent', () async { + final target = Uri.parse('https://calendar.example.test/feed'); + final conditional = _FakeHttpClient([_FakeResponse(304)]); + final response = + await IoWebCalHttpTransport(clientFactory: () => conditional).get( + target, + validators: const WebCalHttpValidators(etag: '"one"'), + validatorTarget: target, + ); + expect(response.conditionalRequestSent, isTrue); + + final unbound = _FakeHttpClient([_FakeResponse(304)]); + final unboundResponse = + await IoWebCalHttpTransport(clientFactory: () => unbound).get( + target, + validators: const WebCalHttpValidators(etag: '"one"'), + validatorTarget: Uri.parse('https://other.example.test/feed'), + ); + expect(unboundResponse.conditionalRequestSent, isFalse); + }); +} + +final class _FakeHttpClient implements HttpClient { + _FakeHttpClient(this.responses, {this.neverConnect = false}); + + final List<_FakeResponse> responses; + final bool neverConnect; + final requests = <_FakeRequest>[]; + + @override + Future getUrl(Uri url) { + if (neverConnect) return Completer().future; + final request = _FakeRequest(url, responses[requests.length]); + requests.add(request); + return Future.value(request); + } + + @override + set connectionTimeout(Duration? value) {} + + @override + set autoUncompress(bool value) {} + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeRequest implements HttpClientRequest { + _FakeRequest(this.uri, this.response); + + @override + final Uri uri; + final _FakeResponse response; + final _FakeHeaders _headers = _FakeHeaders(); + + @override + HttpHeaders get headers => _headers; + + Map get recordedHeaders => _headers.values; + + @override + set followRedirects(bool value) {} + + @override + set maxRedirects(int value) {} + + @override + Future close() async => response; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeResponse extends Stream> + implements HttpClientResponse { + _FakeResponse( + this.statusCode, { + Map headers = const {}, + List> chunks = const [], + Stream>? stream, + }) : _headers = _FakeHeaders(headers), + _stream = stream ?? Stream>.fromIterable(chunks); + + @override + final int statusCode; + final _FakeHeaders _headers; + final Stream> _stream; + + @override + HttpHeaders get headers => _headers; + + @override + StreamSubscription> listen( + void Function(List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => _stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeHeaders implements HttpHeaders { + _FakeHeaders([Map initial = const {}]) + : values = { + for (final entry in initial.entries) + entry.key.toLowerCase(): entry.value, + }; + + final Map values; + + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) { + values[name.toLowerCase()] = value.toString(); + } + + @override + void removeAll(String name) => values.remove(name.toLowerCase()); + + @override + String? value(String name) => values[name.toLowerCase()]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/webcal/webcal_read_only_test.dart b/test/webcal/webcal_read_only_test.dart new file mode 100644 index 00000000..57139bcb --- /dev/null +++ b/test/webcal/webcal_read_only_test.dart @@ -0,0 +1,191 @@ +import 'package:busymax/src/calendar_providers/calendar_mutation.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:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late CalendarRepository repository; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + repository = CalendarRepository(database: database); + await _seed(database); + }); + + tearDown(() async => database.close()); + + test('rejects create, edit, delete, move, and RSVP for WebCal', () async { + final createDraft = EventEditorDraft.newEvent( + accountId: 'webcal-account-sub', + sourceId: 'webcal-calendar-sub', + providerCalendarId: 'sub', + start: DateTime(2026, 8, 30, 9), + end: DateTime(2026, 8, 30, 10), + ).copyWith(title: 'Not allowed'); + await expectLater( + repository.createLocalEvent(createDraft), + throwsA(isA()), + ); + + final webCalDetail = (await repository.loadEventDetail('webcal-event'))!; + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(webCalDetail).copyWith(title: 'Edit'), + ), + throwsA(isA()), + ); + await expectLater( + repository.deleteLocalEvent('webcal-event'), + throwsA(isA()), + ); + await expectLater( + repository.respondToLocalEvent( + 'webcal-event', + CalendarInvitationResponse.accept, + ), + throwsA(isA()), + ); + + final cloudDetail = (await repository.loadEventDetail('google-event'))!; + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(cloudDetail).copyWith( + accountId: 'webcal-account-sub', + sourceId: 'webcal-calendar-sub', + providerCalendarId: 'sub', + ), + ), + throwsA(isA()), + ); + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(webCalDetail).copyWith( + accountId: 'google-account', + sourceId: 'google-calendar', + providerCalendarId: 'primary', + ), + ), + throwsA(isA()), + ); + + expect(await database.select(database.pendingOps).get(), isEmpty); + }); +} + +Future _seed(AppDatabase database) async { + for (final account in [ + ( + id: 'webcal-account-sub', + provider: 'webcal', + authority: 'https://calendar.example.test', + providerId: 'fingerprint', + credential: 'webcal_subscription', + tasks: false, + ), + ( + id: 'google-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerId: 'me@example.test', + credential: 'oauth', + tasks: true, + ), + ]) { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: account.id, + provider: account.provider, + authority: account.authority, + providerAccountId: account.providerId, + credentialKind: account.credential, + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: Value(account.tasks), + grantedScopes: const Value(''), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + } + for (final source in [ + ( + id: 'webcal-calendar-sub', + accountId: 'webcal-account-sub', + provider: 'webcal', + calendarId: 'sub', + readOnly: true, + ), + ( + id: 'google-calendar', + accountId: 'google-account', + provider: 'google', + calendarId: 'primary', + readOnly: false, + ), + ]) { + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: source.id, + accountId: source.accountId, + provider: source.provider, + providerCalendarId: source.calendarId, + summary: source.id, + readOnly: Value(source.readOnly), + accessRole: Value(source.readOnly ? 'reader' : 'owner'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + } + for (final event in [ + ( + id: 'webcal-event', + accountId: 'webcal-account-sub', + sourceId: 'webcal-calendar-sub', + provider: 'webcal', + calendarId: 'sub', + ), + ( + id: 'google-event', + accountId: 'google-account', + sourceId: 'google-calendar', + provider: 'google', + calendarId: 'primary', + ), + ]) { + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: event.id, + accountId: event.accountId, + calendarSourceId: event.sourceId, + provider: event.provider, + providerCalendarId: event.calendarId, + providerEventId: event.id, + title: event.id, + startDateTime: const Value('2026-08-30T09:00:00'), + startTimeZone: const Value('UTC'), + endDateTime: const Value('2026-08-30T10:00:00'), + endTimeZone: const Value('UTC'), + organizerJson: Value( + event.provider == 'google' ? '{"self":true}' : null, + ), + rawJson: const Value('{}'), + baselineRawJson: const Value('{}'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + } +} + +const _now = '2026-08-29T00:00:00.000Z'; diff --git a/test/webcal/webcal_subscription_service_test.dart b/test/webcal/webcal_subscription_service_test.dart new file mode 100644 index 00000000..c95a2326 --- /dev/null +++ b/test/webcal/webcal_subscription_service_test.dart @@ -0,0 +1,461 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/webcal/webcal_http_client.dart'; +import 'package:busymax/src/webcal/webcal_subscription_service.dart'; +import 'package:crypto/crypto.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late InMemorySecretStore secrets; + late _FakeTransport transport; + late WebCalSubscriptionService service; + late int notificationRebuilds; + final now = DateTime.utc(2026, 8, 29, 20); + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + secrets = InMemorySecretStore(); + transport = _FakeTransport(); + notificationRebuilds = 0; + service = WebCalSubscriptionService( + database: database, + secretStore: secrets, + httpTransport: transport, + nowUtc: () => now, + idFactory: () => 'subscription-1', + onNotificationScheduleChanged: () async => notificationRebuilds += 1, + ); + }); + + tearDown(() async => database.close()); + + test( + 'add stores a redacted read-only snapshot with stable identities', + () async { + const credential = + 'https://calendar.example.test/feed?private-token=secret'; + transport.responses.add( + _response( + uri: Uri.parse(credential), + etag: '"one"', + body: utf8.encode( + _calendar(''' +SOURCE:$credential +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Published event +URL:$credential +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT15M +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +'''), + ), + ), + ); + + final id = await service.addSubscription( + subscriptionUrl: credential, + localName: 'Team schedule', + color: '#336699', + refreshMode: WebCalRefreshMode.hourly, + ); + + expect(id, 'subscription-1'); + final account = await database.select(database.accounts).getSingle(); + expect(account.id, 'webcal-account-subscription-1'); + expect(account.provider, 'webcal'); + expect(account.credentialKind, 'webcal_subscription'); + expect(account.authority, 'https://calendar.example.test'); + expect(account.providerAccountId, isNot(contains('private-token'))); + expect(account.tasksEnabled, isFalse); + expect(await secrets.readActiveAccountId(), isNull); + + final source = await database + .select(database.calendarSources) + .getSingle(); + expect(source.id, 'webcal-calendar-subscription-1'); + expect(source.providerCalendarId, 'subscription-1'); + expect(source.readOnly, isTrue); + expect(source.accessRole, 'reader'); + expect(source.backgroundColor, '#336699'); + expect(source.rawJson, '{"transport":"webcal"}'); + + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect(subscription.safeOrigin, 'https://calendar.example.test'); + expect(subscription.snapshotIcsBody, isNot(contains(credential))); + expect(subscription.etag, '"one"'); + expect(subscription.refreshMode, 'hourly'); + expect( + DateTime.parse(subscription.nextRefreshAtUtc).difference(now), + greaterThanOrEqualTo(const Duration(hours: 1)), + ); + + final event = await database.select(database.calendarEvents).getSingle(); + final itemId = _stable('webcal-item', 'subscription-1\u0000uid-one'); + expect(event.providerEventId, itemId); + expect(event.id, _stable('webcal-event', '$itemId\u0000master')); + expect(event.syncStatus, 'synced'); + expect(event.webLink, isNull); + expect(event.rawJson, isNot(contains(credential))); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(notificationRebuilds, 1); + + final secret = + await secrets.readCredential(account.id) as WebCalSecretRecord; + expect(secret.normalizedSubscriptionUri, credential); + expect(secret.toString(), isNot(contains(credential))); + }, + ); + + test('duplicate add is rejected before another fetch', () async { + const uri = 'https://calendar.example.test/feed'; + transport.responses.add(_response(uri: Uri.parse(uri), body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri); + + await expectLater( + service.addSubscription( + subscriptionUrl: 'webcal://calendar.example.test/feed', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalDuplicateSubscription', + ), + ), + ); + expect(transport.requests, hasLength(1)); + }); + + test( + 'malformed refresh preserves snapshot, events, validators, and alarms', + () async { + const uri = 'https://calendar.example.test/feed'; + transport.responses.add( + _response(uri: Uri.parse(uri), etag: '"good"', body: _alarmedEvent), + ); + await service.addSubscription(subscriptionUrl: uri); + final beforeSubscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + final beforeEvents = await database.select(database.calendarEvents).get(); + final beforeNotifications = await database + .select(database.notificationSchedule) + .get(); + + transport.responses.add( + _response( + uri: Uri.parse(uri), + etag: '"bad"', + body: utf8.encode('not a calendar'), + contentType: 'text/html', + ), + ); + await expectLater( + service.refreshSubscription('subscription-1', force: true), + throwsA(isA()), + ); + + final afterSubscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect( + afterSubscription.snapshotIcsBody, + beforeSubscription.snapshotIcsBody, + ); + expect(afterSubscription.semanticHash, beforeSubscription.semanticHash); + expect(afterSubscription.etag, '"good"'); + expect( + afterSubscription.validatorTargetFingerprint, + beforeSubscription.validatorTargetFingerprint, + ); + expect(afterSubscription.lastFailureCode, 'WebCalContentTypeRejected'); + expect(afterSubscription.consecutiveFailureCount, 1); + expect( + (await database.select(database.calendarEvents).get()).map( + (row) => row.id, + ), + beforeEvents.map((row) => row.id), + ); + expect( + (await database.select(database.notificationSchedule).get()).map( + (row) => row.id, + ), + beforeNotifications.map((row) => row.id), + ); + expect(notificationRebuilds, 1); + }, + ); + + test('conditional 304 is accepted only for the bound target', () async { + final origin = Uri.parse('https://calendar.example.test/feed'); + final target = Uri.parse('https://cdn.example.test/current.ics'); + transport.responses.add( + _response(uri: target, etag: '"one"', body: _oneEvent), + ); + await service.addSubscription(subscriptionUrl: origin.toString()); + transport.responses.add( + WebCalHttpResponse( + statusCode: 304, + finalUri: target, + body: null, + etag: '"one"', + lastModified: null, + contentType: null, + conditionalRequestSent: true, + ), + ); + + await service.refreshSubscription('subscription-1', force: true); + + expect(transport.requests.last.uri, target); + expect(transport.requests.last.validatorTarget, target); + expect(transport.requests.last.validators.etag, '"one"'); + expect( + (await database.select(database.webCalSubscriptions).getSingle()) + .consecutiveFailureCount, + 0, + ); + + transport.responses.add( + WebCalHttpResponse( + statusCode: 304, + finalUri: target, + body: null, + etag: null, + lastModified: null, + contentType: null, + conditionalRequestSent: false, + ), + ); + await expectLater( + service.refreshSubscription('subscription-1', force: true), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalInvalidNotModified', + ), + ), + ); + }); + + test('empty successful snapshot clears events atomically', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + expect(await database.select(database.calendarEvents).get(), isNotEmpty); + + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +BEGIN:VFREEBUSY +UID:freebusy +DTSTART:20260830T000000Z +DTEND:20260831T000000Z +END:VFREEBUSY +'''), + ), + ), + ); + await service.refreshSubscription('subscription-1', force: true); + + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect( + (await database.select(database.webCalSubscriptions).getSingle()) + .snapshotIcsBody, + contains('VFREEBUSY'), + ); + }); + + test('single-event identity survives text and time changes', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + final before = await database.select(database.calendarEvents).getSingle(); + + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:uid-one +DTSTART:20260902T180000Z +DTEND:20260902T193000Z +SEQUENCE:8 +SUMMARY:Changed title and time +END:VEVENT +'''), + ), + ), + ); + await service.refreshSubscription('subscription-1', force: true); + + final after = await database.select(database.calendarEvents).getSingle(); + expect(after.id, before.id); + expect(after.providerEventId, before.providerEventId); + expect(after.title, 'Changed title and time'); + expect(after.startDateTime, '2026-09-02T18:00:00.000Z'); + }); + + test( + 'due-aware refresh skips early work and force bypasses due time', + () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +REFRESH-INTERVAL;VALUE=DURATION:PT12H +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:One +END:VEVENT +'''), + ), + ), + ); + await service.addSubscription(subscriptionUrl: uri.toString()); + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect(subscription.serverRefreshIntervalSeconds, 12 * 60 * 60); + expect( + DateTime.parse(subscription.nextRefreshAtUtc).difference(now), + greaterThanOrEqualTo(const Duration(hours: 12)), + ); + + await service.refreshSubscription('subscription-1'); + expect(transport.requests, hasLength(1)); + + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.refreshSubscription('subscription-1', force: true); + expect(transport.requests, hasLength(2)); + }, + ); + + test('rename, color, and unsubscribe remain local and cascade', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + + await service.renameSubscription('subscription-1', 'Renamed'); + await service.changeSubscriptionColor('subscription-1', '#AABBCC'); + expect( + (await database.select(database.accounts).getSingle()).displayName, + 'Renamed', + ); + expect( + (await database.select(database.calendarSources).getSingle()).summary, + 'Renamed', + ); + expect( + (await database.select(database.calendarEvents).getSingle()).colorHex, + '#AABBCC', + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + + await service.unsubscribe('subscription-1'); + expect(await database.select(database.accounts).get(), isEmpty); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.webCalSubscriptions).get(), isEmpty); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect( + await secrets.readCredential('webcal-account-subscription-1'), + isNull, + ); + }); +} + +final class _FakeTransport implements WebCalHttpTransport { + final responses = []; + final requests = + <({Uri uri, WebCalHttpValidators validators, Uri? validatorTarget})>[]; + + @override + Future get( + Uri uri, { + WebCalHttpValidators validators = const WebCalHttpValidators(), + Uri? validatorTarget, + }) async { + requests.add(( + uri: uri, + validators: validators, + validatorTarget: validatorTarget, + )); + final next = responses.removeAt(0); + if (next is WebCalHttpResponse) return next; + throw next; + } +} + +WebCalHttpResponse _response({ + required Uri uri, + required List body, + String? etag, + String? contentType = 'text/calendar; charset=utf-8', +}) => WebCalHttpResponse( + statusCode: 200, + finalUri: uri, + body: Uint8List.fromList(body), + etag: etag, + lastModified: null, + contentType: contentType, + conditionalRequestSent: false, +); + +List get _oneEvent => utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:One +END:VEVENT +'''), +); + +List get _alarmedEvent => utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:uid-alarm +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Alarmed +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT15M +DESCRIPTION:Reminder +END:VALARM +END:VEVENT +'''), +); + +String _calendar(String components) => + '''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax WebCal Test//EN\r +${components.trim().replaceAll('\n', '\r\n')}\r +END:VCALENDAR\r +'''; + +String _stable(String prefix, String input) => + '$prefix-${sha256.convert(utf8.encode(input))}'; diff --git a/test/webcal/webcal_uri_test.dart b/test/webcal/webcal_uri_test.dart new file mode 100644 index 00000000..7d6d99f5 --- /dev/null +++ b/test/webcal/webcal_uri_test.dart @@ -0,0 +1,49 @@ +import 'package:busymax/src/webcal/webcal_uri.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('normalizes WebCal without changing credential components', () { + final normalized = normalizeWebCalUri( + ' webcal://Example.COM:8443/path%2Fcalendar?b=2&a=one%20two ', + ); + + expect( + normalized.uri.toString(), + 'https://example.com:8443/path%2Fcalendar?b=2&a=one%20two', + ); + expect(normalized.safeOrigin.toString(), 'https://example.com:8443'); + expect(normalized.fingerprint, webCalUriFingerprint(normalized.uri)); + }); + + test('default HTTPS port is omitted only from the safe origin', () { + final normalized = normalizeWebCalUri( + 'https://example.test:443/calendar?token=private', + ); + + expect(normalized.credentialUri, contains(':443/calendar')); + expect(normalized.safeOrigin.toString(), 'https://example.test'); + }); + + for (final invalid in [ + 'http://example.test/calendar', + 'ftp://example.test/calendar', + 'https://user:secret@example.test/calendar', + 'https://example.test/calendar#fragment', + 'webcal:calendar', + 'https://example.test:not-a-port/feed', + 'https://example.test:70000/feed', + ]) { + test('rejects $invalid', () { + expect( + () => normalizeWebCalUri(invalid), + throwsA(isA()), + ); + }); + } + + test('fingerprints distinguish exact normalized subscription targets', () { + final first = normalizeWebCalUri('webcal://example.test/feed?a=1&b=2'); + final second = normalizeWebCalUri('https://example.test/feed?b=2&a=1'); + expect(first.fingerprint, isNot(second.fingerprint)); + }); +} diff --git a/tools/install_linux_dev_desktop.sh b/tools/install_linux_dev_desktop.sh index 25e01929..43893a6b 100755 --- a/tools/install_linux_dev_desktop.sh +++ b/tools/install_linux_dev_desktop.sh @@ -110,7 +110,7 @@ GENERATED_DESKTOP="$TEMP_DIR/${APP_ID}.desktop" awk -v executable="$EXECUTABLE" -v icon="$ICON_DEST" ' /^Exec=/ { - print "Exec=\"" executable "\"" + print "Exec=\"" executable "\" %U" next } /^Icon=/ { From f545773fbba9398b77164c25482a0a949e399423 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 19:23:12 -0700 Subject: [PATCH 10/69] feat: enhance calendar and task management with ICS import and reminders; streamline task list handling --- docs/provider_support_matrix.md | 17 +- lib/l10n/app_ar.arb | 4 + lib/l10n/app_de.arb | 4 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 4 + lib/l10n/app_et.arb | 4 + lib/l10n/app_fa.arb | 4 + lib/l10n/app_fi.arb | 4 + lib/l10n/app_fr.arb | 4 + lib/l10n/app_hi.arb | 4 + lib/l10n/app_it.arb | 4 + lib/l10n/app_ja.arb | 4 + lib/l10n/app_ko.arb | 4 + lib/l10n/app_pt.arb | 4 + lib/l10n/app_ru.arb | 4 + lib/l10n/app_vi.arb | 4 + lib/l10n/app_zh.arb | 4 + lib/l10n/app_zh_Hans.arb | 4 + lib/l10n/app_zh_Hant.arb | 4 + lib/l10n/generated/app_localizations.dart | 24 + lib/l10n/generated/app_localizations_ar.dart | 14 + lib/l10n/generated/app_localizations_de.dart | 14 + lib/l10n/generated/app_localizations_en.dart | 14 + lib/l10n/generated/app_localizations_es.dart | 14 + lib/l10n/generated/app_localizations_et.dart | 14 + lib/l10n/generated/app_localizations_fa.dart | 14 + lib/l10n/generated/app_localizations_fi.dart | 14 + lib/l10n/generated/app_localizations_fr.dart | 14 + lib/l10n/generated/app_localizations_hi.dart | 14 + lib/l10n/generated/app_localizations_it.dart | 14 + lib/l10n/generated/app_localizations_ja.dart | 14 + lib/l10n/generated/app_localizations_ko.dart | 14 + lib/l10n/generated/app_localizations_pt.dart | 14 + lib/l10n/generated/app_localizations_ru.dart | 14 + lib/l10n/generated/app_localizations_vi.dart | 14 + lib/l10n/generated/app_localizations_zh.dart | 39 ++ lib/src/app/app_bootstrap.dart | 44 ++ lib/src/app/busymax_app.dart | 2 +- lib/src/app/busymax_design.dart | 3 + lib/src/app/busymax_glyphs.dart | 9 + ..._calendar_collection_mutation_service.dart | 431 ++++++++++++++ .../dav_collection_mutation_helpers.dart | 43 ++ .../dav_task_list_mutation_service.dart | 62 +- lib/src/db/app_database.g.dart | 79 ++- lib/src/db/migrations.dart | 14 +- lib/src/db/tables.dart | 2 + ...ount_collection_creation_capabilities.dart | 98 ++++ .../calendar_collection_creation_service.dart | 118 ++++ .../calendar/data/calendar_repository.dart | 4 +- .../presentation/ical_import_flow.dart | 182 ++++++ .../notification_schedule_service.dart | 15 + .../presentation/schedule_sidebar.dart | 412 +++++++------- .../presentation/settings_screen.dart | 93 +-- .../data/task_lists_repository.dart | 67 ++- test/app/rtl_glyphs_test.dart | 15 + ...ndar_collection_mutation_service_test.dart | 532 ++++++++++++++++++ .../dav_task_list_mutation_service_test.dart | 30 + test/db/app_database_test.dart | 64 ++- ...collection_creation_capabilities_test.dart | 133 +++++ ...ndar_collection_creation_service_test.dart | 190 +++++++ .../notification_schedule_service_test.dart | 24 + ...edule_sidebar_collection_actions_test.dart | 452 +++++++++++++++ .../presentation/schedule_views_test.dart | 11 +- .../presentation/settings_screen_test.dart | 54 +- .../data/task_lists_repository_test.dart | 103 ++++ 65 files changed, 3263 insertions(+), 385 deletions(-) create mode 100644 lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart create mode 100644 lib/src/dav/mutation/dav_collection_mutation_helpers.dart create mode 100644 lib/src/features/accounts/domain/account_collection_creation_capabilities.dart create mode 100644 lib/src/features/calendar/data/calendar_collection_creation_service.dart create mode 100644 lib/src/features/calendar/presentation/ical_import_flow.dart create mode 100644 test/dav/mutation/dav_calendar_collection_mutation_service_test.dart create mode 100644 test/features/accounts/domain/account_collection_creation_capabilities_test.dart create mode 100644 test/features/calendar/data/calendar_collection_creation_service_test.dart create mode 100644 test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart diff --git a/docs/provider_support_matrix.md b/docs/provider_support_matrix.md index 96d1d2f4..af73d5a0 100644 --- a/docs/provider_support_matrix.md +++ b/docs/provider_support_matrix.md @@ -11,6 +11,20 @@ access from a provider name. | Microsoft | Microsoft Calendar | Microsoft To Do | OAuth public client | | Apple iCloud | iCloud Calendar | Not supported; Apple Reminders is not supported | Apple app-specific password over HTTPS | | Nextcloud | `VEVENT` collections | `VTODO` collections | Login Flow v2 in the default browser | +| WebCal | Read-only calendar subscriptions | Not supported | Subscription URL stored as a secret | + +## Collection creation + +| Capability | Google | Microsoft | Nextcloud | Apple iCloud | WebCal | +|---|---|---|---|---|---| +| Create calendar | Yes | Yes | Yes | No | No | +| Create task list | Yes | Yes | Yes | No | No | + +Nextcloud event calendars are created remotely with CalDAV `MKCALENDAR` and +an event-only (`VEVENT`) component set. Nextcloud task lists continue to use +their existing `VTODO` collection implementation. This is Nextcloud-specific; +BusyMax does not claim generic CalDAV collection creation. Apple collection +creation remains unsupported, and WebCal subscriptions remain read-only. ## CalDAV synchronization @@ -24,7 +38,8 @@ access from a provider name. | Recurrence rules and exceptions | Supported | Supported | Supported; see the editable subset below | | `VALARM` preservation | Supported | Supported | Supported | | Read-only and shared collections | Supported | Supported | Supported | -| Collection create, rename, delete, or unshare | Not supported | Not supported | Supported online when permitted | +| Collection creation | Not supported | Supported online through `MKCALENDAR` | Supported online when permitted | +| Collection rename, delete, or unshare | Not supported | Not supported | Supported online when permitted | | Collection color or order editing | Not supported | Not supported | Not supported | | Cross-list task moves | Not applicable | Not applicable | Supported between writable Nextcloud task collections | | Clear completed tasks | Not applicable | Not applicable | Supported | diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 4fe9c789..82a82728 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "لا يدعم هذا المزوّد إدارة التقويمات في BusyMax.", "primaryCalendarCannotDelete": "لا يمكن حذف التقويم الأساسي.", "calendarCreateFailed": "تعذّر إنشاء التقويم: {error}", + "calendarCreatedRefreshPending": "تم إنشاء التقويم، لكن تعذّر على BusyMax تحديث الحساب. سيظهر بعد المزامنة التالية.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "تعذّر تحديث التقويم: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "إشعارات عند حدوث تعارضات", "notifyDueToday": "إشعارات المهام المستحقة اليوم", "eventReminders": "تذكيرات الأحداث", + "onState": "تشغيل", "taskReminders": "تذكيرات المهام", "notificationDetailLevel": "مستوى تفاصيل الإشعارات", "notificationDetailPrivate": "خاص", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "استيراد التقويم", + "calendarImportDescription": "حدّد ملفًا وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي سيتلقاها.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 18ce98f8..ad162541 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Dieser Anbieter unterstützt die Kalenderverwaltung in BusyMax nicht.", "primaryCalendarCannotDelete": "Der primäre Kalender kann nicht gelöscht werden.", "calendarCreateFailed": "Kalender konnte nicht erstellt werden: {error}", + "calendarCreatedRefreshPending": "Der Kalender wurde erstellt, aber BusyMax konnte das Konto nicht aktualisieren. Er erscheint nach der nächsten Synchronisierung.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Kalender konnte nicht aktualisiert werden: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -384,6 +385,7 @@ "notifyConflicts": "Benachrichtigungen bei Konflikten", "notifyDueToday": "Benachrichtigungen für heute fällige Aufgaben", "eventReminders": "Terminerinnerungen", + "onState": "Ein", "taskReminders": "Aufgabenerinnerungen", "notificationDetailLevel": "Detailgrad der Benachrichtigungen", "notificationDetailPrivate": "Privat", @@ -762,6 +764,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Kalender importieren", + "calendarImportDescription": "Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie dann den beschreibbaren Zielkalender.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e8b60bb5..9323e88f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -41,6 +41,7 @@ "primaryCalendarCannotDelete": "The primary calendar cannot be deleted.", "calendarCreateFailed": "Could not create the calendar: {error}", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarCreatedRefreshPending": "The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.", "calendarUpdateFailed": "Could not update the calendar: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarDeleteFailed": "Could not delete the calendar: {error}", @@ -514,6 +515,7 @@ "notifyConflicts": "Notifications on conflicts", "notifyDueToday": "Due-today notifications", "eventReminders": "Event reminders", + "onState": "On", "taskReminders": "Task reminders", "notificationDetailLevel": "Notification detail level", "notificationDetailPrivate": "Private", @@ -720,6 +722,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Calendar import", + "calendarImportDescription": "Select a file, review its events, then choose the writable calendar that should receive them.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d8b7788c..8792429a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Este proveedor no admite la gestión de calendarios en BusyMax.", "primaryCalendarCannotDelete": "El calendario principal no se puede eliminar.", "calendarCreateFailed": "No se pudo crear el calendario: {error}", + "calendarCreatedRefreshPending": "El calendario se creó, pero BusyMax no pudo actualizar la cuenta. Aparecerá después de la próxima sincronización.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "No se pudo actualizar el calendario: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -384,6 +385,7 @@ "notifyConflicts": "Notificaciones de conflictos", "notifyDueToday": "Notificaciones de tareas que vencen hoy", "eventReminders": "Recordatorios de eventos", + "onState": "Activado", "taskReminders": "Recordatorios de tareas", "notificationDetailLevel": "Nivel de detalle de las notificaciones", "notificationDetailPrivate": "Privado", @@ -762,6 +764,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Importar calendario", + "calendarImportDescription": "Selecciona un archivo, revisa sus eventos y elige el calendario editable que los recibirá.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index f9da772e..3d92123a 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "See teenusepakkuja ei toeta BusyMaxis kalendrite haldamist.", "primaryCalendarCannotDelete": "Põhikalendrit ei saa kustutada.", "calendarCreateFailed": "Kalendri loomine nurjus: {error}", + "calendarCreatedRefreshPending": "Kalender loodi, kuid BusyMax ei saanud kontot värskendada. See ilmub pärast järgmist sünkroonimist.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Kalendri värskendamine nurjus: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -450,6 +451,7 @@ "notifyConflicts": "Teavitused konfliktide korral", "notifyDueToday": "Täna tähtuvate ülesannete teavitused", "eventReminders": "Sündmuste meeldetuletused", + "onState": "Sees", "taskReminders": "Ülesannete meeldetuletused", "notificationDetailLevel": "Teavituste üksikasjalikkus", "notificationDetailPrivate": "Privaatne", @@ -877,6 +879,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Kalendri importimine", + "calendarImportDescription": "Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need imporditakse.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index e12c95b3..2f149d26 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "این ارائه‌دهنده از مدیریت تقویم در BusyMax پشتیبانی نمی‌کند.", "primaryCalendarCannotDelete": "تقویم اصلی را نمی‌توان حذف کرد.", "calendarCreateFailed": "ایجاد تقویم ممکن نشد: {error}", + "calendarCreatedRefreshPending": "تقویم ایجاد شد، اما BusyMax نتوانست حساب را تازه‌سازی کند. پس از همگام‌سازی بعدی نمایش داده می‌شود.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "به‌روزرسانی تقویم ممکن نشد: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "اعلان هنگام تداخل", "notifyDueToday": "اعلان کارهای دارای سررسید امروز", "eventReminders": "یادآورهای رویداد", + "onState": "روشن", "taskReminders": "یادآورهای کار", "notificationDetailLevel": "سطح جزئیات اعلان", "notificationDetailPrivate": "خصوصی", @@ -763,6 +765,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "وارد کردن تقویم", + "calendarImportDescription": "یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل‌نوشتن مقصد را برگزینید.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index ec061b98..9c32b7a5 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Tämä palveluntarjoaja ei tue kalenterien hallintaa BusyMaxissa.", "primaryCalendarCannotDelete": "Ensisijaista kalenteria ei voi poistaa.", "calendarCreateFailed": "Kalenteria ei voitu luoda: {error}", + "calendarCreatedRefreshPending": "Kalenteri luotiin, mutta BusyMax ei voinut päivittää tiliä. Se tulee näkyviin seuraavan synkronoinnin jälkeen.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Kalenteria ei voitu päivittää: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "Ilmoita ristiriidoista", "notifyDueToday": "Ilmoita tänään erääntyvistä", "eventReminders": "Tapahtumamuistutukset", + "onState": "Käytössä", "taskReminders": "Tehtävämuistutukset", "notificationDetailLevel": "Ilmoitusten yksityiskohtaisuus", "notificationDetailPrivate": "Yksityinen", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Kalenterin tuonti", + "calendarImportDescription": "Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten kirjoitettava kohdekalenteri.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 467fd173..2b4a4e12 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Ce fournisseur ne prend pas en charge la gestion des calendriers dans BusyMax.", "primaryCalendarCannotDelete": "Le calendrier principal ne peut pas être supprimé.", "calendarCreateFailed": "Impossible de créer le calendrier : {error}", + "calendarCreatedRefreshPending": "Le calendrier a été créé, mais BusyMax n’a pas pu actualiser le compte. Il apparaîtra après la prochaine synchronisation.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Impossible de mettre à jour le calendrier : {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -384,6 +385,7 @@ "notifyConflicts": "Notifications de conflits", "notifyDueToday": "Notifications des tâches dues aujourd’hui", "eventReminders": "Rappels d’événements", + "onState": "Activé", "taskReminders": "Rappels de tâches", "notificationDetailLevel": "Niveau de détail des notifications", "notificationDetailPrivate": "Privé", @@ -762,6 +764,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Importer un calendrier", + "calendarImportDescription": "Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui les recevra.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index c3dcf313..5af01e64 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "यह प्रदाता BusyMax में कैलेंडर प्रबंधन का समर्थन नहीं करता है।", "primaryCalendarCannotDelete": "प्राथमिक कैलेंडर को हटाया नहीं जा सकता।", "calendarCreateFailed": "कैलेंडर नहीं बनाया जा सका: {error}", + "calendarCreatedRefreshPending": "कैलेंडर बन गया, लेकिन BusyMax खाते को रीफ़्रेश नहीं कर सका। यह अगली सिंक के बाद दिखाई देगा।", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "कैलेंडर अपडेट नहीं किया जा सका: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "टकराव की सूचनाएँ", "notifyDueToday": "आज देय कार्यों की सूचनाएँ", "eventReminders": "ईवेंट रिमाइंडर", + "onState": "चालू", "taskReminders": "कार्य रिमाइंडर", "notificationDetailLevel": "सूचनाओं के विवरण का स्तर", "notificationDetailPrivate": "निजी", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "कैलेंडर आयात", + "calendarImportDescription": "फ़ाइल चुनें, उसके इवेंट की समीक्षा करें, फिर वह लिखने योग्य कैलेंडर चुनें जिसमें उन्हें आयात करना है।", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 7b9dff85..151106df 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Questo provider non supporta la gestione dei calendari in BusyMax.", "primaryCalendarCannotDelete": "Il calendario principale non può essere eliminato.", "calendarCreateFailed": "Impossibile creare il calendario: {error}", + "calendarCreatedRefreshPending": "Il calendario è stato creato, ma BusyMax non ha potuto aggiornare l’account. Apparirà dopo la prossima sincronizzazione.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Impossibile aggiornare il calendario: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "Notifiche in caso di conflitto", "notifyDueToday": "Notifiche per attività in scadenza oggi", "eventReminders": "Promemoria degli eventi", + "onState": "Attivato", "taskReminders": "Promemoria delle attività", "notificationDetailLevel": "Livello di dettaglio delle notifiche", "notificationDetailPrivate": "Privato", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Importa calendario", + "calendarImportDescription": "Seleziona un file, controlla gli eventi e scegli il calendario scrivibile in cui importarli.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b3ad0a49..c27e6ab0 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "このプロバイダーのカレンダー管理は BusyMax ではサポートされていません。", "primaryCalendarCannotDelete": "メインカレンダーは削除できません。", "calendarCreateFailed": "カレンダーを作成できませんでした: {error}", + "calendarCreatedRefreshPending": "カレンダーは作成されましたが、BusyMax はアカウントを更新できませんでした。次回の同期後に表示されます。", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "カレンダーを更新できませんでした: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "競合時に通知", "notifyDueToday": "今日が期限のタスクを通知", "eventReminders": "予定のリマインダー", + "onState": "オン", "taskReminders": "タスクのリマインダー", "notificationDetailLevel": "通知の詳細度", "notificationDetailPrivate": "非公開", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "カレンダーのインポート", + "calendarImportDescription": "ファイルを選択して予定を確認し、インポート先の書き込み可能なカレンダーを選択します。", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index dcd8fec4..296f97bf 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "이 공급자는 BusyMax에서 캘린더 관리를 지원하지 않습니다.", "primaryCalendarCannotDelete": "기본 캘린더는 삭제할 수 없습니다.", "calendarCreateFailed": "캘린더를 만들 수 없습니다: {error}", + "calendarCreatedRefreshPending": "캘린더가 생성되었지만 BusyMax에서 계정을 새로 고치지 못했습니다. 다음 동기화 후에 표시됩니다.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "캘린더를 업데이트할 수 없습니다: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "충돌 알림", "notifyDueToday": "오늘 마감인 할 일 알림", "eventReminders": "일정 미리 알림", + "onState": "켬", "taskReminders": "할 일 미리 알림", "notificationDetailLevel": "알림 세부 수준", "notificationDetailPrivate": "비공개", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "캘린더 가져오기", + "calendarImportDescription": "파일을 선택하고 일정을 검토한 다음 가져올 쓰기 가능한 캘린더를 선택하세요.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 506b7787..42233317 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Este provedor não oferece suporte ao gerenciamento de calendários no BusyMax.", "primaryCalendarCannotDelete": "O calendário principal não pode ser excluído.", "calendarCreateFailed": "Não foi possível criar o calendário: {error}", + "calendarCreatedRefreshPending": "O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Ele aparecerá após a próxima sincronização.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Não foi possível atualizar o calendário: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "Notificações de conflitos", "notifyDueToday": "Notificações de tarefas com prazo para hoje", "eventReminders": "Lembretes de eventos", + "onState": "Ativado", "taskReminders": "Lembretes de tarefas", "notificationDetailLevel": "Nível de detalhe das notificações", "notificationDetailPrivate": "Privado", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Importar calendário", + "calendarImportDescription": "Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os receberá.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a47ebfcd..546ce553 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Этот провайдер не поддерживает управление календарями в BusyMax.", "primaryCalendarCannotDelete": "Основной календарь нельзя удалить.", "calendarCreateFailed": "Не удалось создать календарь: {error}", + "calendarCreatedRefreshPending": "Календарь создан, но BusyMax не смог обновить учётную запись. Он появится после следующей синхронизации.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Не удалось обновить календарь: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "Уведомлять о конфликтах", "notifyDueToday": "Уведомлять о задачах на сегодня", "eventReminders": "Напоминания о событиях", + "onState": "Вкл.", "taskReminders": "Напоминания о задачах", "notificationDetailLevel": "Содержимое уведомлений", "notificationDetailPrivate": "Скрывать подробности", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Импорт календаря", + "calendarImportDescription": "Выберите файл, проверьте события, затем выберите доступный для записи календарь назначения.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 38115aa7..bd42641e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "Nhà cung cấp này không hỗ trợ quản lý lịch trong BusyMax.", "primaryCalendarCannotDelete": "Không thể xóa lịch chính.", "calendarCreateFailed": "Không thể tạo lịch: {error}", + "calendarCreatedRefreshPending": "Lịch đã được tạo nhưng BusyMax không thể làm mới tài khoản. Lịch sẽ xuất hiện sau lần đồng bộ tiếp theo.", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "Không thể cập nhật lịch: {error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "Thông báo khi có xung đột", "notifyDueToday": "Thông báo công việc đến hạn hôm nay", "eventReminders": "Lời nhắc sự kiện", + "onState": "Bật", "taskReminders": "Lời nhắc công việc", "notificationDetailLevel": "Mức độ chi tiết của thông báo", "notificationDetailPrivate": "Riêng tư", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "Nhập lịch", + "calendarImportDescription": "Chọn một tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a348843c..14ae02d6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", "primaryCalendarCannotDelete": "无法删除主日历。", "calendarCreateFailed": "无法创建日历:{error}", + "calendarCreatedRefreshPending": "日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "无法更新日历:{error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "冲突通知", "notifyDueToday": "今天到期任务通知", "eventReminders": "日程提醒", + "onState": "开启", "taskReminders": "任务提醒", "notificationDetailLevel": "通知详细程度", "notificationDetailPrivate": "私密", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "导入日历", + "calendarImportDescription": "选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index b43ded9f..a8370de3 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", "primaryCalendarCannotDelete": "无法删除主日历。", "calendarCreateFailed": "无法创建日历:{error}", + "calendarCreatedRefreshPending": "日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "无法更新日历:{error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "冲突通知", "notifyDueToday": "今天到期任务通知", "eventReminders": "日程提醒", + "onState": "开启", "taskReminders": "任务提醒", "notificationDetailLevel": "通知详细程度", "notificationDetailPrivate": "私密", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "导入日历", + "calendarImportDescription": "选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 0c5e535c..da1df534 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -40,6 +40,7 @@ "calendarManagementUnsupported": "此提供者不支援 BusyMax 中的行事曆管理。", "primaryCalendarCannotDelete": "無法刪除主要行事曆。", "calendarCreateFailed": "無法建立行事曆:{error}", + "calendarCreatedRefreshPending": "行事曆已建立,但 BusyMax 無法重新整理帳戶。它會在下次同步後顯示。", "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, "calendarUpdateFailed": "無法更新行事曆:{error}", "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, @@ -342,6 +343,7 @@ "notifyConflicts": "衝突通知", "notifyDueToday": "今天到期待辦事項通知", "eventReminders": "活動提醒", + "onState": "開啟", "taskReminders": "待辦事項提醒", "notificationDetailLevel": "通知詳細程度", "notificationDetailPrivate": "私人", @@ -699,6 +701,8 @@ "subscriptionOperationFailed": "Calendar subscription failed: {error}", "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, "subscriptions": "Subscriptions", + "calendarImport": "匯入行事曆", + "calendarImportDescription": "選擇檔案並檢查其中的行程,然後選擇要接收這些行程的可寫入行事曆。", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index d90d8674..fe716833 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -366,6 +366,12 @@ abstract class AppLocalizations { /// **'Could not create the calendar: {error}'** String calendarCreateFailed(String error); + /// No description provided for @calendarCreatedRefreshPending. + /// + /// In en, this message translates to: + /// **'The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.'** + String get calendarCreatedRefreshPending; + /// No description provided for @calendarUpdateFailed. /// /// In en, this message translates to: @@ -2910,6 +2916,12 @@ abstract class AppLocalizations { /// **'Event reminders'** String get eventReminders; + /// No description provided for @onState. + /// + /// In en, this message translates to: + /// **'On'** + String get onState; + /// No description provided for @taskReminders. /// /// In en, this message translates to: @@ -3984,6 +3996,18 @@ abstract class AppLocalizations { /// **'Subscriptions'** String get subscriptions; + /// No description provided for @calendarImport. + /// + /// In en, this message translates to: + /// **'Calendar import'** + String get calendarImport; + + /// No description provided for @calendarImportDescription. + /// + /// In en, this message translates to: + /// **'Select a file, review its events, then choose the writable calendar that should receive them.'** + String get calendarImportDescription; + /// No description provided for @importIcsFile. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index f52a7575..f9331981 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -141,6 +141,10 @@ class AppLocalizationsAr extends AppLocalizations { return 'تعذّر إنشاء التقويم: $error'; } + @override + String get calendarCreatedRefreshPending => + 'تم إنشاء التقويم، لكن تعذّر على BusyMax تحديث الحساب. سيظهر بعد المزامنة التالية.'; + @override String calendarUpdateFailed(String error) { return 'تعذّر تحديث التقويم: $error'; @@ -1592,6 +1596,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get eventReminders => 'تذكيرات الأحداث'; + @override + String get onState => 'تشغيل'; + @override String get taskReminders => 'تذكيرات المهام'; @@ -2224,6 +2231,13 @@ class AppLocalizationsAr extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'استيراد التقويم'; + + @override + String get calendarImportDescription => + 'حدّد ملفًا وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي سيتلقاها.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index b51b7844..2d2e29fb 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -141,6 +141,10 @@ class AppLocalizationsDe extends AppLocalizations { return 'Kalender konnte nicht erstellt werden: $error'; } + @override + String get calendarCreatedRefreshPending => + 'Der Kalender wurde erstellt, aber BusyMax konnte das Konto nicht aktualisieren. Er erscheint nach der nächsten Synchronisierung.'; + @override String calendarUpdateFailed(String error) { return 'Kalender konnte nicht aktualisiert werden: $error'; @@ -1593,6 +1597,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get eventReminders => 'Terminerinnerungen'; + @override + String get onState => 'Ein'; + @override String get taskReminders => 'Aufgabenerinnerungen'; @@ -2225,6 +2232,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Kalender importieren'; + + @override + String get calendarImportDescription => + 'Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie dann den beschreibbaren Zielkalender.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index b58d2a63..0a32ab7b 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -141,6 +141,10 @@ class AppLocalizationsEn extends AppLocalizations { return 'Could not create the calendar: $error'; } + @override + String get calendarCreatedRefreshPending => + 'The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.'; + @override String calendarUpdateFailed(String error) { return 'Could not update the calendar: $error'; @@ -1582,6 +1586,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eventReminders => 'Event reminders'; + @override + String get onState => 'On'; + @override String get taskReminders => 'Task reminders'; @@ -2206,6 +2213,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Calendar import'; + + @override + String get calendarImportDescription => + 'Select a file, review its events, then choose the writable calendar that should receive them.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 517b7c2e..52a7972b 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -143,6 +143,10 @@ class AppLocalizationsEs extends AppLocalizations { return 'No se pudo crear el calendario: $error'; } + @override + String get calendarCreatedRefreshPending => + 'El calendario se creó, pero BusyMax no pudo actualizar la cuenta. Aparecerá después de la próxima sincronización.'; + @override String calendarUpdateFailed(String error) { return 'No se pudo actualizar el calendario: $error'; @@ -1593,6 +1597,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get eventReminders => 'Recordatorios de eventos'; + @override + String get onState => 'Activado'; + @override String get taskReminders => 'Recordatorios de tareas'; @@ -2225,6 +2232,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Importar calendario'; + + @override + String get calendarImportDescription => + 'Selecciona un archivo, revisa sus eventos y elige el calendario editable que los recibirá.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index b22379c2..cef60c67 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -140,6 +140,10 @@ class AppLocalizationsEt extends AppLocalizations { return 'Kalendri loomine nurjus: $error'; } + @override + String get calendarCreatedRefreshPending => + 'Kalender loodi, kuid BusyMax ei saanud kontot värskendada. See ilmub pärast järgmist sünkroonimist.'; + @override String calendarUpdateFailed(String error) { return 'Kalendri värskendamine nurjus: $error'; @@ -1587,6 +1591,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get eventReminders => 'Sündmuste meeldetuletused'; + @override + String get onState => 'Sees'; + @override String get taskReminders => 'Ülesannete meeldetuletused'; @@ -2213,6 +2220,13 @@ class AppLocalizationsEt extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Kalendri importimine'; + + @override + String get calendarImportDescription => + 'Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need imporditakse.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 3361a23f..3fbd3bbb 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -140,6 +140,10 @@ class AppLocalizationsFa extends AppLocalizations { return 'ایجاد تقویم ممکن نشد: $error'; } + @override + String get calendarCreatedRefreshPending => + 'تقویم ایجاد شد، اما BusyMax نتوانست حساب را تازه‌سازی کند. پس از همگام‌سازی بعدی نمایش داده می‌شود.'; + @override String calendarUpdateFailed(String error) { return 'به‌روزرسانی تقویم ممکن نشد: $error'; @@ -1601,6 +1605,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get eventReminders => 'یادآورهای رویداد'; + @override + String get onState => 'روشن'; + @override String get taskReminders => 'یادآورهای کار'; @@ -2247,6 +2254,13 @@ class AppLocalizationsFa extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'وارد کردن تقویم'; + + @override + String get calendarImportDescription => + 'یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل‌نوشتن مقصد را برگزینید.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index faa5994a..0d76f426 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -143,6 +143,10 @@ class AppLocalizationsFi extends AppLocalizations { return 'Kalenteria ei voitu luoda: $error'; } + @override + String get calendarCreatedRefreshPending => + 'Kalenteri luotiin, mutta BusyMax ei voinut päivittää tiliä. Se tulee näkyviin seuraavan synkronoinnin jälkeen.'; + @override String calendarUpdateFailed(String error) { return 'Kalenteria ei voitu päivittää: $error'; @@ -1591,6 +1595,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get eventReminders => 'Tapahtumamuistutukset'; + @override + String get onState => 'Käytössä'; + @override String get taskReminders => 'Tehtävämuistutukset'; @@ -2219,6 +2226,13 @@ class AppLocalizationsFi extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Kalenterin tuonti'; + + @override + String get calendarImportDescription => + 'Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten kirjoitettava kohdekalenteri.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 45262224..b80cfa1e 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -142,6 +142,10 @@ class AppLocalizationsFr extends AppLocalizations { return 'Impossible de créer le calendrier : $error'; } + @override + String get calendarCreatedRefreshPending => + 'Le calendrier a été créé, mais BusyMax n’a pas pu actualiser le compte. Il apparaîtra après la prochaine synchronisation.'; + @override String calendarUpdateFailed(String error) { return 'Impossible de mettre à jour le calendrier : $error'; @@ -1591,6 +1595,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get eventReminders => 'Rappels d’événements'; + @override + String get onState => 'Activé'; + @override String get taskReminders => 'Rappels de tâches'; @@ -2223,6 +2230,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Importer un calendrier'; + + @override + String get calendarImportDescription => + 'Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui les recevra.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 7c89ef43..b7d698e1 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -143,6 +143,10 @@ class AppLocalizationsHi extends AppLocalizations { return 'कैलेंडर नहीं बनाया जा सका: $error'; } + @override + String get calendarCreatedRefreshPending => + 'कैलेंडर बन गया, लेकिन BusyMax खाते को रीफ़्रेश नहीं कर सका। यह अगली सिंक के बाद दिखाई देगा।'; + @override String calendarUpdateFailed(String error) { return 'कैलेंडर अपडेट नहीं किया जा सका: $error'; @@ -1588,6 +1592,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get eventReminders => 'ईवेंट रिमाइंडर'; + @override + String get onState => 'चालू'; + @override String get taskReminders => 'कार्य रिमाइंडर'; @@ -2213,6 +2220,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'कैलेंडर आयात'; + + @override + String get calendarImportDescription => + 'फ़ाइल चुनें, उसके इवेंट की समीक्षा करें, फिर वह लिखने योग्य कैलेंडर चुनें जिसमें उन्हें आयात करना है।'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index b84c465e..516a0c06 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -143,6 +143,10 @@ class AppLocalizationsIt extends AppLocalizations { return 'Impossibile creare il calendario: $error'; } + @override + String get calendarCreatedRefreshPending => + 'Il calendario è stato creato, ma BusyMax non ha potuto aggiornare l’account. Apparirà dopo la prossima sincronizzazione.'; + @override String calendarUpdateFailed(String error) { return 'Impossibile aggiornare il calendario: $error'; @@ -1594,6 +1598,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get eventReminders => 'Promemoria degli eventi'; + @override + String get onState => 'Attivato'; + @override String get taskReminders => 'Promemoria delle attività'; @@ -2225,6 +2232,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Importa calendario'; + + @override + String get calendarImportDescription => + 'Seleziona un file, controlla gli eventi e scegli il calendario scrivibile in cui importarli.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 95a26061..7dc388c0 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -140,6 +140,10 @@ class AppLocalizationsJa extends AppLocalizations { return 'カレンダーを作成できませんでした: $error'; } + @override + String get calendarCreatedRefreshPending => + 'カレンダーは作成されましたが、BusyMax はアカウントを更新できませんでした。次回の同期後に表示されます。'; + @override String calendarUpdateFailed(String error) { return 'カレンダーを更新できませんでした: $error'; @@ -1566,6 +1570,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eventReminders => '予定のリマインダー'; + @override + String get onState => 'オン'; + @override String get taskReminders => 'タスクのリマインダー'; @@ -2184,6 +2191,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'カレンダーのインポート'; + + @override + String get calendarImportDescription => + 'ファイルを選択して予定を確認し、インポート先の書き込み可能なカレンダーを選択します。'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 70c167b2..f9a74523 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -140,6 +140,10 @@ class AppLocalizationsKo extends AppLocalizations { return '캘린더를 만들 수 없습니다: $error'; } + @override + String get calendarCreatedRefreshPending => + '캘린더가 생성되었지만 BusyMax에서 계정을 새로 고치지 못했습니다. 다음 동기화 후에 표시됩니다.'; + @override String calendarUpdateFailed(String error) { return '캘린더를 업데이트할 수 없습니다: $error'; @@ -1566,6 +1570,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get eventReminders => '일정 미리 알림'; + @override + String get onState => '켬'; + @override String get taskReminders => '할 일 미리 알림'; @@ -2185,6 +2192,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => '캘린더 가져오기'; + + @override + String get calendarImportDescription => + '파일을 선택하고 일정을 검토한 다음 가져올 쓰기 가능한 캘린더를 선택하세요.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 3dee71b3..165e1710 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -143,6 +143,10 @@ class AppLocalizationsPt extends AppLocalizations { return 'Não foi possível criar o calendário: $error'; } + @override + String get calendarCreatedRefreshPending => + 'O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Ele aparecerá após a próxima sincronização.'; + @override String calendarUpdateFailed(String error) { return 'Não foi possível atualizar o calendário: $error'; @@ -1593,6 +1597,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get eventReminders => 'Lembretes de eventos'; + @override + String get onState => 'Ativado'; + @override String get taskReminders => 'Lembretes de tarefas'; @@ -2223,6 +2230,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Importar calendário'; + + @override + String get calendarImportDescription => + 'Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os receberá.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 7db7dd6f..6c01ae56 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -141,6 +141,10 @@ class AppLocalizationsRu extends AppLocalizations { return 'Не удалось создать календарь: $error'; } + @override + String get calendarCreatedRefreshPending => + 'Календарь создан, но BusyMax не смог обновить учётную запись. Он появится после следующей синхронизации.'; + @override String calendarUpdateFailed(String error) { return 'Не удалось обновить календарь: $error'; @@ -1595,6 +1599,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get eventReminders => 'Напоминания о событиях'; + @override + String get onState => 'Вкл.'; + @override String get taskReminders => 'Напоминания о задачах'; @@ -2226,6 +2233,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Импорт календаря'; + + @override + String get calendarImportDescription => + 'Выберите файл, проверьте события, затем выберите доступный для записи календарь назначения.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index ad254d00..fd2e2ab6 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -140,6 +140,10 @@ class AppLocalizationsVi extends AppLocalizations { return 'Không thể tạo lịch: $error'; } + @override + String get calendarCreatedRefreshPending => + 'Lịch đã được tạo nhưng BusyMax không thể làm mới tài khoản. Lịch sẽ xuất hiện sau lần đồng bộ tiếp theo.'; + @override String calendarUpdateFailed(String error) { return 'Không thể cập nhật lịch: $error'; @@ -1586,6 +1590,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get eventReminders => 'Lời nhắc sự kiện'; + @override + String get onState => 'Bật'; + @override String get taskReminders => 'Lời nhắc công việc'; @@ -2212,6 +2219,13 @@ class AppLocalizationsVi extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => 'Nhập lịch'; + + @override + String get calendarImportDescription => + 'Chọn một tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index fde9c678..2d59ad34 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -138,6 +138,10 @@ class AppLocalizationsZh extends AppLocalizations { return '无法创建日历:$error'; } + @override + String get calendarCreatedRefreshPending => + '日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。'; + @override String calendarUpdateFailed(String error) { return '无法更新日历:$error'; @@ -1556,6 +1560,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eventReminders => '日程提醒'; + @override + String get onState => '开启'; + @override String get taskReminders => '任务提醒'; @@ -2172,6 +2179,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => '导入日历'; + + @override + String get calendarImportDescription => '选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。'; + @override String get importIcsFile => 'Import .ics file'; @@ -2366,6 +2379,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '无法创建日历:$error'; } + @override + String get calendarCreatedRefreshPending => + '日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。'; + @override String calendarUpdateFailed(String error) { return '无法更新日历:$error'; @@ -3784,6 +3801,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get eventReminders => '日程提醒'; + @override + String get onState => '开启'; + @override String get taskReminders => '任务提醒'; @@ -4400,6 +4420,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => '导入日历'; + + @override + String get calendarImportDescription => '选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。'; + @override String get importIcsFile => 'Import .ics file'; @@ -4594,6 +4620,10 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { return '無法建立行事曆:$error'; } + @override + String get calendarCreatedRefreshPending => + '行事曆已建立,但 BusyMax 無法重新整理帳戶。它會在下次同步後顯示。'; + @override String calendarUpdateFailed(String error) { return '無法更新行事曆:$error'; @@ -6013,6 +6043,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get eventReminders => '活動提醒'; + @override + String get onState => '開啟'; + @override String get taskReminders => '待辦事項提醒'; @@ -6629,6 +6662,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get subscriptions => 'Subscriptions'; + @override + String get calendarImport => '匯入行事曆'; + + @override + String get calendarImportDescription => '選擇檔案並檢查其中的行程,然後選擇要接收這些行程的可寫入行事曆。'; + @override String get importIcsFile => 'Import .ics file'; diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 669a9bfa..423660c7 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -17,11 +17,13 @@ import '../dav/dav_provider_profile.dart'; import '../dav/discovery/dav_discovery_service.dart'; import '../dav/http/dav_http_transport.dart'; import '../dav/mutation/dav_conflict_repository.dart'; +import '../dav/mutation/dav_calendar_collection_mutation_service.dart'; import '../dav/mutation/dav_pending_operations.dart'; import '../dav/mutation/dav_task_list_mutation_service.dart'; import '../dav/sync/dav_account_sync_engine.dart'; import '../dav/storage/dav_settings_repository.dart'; import '../features/calendar/data/calendar_repository.dart'; +import '../features/calendar/data/calendar_collection_creation_service.dart'; import '../ical/ical_import_service.dart'; import '../features/accounts/data/accounts_repository.dart'; import '../features/auth/data/auth_repository.dart'; @@ -728,6 +730,44 @@ final davTaskListMutationClientForAccountProvider = refreshAfterMutation: () => ref .read(accountSyncOperationsProvider) .syncAccount(accountId, full: true), + requireNetwork: ref + .watch(networkConnectivityMonitorProvider) + .requireNetwork, + ); + }); + +final davCalendarCollectionMutationClientForAccountProvider = + Provider.family(( + ref, + accountId, + ) { + return DavCalendarCollectionMutationService( + database: ref.watch(databaseProvider), + secretStore: ref.watch(secretStoreProvider), + httpClient: ref.watch(baseHttpClientProvider), + accountId: accountId, + refreshAfterMutation: () => ref + .read(accountSyncOperationsProvider) + .syncAccount(accountId, full: true), + requireNetwork: ref + .watch(networkConnectivityMonitorProvider) + .requireNetwork, + ); + }); + +final calendarCollectionCreationServiceProvider = + Provider((ref) { + return CalendarCollectionCreationService( + accountsRepository: ref.watch(accountsRepositoryProvider), + calendarRepository: ref.watch(calendarRepositoryProvider), + davClientForAccount: (accountId) => ref.read( + davCalendarCollectionMutationClientForAccountProvider(accountId), + ), + requestCloudSynchronization: (accountId) => ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider(accountId), + ) + .request(), ); }); @@ -744,6 +784,8 @@ final taskListsRepositoryProvider = Provider((ref) { davTaskListMutationClientForAccountProvider(accountId), ), onMutationQueued: ref.watch(pendingMutationSyncRequesterProvider)?.request, + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), ); }); @@ -759,6 +801,8 @@ final taskListsRepositoryForAccountProvider = onMutationQueued: ref .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), ); }); diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index b4b1a781..6e766c2e 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -25,7 +25,7 @@ import 'busymax_design.dart'; import 'system_accent.dart'; import 'app_theme.dart'; import '../features/settings/presentation/settings_screen.dart'; -import '../features/schedule/presentation/schedule_sidebar.dart'; +import '../features/calendar/presentation/ical_import_flow.dart'; typedef BusyMaxTrayServiceFactory = BusyMaxTrayService Function(BusyMaxTrayServiceConfiguration configuration); diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index f5d626a9..63e85db5 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -542,6 +542,7 @@ class BusyMaxHeaderIconButton extends StatelessWidget { this.overlayColor, this.fixedSize, this.shape, + this.focusNode, }); final Widget icon; @@ -553,6 +554,7 @@ class BusyMaxHeaderIconButton extends StatelessWidget { final WidgetStateProperty? overlayColor; final Size? fixedSize; final OutlinedBorder? shape; + final FocusNode? focusNode; @override Widget build(BuildContext context) { @@ -561,6 +563,7 @@ class BusyMaxHeaderIconButton extends StatelessWidget { icon: icon, iconSize: iconSize, onPressed: onPressed, + focusNode: focusNode, style: busyMaxHeaderIconButtonStyle( context, diff --git a/lib/src/app/busymax_glyphs.dart b/lib/src/app/busymax_glyphs.dart index 912ba00b..da23c5c0 100644 --- a/lib/src/app/busymax_glyphs.dart +++ b/lib/src/app/busymax_glyphs.dart @@ -29,6 +29,15 @@ abstract final class BusyMaxGlyphs { if (icon == Icons.edit_outlined) { return 'document-edit-symbolic'; } + if (icon == Icons.palette_outlined) { + return 'color-select-symbolic'; + } + if (icon == Icons.notifications_outlined) { + return 'preferences-system-notifications-symbolic'; + } + if (icon == Icons.notifications_off_outlined) { + return 'notifications-disabled-symbolic'; + } if (icon == Icons.calendar_view_day_outlined || icon == YaruIcons.calendar_day) { return 'calendar-app-symbolic'; diff --git a/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart b/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart new file mode 100644 index 00000000..2f1938df --- /dev/null +++ b/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart @@ -0,0 +1,431 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:http/http.dart' as http; +import 'package:uuid/uuid.dart'; + +import '../../core/secrets/secret_store.dart'; +import '../../db/app_database.dart'; +import '../../providers/busy_provider.dart'; +import '../dav_errors.dart'; +import '../dav_provider_profile.dart'; +import '../http/dav_http_transport.dart'; +import '../xml/dav_xml.dart'; +import 'dav_collection_mutation_helpers.dart'; + +enum DavCalendarCollectionCreationStatus { created, createdRefreshPending } + +final class DavCalendarCollectionCreationResult { + const DavCalendarCollectionCreationResult(this.status); + + final DavCalendarCollectionCreationStatus status; + + bool get refreshPending => + status == DavCalendarCollectionCreationStatus.createdRefreshPending; +} + +abstract interface class DavCalendarCollectionMutationClient { + Future createEventCalendar( + String title, { + DavCancellationToken? cancellationToken, + }); +} + +/// Creates a Nextcloud VEVENT collection, then lets normal DAV discovery own +/// all local collection and calendar-source projection. +final class DavCalendarCollectionMutationService + implements DavCalendarCollectionMutationClient { + DavCalendarCollectionMutationService({ + required AppDatabase database, + required SecretStore secretStore, + required http.Client httpClient, + required String accountId, + required Future Function() refreshAfterMutation, + Future Function()? requireNetwork, + DavTransportLimits transportLimits = const DavTransportLimits(), + DavXmlParser xmlParser = const DavXmlParser(), + String Function()? correlationIdFactory, + }) : _database = database, + _secretStore = secretStore, + _httpClient = httpClient, + _accountId = accountId, + _refreshAfterMutation = refreshAfterMutation, + _requireNetwork = requireNetwork ?? _noNetworkCheck, + _transportLimits = transportLimits, + _xmlParser = xmlParser, + _correlationIdFactory = correlationIdFactory ?? const Uuid().v4; + + final AppDatabase _database; + final SecretStore _secretStore; + final http.Client _httpClient; + final String _accountId; + final Future Function() _refreshAfterMutation; + final Future Function() _requireNetwork; + final DavTransportLimits _transportLimits; + final DavXmlParser _xmlParser; + final String Function() _correlationIdFactory; + + @override + Future createEventCalendar( + String title, { + DavCancellationToken? cancellationToken, + }) async { + final displayName = _requiredCalendarTitle(title); + cancellationToken?.throwIfCancelled(); + await _requireNetwork(); + final context = await _loadContext(); + final existing = await _activeCollections(); + if (existing.any( + (collection) => + collection.eventProjectionEnabled && + collection.displayName == displayName, + )) { + throw const DavException( + kind: DavErrorKind.conflict, + code: 'DavCalendarNameConflict', + safeMessage: 'A Nextcloud calendar already uses this name.', + ); + } + + final homeUri = davCollectionUri(context.calendarHomeUri); + final memberName = nextcloudCollectionMemberName( + displayName, + homeUri: homeUri, + existingCollectionUris: [ + for (final collection in existing) Uri.parse(collection.requestUri), + ], + ); + final targetUri = homeUri.resolve('$memberName/'); + _validateTarget( + profile: context.profile, + authority: context.authority, + calendarHome: homeUri, + target: targetUri, + ); + + try { + final response = await context.transport.send( + DavRequest.xml( + method: 'MKCALENDAR', + uri: targetUri, + accountId: _accountId, + correlationId: _correlationIdFactory(), + body: _eventCalendarMkcalendarXml(displayName), + retryClass: DavRetryClass.never, + ), + credential: context.credential, + cancellationToken: cancellationToken, + ); + if (response.statusCode != HttpStatus.created) { + throw _calendarCreationStatusError( + response.statusCode, + correlationId: response.correlationId, + ); + } + } on DavException catch (error) { + if (!_calendarCreationMayHaveCommitted(error) || + !await _isMatchingEventCalendar( + context, + targetUri, + displayName: displayName, + cancellationToken: cancellationToken, + )) { + rethrow; + } + } + + try { + await _refreshAfterMutation(); + return const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.created, + ); + } on Object { + return const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.createdRefreshPending, + ); + } + } + + Future<_DavCalendarCollectionContext> _loadContext() async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); + if (account == null || + BusyProviderCodec.requireStorageValue(account.provider) != + BusyProvider.nextcloud) { + throw const DavException( + kind: DavErrorKind.protocol, + code: 'DavCalendarMutationRequiresNextcloud', + safeMessage: 'Calendar collection creation requires Nextcloud.', + ); + } + final authority = Uri.tryParse(account.authority); + if (authority == null) { + throw _invalidCalendarMutationContext(); + } + final secret = await _secretStore.readCredential(_accountId); + if (secret is! NextcloudSecretRecord || + secret.canonicalServer != authority || + secret.loginName != account.providerAccountId) { + throw const DavException( + kind: DavErrorKind.authentication, + code: 'DavCredentialsRevoked', + safeMessage: 'The Nextcloud credential is unavailable or invalid.', + ); + } + final service = await (_database.select( + _database.davAccountServices, + )..where((row) => row.accountId.equals(_accountId))).getSingleOrNull(); + final calendarHomeValue = service?.calendarHomeHref?.trim(); + final calendarHome = calendarHomeValue == null || calendarHomeValue.isEmpty + ? null + : Uri.tryParse(calendarHomeValue); + if (calendarHome == null) { + throw const DavException( + kind: DavErrorKind.protocol, + code: 'DavCalendarHomeUnavailable', + safeMessage: 'The Nextcloud calendar home is unavailable.', + ); + } + final profile = davProviderProfile( + BusyProvider.nextcloud, + nextcloudServer: authority, + ); + if (!profile.allowCollectionMutations || + !profile.isTrustedCredentialDestination( + calendarHome, + accountAuthority: authority, + )) { + throw _invalidCalendarMutationContext(); + } + return _DavCalendarCollectionContext( + authority: authority, + profile: profile, + transport: DavHttpTransport( + client: _httpClient, + profile: profile, + accountAuthority: authority, + limits: _transportLimits, + ), + credential: DavBasicCredential( + username: secret.loginName, + password: secret.appPassword, + ), + calendarHomeUri: calendarHome, + ); + } + + Future> _activeCollections() { + return (_database.select(_database.davCollections)..where( + (row) => + row.accountId.equals(_accountId) & + row.deleted.equals(false) & + row.serverMissing.equals(false), + )) + .get(); + } + + Future _isMatchingEventCalendar( + _DavCalendarCollectionContext context, + Uri uri, { + required String displayName, + DavCancellationToken? cancellationToken, + }) async { + try { + final response = await context.transport.send( + DavRequest.xml( + method: 'PROPFIND', + uri: uri, + accountId: _accountId, + correlationId: _correlationIdFactory(), + headers: const {'depth': '0'}, + body: _eventCalendarProbeXml, + retryClass: DavRetryClass.safeRead, + ), + credential: context.credential, + cancellationToken: cancellationToken, + ); + if (response.statusCode != HttpStatus.multiStatus) return false; + final multistatus = _xmlParser.parseMultistatus( + response.bodyBytes, + correlationId: response.correlationId, + ); + for (final item in multistatus.responses) { + final resourceType = item.successfulProperty( + davNamespace, + 'resourcetype', + ); + final components = item.successfulProperty( + caldavNamespace, + 'supported-calendar-component-set', + ); + final name = item + .successfulProperty(davNamespace, 'displayname') + ?.text + .trim(); + final isCollection = resourceType?.childElements.any( + (element) => + element.name.namespaceUri == davNamespace && + element.name.local == 'collection', + ); + final isCalendar = resourceType?.childElements.any( + (element) => + element.name.namespaceUri == caldavNamespace && + element.name.local == 'calendar', + ); + final supportsEvents = components?.childElements.any( + (element) => + element.name.namespaceUri == caldavNamespace && + element.name.local == 'comp' && + element.getAttribute('name')?.toUpperCase() == 'VEVENT', + ); + if (isCollection == true && + isCalendar == true && + supportsEvents == true && + name == displayName) { + return true; + } + } + } on Object { + return false; + } + return false; + } +} + +final class _DavCalendarCollectionContext { + const _DavCalendarCollectionContext({ + required this.authority, + required this.profile, + required this.transport, + required this.credential, + required this.calendarHomeUri, + }); + + final Uri authority; + final DavProviderProfile profile; + final DavHttpTransport transport; + final DavBasicCredential credential; + final Uri calendarHomeUri; +} + +void _validateTarget({ + required DavProviderProfile profile, + required Uri authority, + required Uri calendarHome, + required Uri target, +}) { + final home = davCollectionUri(calendarHome); + final targetPath = target.path; + final relativePath = targetPath.startsWith(home.path) + ? targetPath.substring(home.path.length) + : ''; + final memberPath = relativePath.endsWith('/') + ? relativePath.substring(0, relativePath.length - 1) + : relativePath; + if (!profile.isTrustedCredentialDestination( + target, + accountAuthority: authority, + ) || + target.query.isNotEmpty || + target.fragment.isNotEmpty || + !target.path.endsWith('/') || + relativePath.isEmpty || + memberPath.isEmpty || + memberPath.contains('/')) { + throw _invalidCalendarMutationContext(); + } +} + +String _requiredCalendarTitle(String title) { + final value = title.trim(); + if (value.isEmpty) { + throw ArgumentError.value(title, 'title', 'A calendar name is required.'); + } + return value; +} + +String _eventCalendarMkcalendarXml(String displayName) => + '' + '' + '' + '${escapeDavXmlText(displayName)}' + '' + '' + '' + '' + ''; + +const _eventCalendarProbeXml = + '' + '' + '' + '' + ''; + +bool _calendarCreationMayHaveCommitted(DavException error) => + switch (error.kind) { + DavErrorKind.timeout || + DavErrorKind.network || + DavErrorKind.server => true, + _ => false, + }; + +DavException _calendarCreationStatusError( + int statusCode, { + required String correlationId, +}) { + final mapped = switch (statusCode) { + HttpStatus.unauthorized => ( + DavErrorKind.authentication, + 'DavAuthRejected', + 'Nextcloud rejected the account credential.', + ), + HttpStatus.forbidden => ( + DavErrorKind.authorization, + 'DavCalendarParentNotWritable', + 'Nextcloud did not allow calendar creation.', + ), + HttpStatus.conflict || HttpStatus.preconditionFailed => ( + DavErrorKind.conflict, + 'DavCalendarCollectionConflict', + 'A Nextcloud calendar already exists at the selected location.', + ), + HttpStatus.methodNotAllowed || HttpStatus.notImplemented => ( + DavErrorKind.unsupportedComponent, + 'DavMkcalendarUnsupported', + 'This Nextcloud server does not support calendar creation.', + ), + HttpStatus.tooManyRequests => ( + DavErrorKind.rateLimited, + 'DavRateLimited', + 'Nextcloud temporarily limited calendar creation.', + ), + >= 500 => ( + DavErrorKind.server, + 'DavServerUnavailable', + 'Nextcloud could not complete calendar creation.', + ), + _ => ( + DavErrorKind.protocol, + 'DavCalendarCollectionCreationRejected', + 'Nextcloud rejected calendar creation.', + ), + }; + return DavException( + kind: mapped.$1, + code: mapped.$2, + safeMessage: mapped.$3, + statusCode: statusCode, + correlationId: correlationId, + ); +} + +DavException _invalidCalendarMutationContext() => const DavException( + kind: DavErrorKind.protocol, + code: 'DavCalendarMutationContextInvalid', + safeMessage: 'The Nextcloud calendar connection is invalid.', +); + +Future _noNetworkCheck() async {} diff --git a/lib/src/dav/mutation/dav_collection_mutation_helpers.dart b/lib/src/dav/mutation/dav_collection_mutation_helpers.dart new file mode 100644 index 00000000..d2a6a651 --- /dev/null +++ b/lib/src/dav/mutation/dav_collection_mutation_helpers.dart @@ -0,0 +1,43 @@ +Uri davCollectionUri(Uri uri) { + final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/'; + return uri.replace(path: path, query: null, fragment: null); +} + +String nextcloudCollectionMemberName( + String displayName, { + required Uri homeUri, + required Iterable existingCollectionUris, +}) { + var candidate = displayName + .toLowerCase() + .replaceAll(RegExp(r'\s+'), '-') + .replaceAll(RegExp(r'[^\w-]+'), '') + .replaceAll(RegExp(r'--+'), '-') + .replaceFirst(RegExp(r'^-+'), '') + .replaceFirst(RegExp(r'-+$'), ''); + if (candidate.isEmpty) candidate = '-'; + final home = davCollectionUri(homeUri); + final occupied = { + for (final uri in existingCollectionUris) davCollectionUri(uri).toString(), + }; + bool available(String value) => + !occupied.contains(davCollectionUri(home.resolve(value)).toString()); + if (available(candidate)) return candidate; + if (!candidate.contains('-')) { + candidate = '$candidate-1'; + if (available(candidate)) return candidate; + } + do { + final lastDash = candidate.lastIndexOf('-'); + final first = candidate.substring(0, lastDash); + final suffix = candidate.substring(lastDash + 1); + final number = int.tryParse(suffix); + candidate = number == null ? '$candidate-1' : '$first-${number + 1}'; + } while (!available(candidate)); + return candidate; +} + +String escapeDavXmlText(String value) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); diff --git a/lib/src/dav/mutation/dav_task_list_mutation_service.dart b/lib/src/dav/mutation/dav_task_list_mutation_service.dart index e67faa14..b5b8d469 100644 --- a/lib/src/dav/mutation/dav_task_list_mutation_service.dart +++ b/lib/src/dav/mutation/dav_task_list_mutation_service.dart @@ -13,6 +13,10 @@ import '../discovery/dav_discovery_models.dart'; import '../http/dav_http_transport.dart'; import '../storage/dav_collection_capabilities.dart'; import '../xml/dav_xml.dart'; +import 'dav_collection_mutation_helpers.dart'; + +export 'dav_collection_mutation_helpers.dart' + show nextcloudCollectionMemberName; const nextcloudDefaultTaskListColor = '#0082C9'; @@ -34,6 +38,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { required http.Client httpClient, required String accountId, required Future Function() refreshAfterMutation, + Future Function()? requireNetwork, DavTransportLimits transportLimits = const DavTransportLimits(), DavXmlParser xmlParser = const DavXmlParser(), String Function()? correlationIdFactory, @@ -42,6 +47,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { _httpClient = httpClient, _accountId = accountId, _refreshAfterMutation = refreshAfterMutation, + _requireNetwork = requireNetwork ?? _noNetworkCheck, _transportLimits = transportLimits, _xmlParser = xmlParser, _correlationIdFactory = correlationIdFactory ?? const Uuid().v4; @@ -51,6 +57,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { final http.Client _httpClient; final String _accountId; final Future Function() _refreshAfterMutation; + final Future Function() _requireNetwork; final DavTransportLimits _transportLimits; final DavXmlParser _xmlParser; final String Function() _correlationIdFactory; @@ -58,6 +65,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { @override Future createTaskList(String title) async { final displayName = _requiredTitle(title); + await _requireNetwork(); final context = await _loadContext(requireCalendarHome: true); final existing = await _activeCollections(); if (existing.any( @@ -72,7 +80,7 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { ); } - final homeUri = _collectionUri(context.calendarHomeUri!); + final homeUri = davCollectionUri(context.calendarHomeUri!); final memberName = nextcloudCollectionMemberName( displayName, homeUri: homeUri, @@ -387,40 +395,6 @@ final class DavTaskListMutationService implements DavTaskListMutationClient { } } -String nextcloudCollectionMemberName( - String displayName, { - required Uri homeUri, - required Iterable existingCollectionUris, -}) { - var candidate = displayName - .toLowerCase() - .replaceAll(RegExp(r'\s+'), '-') - .replaceAll(RegExp(r'[^\w-]+'), '') - .replaceAll(RegExp(r'--+'), '-') - .replaceFirst(RegExp(r'^-+'), '') - .replaceFirst(RegExp(r'-+$'), ''); - if (candidate.isEmpty) candidate = '-'; - final home = _collectionUri(homeUri); - final occupied = { - for (final uri in existingCollectionUris) _collectionUri(uri).toString(), - }; - bool available(String value) => - !occupied.contains(_collectionUri(home.resolve(value)).toString()); - if (available(candidate)) return candidate; - if (!candidate.contains('-')) { - candidate = '$candidate-1'; - if (available(candidate)) return candidate; - } - do { - final lastDash = candidate.lastIndexOf('-'); - final first = candidate.substring(0, lastDash); - final suffix = candidate.substring(lastDash + 1); - final number = int.tryParse(suffix); - candidate = number == null ? '$candidate-1' : '$first-${number + 1}'; - } while (!available(candidate)); - return candidate; -} - final class _DavTaskListContext { const _DavTaskListContext({ required this.transport, @@ -433,11 +407,6 @@ final class _DavTaskListContext { final Uri? calendarHomeUri; } -Uri _collectionUri(Uri uri) { - final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/'; - return uri.replace(path: path, query: null, fragment: null); -} - String _requiredTitle(String title) { final value = title.trim(); if (value.isEmpty) { @@ -446,11 +415,6 @@ String _requiredTitle(String title) { return value; } -String _xmlText(String value) => value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>'); - String _taskCollectionMkcolXml({ required String displayName, required String color, @@ -462,8 +426,8 @@ String _taskCollectionMkcolXml({ 'xmlns:o="$owncloudNamespace">' '' '' - '${_xmlText(displayName)}' - '${_xmlText(color)}' + '${escapeDavXmlText(displayName)}' + '${escapeDavXmlText(color)}' '1' '' '' @@ -475,7 +439,7 @@ String _displayNameProppatchXml(String displayName) => '' '' '' - '${_xmlText(displayName)}' + '${escapeDavXmlText(displayName)}' '' ''; @@ -573,3 +537,5 @@ String? _normalizedHrefPath(String? value) { } return path; } + +Future _noNetworkCheck() async {} diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart index 7ec99b3b..5fad58c6 100644 --- a/lib/src/db/app_database.g.dart +++ b/lib/src/db/app_database.g.dart @@ -7013,6 +7013,21 @@ class $TaskListsTable extends TaskLists ), defaultValue: const Constant(false), ); + static const VerificationMeta _remindersEnabledMeta = const VerificationMeta( + 'remindersEnabled', + ); + @override + late final GeneratedColumn remindersEnabled = GeneratedColumn( + 'reminders_enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("reminders_enabled" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); static const VerificationMeta _lastSyncedAtUtcMeta = const VerificationMeta( 'lastSyncedAtUtc', ); @@ -7067,6 +7082,7 @@ class $TaskListsTable extends TaskLists serverMissing, localDirty, pendingDelete, + remindersEnabled, lastSyncedAtUtc, createdLocalAtUtc, updatedLocalAtUtc, @@ -7205,6 +7221,15 @@ class $TaskListsTable extends TaskLists ), ); } + if (data.containsKey('reminders_enabled')) { + context.handle( + _remindersEnabledMeta, + remindersEnabled.isAcceptableOrUnknown( + data['reminders_enabled']!, + _remindersEnabledMeta, + ), + ); + } if (data.containsKey('last_synced_at_utc')) { context.handle( _lastSyncedAtUtcMeta, @@ -7313,6 +7338,10 @@ class $TaskListsTable extends TaskLists DriftSqlType.bool, data['${effectivePrefix}pending_delete'], )!, + remindersEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}reminders_enabled'], + )!, lastSyncedAtUtc: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}last_synced_at_utc'], @@ -7352,6 +7381,7 @@ class TaskList extends DataClass implements Insertable { final bool serverMissing; final bool localDirty; final bool pendingDelete; + final bool remindersEnabled; final String? lastSyncedAtUtc; final String createdLocalAtUtc; final String updatedLocalAtUtc; @@ -7373,6 +7403,7 @@ class TaskList extends DataClass implements Insertable { required this.serverMissing, required this.localDirty, required this.pendingDelete, + required this.remindersEnabled, this.lastSyncedAtUtc, required this.createdLocalAtUtc, required this.updatedLocalAtUtc, @@ -7417,6 +7448,7 @@ class TaskList extends DataClass implements Insertable { map['server_missing'] = Variable(serverMissing); map['local_dirty'] = Variable(localDirty); map['pending_delete'] = Variable(pendingDelete); + map['reminders_enabled'] = Variable(remindersEnabled); if (!nullToAbsent || lastSyncedAtUtc != null) { map['last_synced_at_utc'] = Variable(lastSyncedAtUtc); } @@ -7460,6 +7492,7 @@ class TaskList extends DataClass implements Insertable { serverMissing: Value(serverMissing), localDirty: Value(localDirty), pendingDelete: Value(pendingDelete), + remindersEnabled: Value(remindersEnabled), lastSyncedAtUtc: lastSyncedAtUtc == null && nullToAbsent ? const Value.absent() : Value(lastSyncedAtUtc), @@ -7493,6 +7526,7 @@ class TaskList extends DataClass implements Insertable { serverMissing: serializer.fromJson(json['serverMissing']), localDirty: serializer.fromJson(json['localDirty']), pendingDelete: serializer.fromJson(json['pendingDelete']), + remindersEnabled: serializer.fromJson(json['remindersEnabled']), lastSyncedAtUtc: serializer.fromJson(json['lastSyncedAtUtc']), createdLocalAtUtc: serializer.fromJson(json['createdLocalAtUtc']), updatedLocalAtUtc: serializer.fromJson(json['updatedLocalAtUtc']), @@ -7519,6 +7553,7 @@ class TaskList extends DataClass implements Insertable { 'serverMissing': serializer.toJson(serverMissing), 'localDirty': serializer.toJson(localDirty), 'pendingDelete': serializer.toJson(pendingDelete), + 'remindersEnabled': serializer.toJson(remindersEnabled), 'lastSyncedAtUtc': serializer.toJson(lastSyncedAtUtc), 'createdLocalAtUtc': serializer.toJson(createdLocalAtUtc), 'updatedLocalAtUtc': serializer.toJson(updatedLocalAtUtc), @@ -7543,6 +7578,7 @@ class TaskList extends DataClass implements Insertable { bool? serverMissing, bool? localDirty, bool? pendingDelete, + bool? remindersEnabled, Value lastSyncedAtUtc = const Value.absent(), String? createdLocalAtUtc, String? updatedLocalAtUtc, @@ -7570,6 +7606,7 @@ class TaskList extends DataClass implements Insertable { serverMissing: serverMissing ?? this.serverMissing, localDirty: localDirty ?? this.localDirty, pendingDelete: pendingDelete ?? this.pendingDelete, + remindersEnabled: remindersEnabled ?? this.remindersEnabled, lastSyncedAtUtc: lastSyncedAtUtc.present ? lastSyncedAtUtc.value : this.lastSyncedAtUtc, @@ -7609,6 +7646,9 @@ class TaskList extends DataClass implements Insertable { pendingDelete: data.pendingDelete.present ? data.pendingDelete.value : this.pendingDelete, + remindersEnabled: data.remindersEnabled.present + ? data.remindersEnabled.value + : this.remindersEnabled, lastSyncedAtUtc: data.lastSyncedAtUtc.present ? data.lastSyncedAtUtc.value : this.lastSyncedAtUtc, @@ -7641,6 +7681,7 @@ class TaskList extends DataClass implements Insertable { ..write('serverMissing: $serverMissing, ') ..write('localDirty: $localDirty, ') ..write('pendingDelete: $pendingDelete, ') + ..write('remindersEnabled: $remindersEnabled, ') ..write('lastSyncedAtUtc: $lastSyncedAtUtc, ') ..write('createdLocalAtUtc: $createdLocalAtUtc, ') ..write('updatedLocalAtUtc: $updatedLocalAtUtc') @@ -7649,7 +7690,7 @@ class TaskList extends DataClass implements Insertable { } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ accountId, id, davCollectionId, @@ -7667,10 +7708,11 @@ class TaskList extends DataClass implements Insertable { serverMissing, localDirty, pendingDelete, + remindersEnabled, lastSyncedAtUtc, createdLocalAtUtc, updatedLocalAtUtc, - ); + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -7692,6 +7734,7 @@ class TaskList extends DataClass implements Insertable { other.serverMissing == this.serverMissing && other.localDirty == this.localDirty && other.pendingDelete == this.pendingDelete && + other.remindersEnabled == this.remindersEnabled && other.lastSyncedAtUtc == this.lastSyncedAtUtc && other.createdLocalAtUtc == this.createdLocalAtUtc && other.updatedLocalAtUtc == this.updatedLocalAtUtc); @@ -7715,6 +7758,7 @@ class TaskListsCompanion extends UpdateCompanion { final Value serverMissing; final Value localDirty; final Value pendingDelete; + final Value remindersEnabled; final Value lastSyncedAtUtc; final Value createdLocalAtUtc; final Value updatedLocalAtUtc; @@ -7737,6 +7781,7 @@ class TaskListsCompanion extends UpdateCompanion { this.serverMissing = const Value.absent(), this.localDirty = const Value.absent(), this.pendingDelete = const Value.absent(), + this.remindersEnabled = const Value.absent(), this.lastSyncedAtUtc = const Value.absent(), this.createdLocalAtUtc = const Value.absent(), this.updatedLocalAtUtc = const Value.absent(), @@ -7760,6 +7805,7 @@ class TaskListsCompanion extends UpdateCompanion { this.serverMissing = const Value.absent(), this.localDirty = const Value.absent(), this.pendingDelete = const Value.absent(), + this.remindersEnabled = const Value.absent(), this.lastSyncedAtUtc = const Value.absent(), required String createdLocalAtUtc, required String updatedLocalAtUtc, @@ -7788,6 +7834,7 @@ class TaskListsCompanion extends UpdateCompanion { Expression? serverMissing, Expression? localDirty, Expression? pendingDelete, + Expression? remindersEnabled, Expression? lastSyncedAtUtc, Expression? createdLocalAtUtc, Expression? updatedLocalAtUtc, @@ -7812,6 +7859,7 @@ class TaskListsCompanion extends UpdateCompanion { if (serverMissing != null) 'server_missing': serverMissing, if (localDirty != null) 'local_dirty': localDirty, if (pendingDelete != null) 'pending_delete': pendingDelete, + if (remindersEnabled != null) 'reminders_enabled': remindersEnabled, if (lastSyncedAtUtc != null) 'last_synced_at_utc': lastSyncedAtUtc, if (createdLocalAtUtc != null) 'created_local_at_utc': createdLocalAtUtc, if (updatedLocalAtUtc != null) 'updated_local_at_utc': updatedLocalAtUtc, @@ -7837,6 +7885,7 @@ class TaskListsCompanion extends UpdateCompanion { Value? serverMissing, Value? localDirty, Value? pendingDelete, + Value? remindersEnabled, Value? lastSyncedAtUtc, Value? createdLocalAtUtc, Value? updatedLocalAtUtc, @@ -7860,6 +7909,7 @@ class TaskListsCompanion extends UpdateCompanion { serverMissing: serverMissing ?? this.serverMissing, localDirty: localDirty ?? this.localDirty, pendingDelete: pendingDelete ?? this.pendingDelete, + remindersEnabled: remindersEnabled ?? this.remindersEnabled, lastSyncedAtUtc: lastSyncedAtUtc ?? this.lastSyncedAtUtc, createdLocalAtUtc: createdLocalAtUtc ?? this.createdLocalAtUtc, updatedLocalAtUtc: updatedLocalAtUtc ?? this.updatedLocalAtUtc, @@ -7923,6 +7973,9 @@ class TaskListsCompanion extends UpdateCompanion { if (pendingDelete.present) { map['pending_delete'] = Variable(pendingDelete.value); } + if (remindersEnabled.present) { + map['reminders_enabled'] = Variable(remindersEnabled.value); + } if (lastSyncedAtUtc.present) { map['last_synced_at_utc'] = Variable(lastSyncedAtUtc.value); } @@ -7958,6 +8011,7 @@ class TaskListsCompanion extends UpdateCompanion { ..write('serverMissing: $serverMissing, ') ..write('localDirty: $localDirty, ') ..write('pendingDelete: $pendingDelete, ') + ..write('remindersEnabled: $remindersEnabled, ') ..write('lastSyncedAtUtc: $lastSyncedAtUtc, ') ..write('createdLocalAtUtc: $createdLocalAtUtc, ') ..write('updatedLocalAtUtc: $updatedLocalAtUtc, ') @@ -31745,6 +31799,7 @@ typedef $$TaskListsTableCreateCompanionBuilder = Value serverMissing, Value localDirty, Value pendingDelete, + Value remindersEnabled, Value lastSyncedAtUtc, required String createdLocalAtUtc, required String updatedLocalAtUtc, @@ -31769,6 +31824,7 @@ typedef $$TaskListsTableUpdateCompanionBuilder = Value serverMissing, Value localDirty, Value pendingDelete, + Value remindersEnabled, Value lastSyncedAtUtc, Value createdLocalAtUtc, Value updatedLocalAtUtc, @@ -31905,6 +31961,11 @@ class $$TaskListsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get lastSyncedAtUtc => $composableBuilder( column: $table.lastSyncedAtUtc, builder: (column) => ColumnFilters(column), @@ -32051,6 +32112,11 @@ class $$TaskListsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get lastSyncedAtUtc => $composableBuilder( column: $table.lastSyncedAtUtc, builder: (column) => ColumnOrderings(column), @@ -32179,6 +32245,11 @@ class $$TaskListsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get remindersEnabled => $composableBuilder( + column: $table.remindersEnabled, + builder: (column) => column, + ); + GeneratedColumn get lastSyncedAtUtc => $composableBuilder( column: $table.lastSyncedAtUtc, builder: (column) => column, @@ -32286,6 +32357,7 @@ class $$TaskListsTableTableManager Value serverMissing = const Value.absent(), Value localDirty = const Value.absent(), Value pendingDelete = const Value.absent(), + Value remindersEnabled = const Value.absent(), Value lastSyncedAtUtc = const Value.absent(), Value createdLocalAtUtc = const Value.absent(), Value updatedLocalAtUtc = const Value.absent(), @@ -32308,6 +32380,7 @@ class $$TaskListsTableTableManager serverMissing: serverMissing, localDirty: localDirty, pendingDelete: pendingDelete, + remindersEnabled: remindersEnabled, lastSyncedAtUtc: lastSyncedAtUtc, createdLocalAtUtc: createdLocalAtUtc, updatedLocalAtUtc: updatedLocalAtUtc, @@ -32332,6 +32405,7 @@ class $$TaskListsTableTableManager Value serverMissing = const Value.absent(), Value localDirty = const Value.absent(), Value pendingDelete = const Value.absent(), + Value remindersEnabled = const Value.absent(), Value lastSyncedAtUtc = const Value.absent(), required String createdLocalAtUtc, required String updatedLocalAtUtc, @@ -32354,6 +32428,7 @@ class $$TaskListsTableTableManager serverMissing: serverMissing, localDirty: localDirty, pendingDelete: pendingDelete, + remindersEnabled: remindersEnabled, lastSyncedAtUtc: lastSyncedAtUtc, createdLocalAtUtc: createdLocalAtUtc, updatedLocalAtUtc: updatedLocalAtUtc, diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index c3efa1dc..e08f16c2 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; -const latestSchemaVersion = 11; +const latestSchemaVersion = 12; /// A recoverable, non-secret diagnostic raised when an on-disk schema cannot /// be migrated without guessing remote identity or losing synchronized data. @@ -57,6 +57,9 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { if (from < 11) { await _migrateToV11(migrator, database); } + if (from < 12) { + await _migrateToV12(migrator, database); + } await _createIndexes(database); await _verifyForeignKeys(database); }, @@ -66,6 +69,15 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { ); } +Future _migrateToV12(Migrator migrator, AppDatabase database) async { + await _addColumnIfMissing( + migrator, + database, + database.taskLists, + database.taskLists.remindersEnabled, + ); +} + Future _migrateToV11(Migrator migrator, AppDatabase database) async { // Rebuild Accounts so SQLite adopts the extended provider and credential // constraints while preserving every existing row and identifier. diff --git a/lib/src/db/tables.dart b/lib/src/db/tables.dart index 7e2c1e69..780d9719 100644 --- a/lib/src/db/tables.dart +++ b/lib/src/db/tables.dart @@ -216,6 +216,8 @@ class TaskLists extends Table { BoolColumn get localDirty => boolean().withDefault(const Constant(false))(); BoolColumn get pendingDelete => boolean().withDefault(const Constant(false))(); + BoolColumn get remindersEnabled => + boolean().withDefault(const Constant(true))(); TextColumn get lastSyncedAtUtc => text().nullable()(); TextColumn get createdLocalAtUtc => text()(); TextColumn get updatedLocalAtUtc => text()(); diff --git a/lib/src/features/accounts/domain/account_collection_creation_capabilities.dart b/lib/src/features/accounts/domain/account_collection_creation_capabilities.dart new file mode 100644 index 00000000..d1dd103d --- /dev/null +++ b/lib/src/features/accounts/domain/account_collection_creation_capabilities.dart @@ -0,0 +1,98 @@ +import '../../../providers/busy_provider.dart'; +import '../../connectivity/network_connectivity_service.dart'; +import '../data/accounts_repository.dart'; + +enum CalendarCollectionCreationMode { + unavailable, + cloudPendingOperation, + nextcloudDav, +} + +enum TaskListCreationMode { unavailable, cloudPendingOperation, nextcloudDav } + +/// Account-level collection creation policy. +/// +/// These modes describe how a collection is created. They are intentionally +/// separate from item-level write capabilities such as creating an event or a +/// task inside an existing collection. +final class AccountCollectionCreationCapabilities { + const AccountCollectionCreationCapabilities({ + required this.calendarMode, + required this.taskListMode, + required this.calendarActionEnabled, + required this.taskListActionEnabled, + }); + + factory AccountCollectionCreationCapabilities.resolve({ + required AccountEntity account, + required NetworkAvailability networkAvailability, + bool calendarCreationRunning = false, + bool taskListCreationRunning = false, + }) { + final providerModes = accountCollectionCreationModes(account.provider); + final calendarMode = account.calendarsEnabled + ? providerModes.calendarMode + : CalendarCollectionCreationMode.unavailable; + final taskListMode = account.tasksEnabled + ? providerModes.taskListMode + : TaskListCreationMode.unavailable; + final connected = account.isSignedIn; + final nextcloudOnline = networkAvailability != NetworkAvailability.offline; + return AccountCollectionCreationCapabilities( + calendarMode: calendarMode, + taskListMode: taskListMode, + calendarActionEnabled: + calendarMode != CalendarCollectionCreationMode.unavailable && + connected && + !calendarCreationRunning && + (calendarMode != CalendarCollectionCreationMode.nextcloudDav || + nextcloudOnline), + taskListActionEnabled: + taskListMode != TaskListCreationMode.unavailable && + connected && + !taskListCreationRunning && + (taskListMode != TaskListCreationMode.nextcloudDav || + nextcloudOnline), + ); + } + + final CalendarCollectionCreationMode calendarMode; + final TaskListCreationMode taskListMode; + final bool calendarActionEnabled; + final bool taskListActionEnabled; + + bool get supportsCalendarCreation => + calendarMode != CalendarCollectionCreationMode.unavailable; + bool get supportsTaskListCreation => + taskListMode != TaskListCreationMode.unavailable; + bool get hasActions => supportsCalendarCreation || supportsTaskListCreation; +} + +final class AccountCollectionCreationModes { + const AccountCollectionCreationModes({ + required this.calendarMode, + required this.taskListMode, + }); + + final CalendarCollectionCreationMode calendarMode; + final TaskListCreationMode taskListMode; +} + +AccountCollectionCreationModes accountCollectionCreationModes( + BusyProvider provider, +) => switch (provider) { + BusyProvider.google || + BusyProvider.microsoft => const AccountCollectionCreationModes( + calendarMode: CalendarCollectionCreationMode.cloudPendingOperation, + taskListMode: TaskListCreationMode.cloudPendingOperation, + ), + BusyProvider.nextcloud => const AccountCollectionCreationModes( + calendarMode: CalendarCollectionCreationMode.nextcloudDav, + taskListMode: TaskListCreationMode.nextcloudDav, + ), + BusyProvider.appleICloud || + BusyProvider.webCal => const AccountCollectionCreationModes( + calendarMode: CalendarCollectionCreationMode.unavailable, + taskListMode: TaskListCreationMode.unavailable, + ), +}; diff --git a/lib/src/features/calendar/data/calendar_collection_creation_service.dart b/lib/src/features/calendar/data/calendar_collection_creation_service.dart new file mode 100644 index 00000000..15ddcf35 --- /dev/null +++ b/lib/src/features/calendar/data/calendar_collection_creation_service.dart @@ -0,0 +1,118 @@ +import '../../../dav/mutation/dav_calendar_collection_mutation_service.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../accounts/domain/account_collection_creation_capabilities.dart'; +import 'calendar_repository.dart'; + +enum CalendarCollectionCreationOutcome { + queued, + created, + createdRefreshPending, +} + +final class CalendarCollectionCreationResult { + const CalendarCollectionCreationResult({ + required this.outcome, + this.localSourceId, + }); + + final CalendarCollectionCreationOutcome outcome; + final String? localSourceId; +} + +final class CalendarCollectionCreationException implements Exception { + const CalendarCollectionCreationException(this.safeMessage); + + final String safeMessage; + + @override + String toString() => safeMessage; +} + +typedef DavCalendarMutationClientForAccount = + DavCalendarCollectionMutationClient Function(String accountId); + +abstract interface class CalendarCollectionCreator { + Future createCalendar({ + required String accountId, + required String title, + }); +} + +/// Routes account-level calendar creation without mixing cloud pending +/// operations with DAV collection mutations. +final class CalendarCollectionCreationService + implements CalendarCollectionCreator { + CalendarCollectionCreationService({ + required AccountsRepository accountsRepository, + required CalendarRepository calendarRepository, + required DavCalendarMutationClientForAccount davClientForAccount, + required void Function(String accountId) requestCloudSynchronization, + }) : _accountsRepository = accountsRepository, + _calendarRepository = calendarRepository, + _davClientForAccount = davClientForAccount, + _requestCloudSynchronization = requestCloudSynchronization; + + final AccountsRepository _accountsRepository; + final CalendarRepository _calendarRepository; + final DavCalendarMutationClientForAccount _davClientForAccount; + final void Function(String accountId) _requestCloudSynchronization; + + @override + Future createCalendar({ + required String accountId, + required String title, + }) async { + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(title, 'title', 'A calendar name is required.'); + } + final account = await _accountsRepository.accountById(accountId); + if (account == null || !account.calendarsEnabled || !account.isSignedIn) { + throw const CalendarCollectionCreationException( + 'Calendar creation is unavailable for this account.', + ); + } + final mode = accountCollectionCreationModes(account.provider).calendarMode; + return switch (mode) { + CalendarCollectionCreationMode.cloudPendingOperation => + _createCloudCalendar(accountId, trimmedTitle), + CalendarCollectionCreationMode.nextcloudDav => _createNextcloudCalendar( + accountId, + trimmedTitle, + ), + CalendarCollectionCreationMode.unavailable => + throw const CalendarCollectionCreationException( + 'This provider does not support calendar creation.', + ), + }; + } + + Future _createCloudCalendar( + String accountId, + String title, + ) async { + final sourceId = await _calendarRepository.createLocalSource( + accountId: accountId, + summary: title, + ); + _requestCloudSynchronization(accountId); + return CalendarCollectionCreationResult( + outcome: CalendarCollectionCreationOutcome.queued, + localSourceId: sourceId, + ); + } + + Future _createNextcloudCalendar( + String accountId, + String title, + ) async { + final result = await _davClientForAccount( + accountId, + ).createEventCalendar(title); + return CalendarCollectionCreationResult( + outcome: result.refreshPending + ? CalendarCollectionCreationOutcome.createdRefreshPending + : CalendarCollectionCreationOutcome.created, + ); + } +} diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 44ae9f1f..2dd371d1 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -16,6 +16,7 @@ import '../../../dav/mutation/dav_pending_operations.dart'; import '../../../dav/mutation/dav_projection_mutations.dart'; import '../../../dav/storage/dav_object_repository.dart'; import '../../../db/app_database.dart'; +import '../../accounts/domain/account_collection_creation_capabilities.dart'; import '../../notifications/notification_schedule_service.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; import '../domain/event_move_policy.dart'; @@ -423,7 +424,8 @@ class CalendarRepository { _database.accounts, )..where((row) => row.id.equals(accountId))).getSingle(); final provider = BusyProviderCodec.requireStorageValue(account.provider); - if (!calendarManagementCapabilities(provider).supportsCreate) { + if (accountCollectionCreationModes(provider).calendarMode != + CalendarCollectionCreationMode.cloudPendingOperation) { throw CalendarMutationNotAllowed( operation: CalendarMutationOperation.createCalendar, sourceId: accountId, diff --git a/lib/src/features/calendar/presentation/ical_import_flow.dart b/lib/src/features/calendar/presentation/ical_import_flow.dart new file mode 100644 index 00000000..c1905ad6 --- /dev/null +++ b/lib/src/features/calendar/presentation/ical_import_flow.dart @@ -0,0 +1,182 @@ +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/app_bootstrap.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../ical/ical_import_service.dart'; +import '../../../ical/ical_ingestion.dart'; +import '../../../l10n/l10n.dart'; +import '../../../providers/busy_provider.dart'; +import '../data/calendar_repository.dart'; + +Future showIcsImportFlow( + BuildContext context, + WidgetRef ref, { + String? filePath, +}) async { + const typeGroup = XTypeGroup( + label: 'iCalendar', + extensions: ['ics'], + mimeTypes: ['text/calendar'], + ); + final file = filePath == null + ? await openFile(acceptedTypeGroups: const [typeGroup]) + : XFile(filePath); + if (file == null || !context.mounted) return; + try { + final length = await file.length(); + if (length > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + final service = ref.read(icalImportServiceProvider); + final preview = service.parsePreview(await file.readAsBytes()); + final destinations = await service.writableDestinations(); + final accountLabels = { + for (final account + in await ref.read(accountsRepositoryProvider).watchAccounts().first) + account.id: account.selectorLabel, + }; + if (!context.mounted) return; + final destination = await showBusyMaxModalDialog( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + barrierDismissible: false, + builder: (dialogContext) => _IcalImportPreviewDialog( + preview: preview, + destinations: destinations, + accountLabels: accountLabels, + ), + ); + if (destination == null || !context.mounted) return; + final report = await service.importPreview( + preview: preview, + destination: destination, + ); + if (!context.mounted) return; + await showBusyMaxModalDialog( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + builder: (dialogContext) => _IcalImportReportDialog(report: report), + ); + } on Object catch (error) { + if (!context.mounted) return; + final code = switch (error) { + IcalIngestionException(:final code) => code, + _ => 'IcalImportFailed', + }; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.importIcsFailed(code)))); + } +} + +class _IcalImportPreviewDialog extends StatefulWidget { + const _IcalImportPreviewDialog({ + required this.preview, + required this.destinations, + required this.accountLabels, + }); + + final IcalImportPreview preview; + final List destinations; + final Map accountLabels; + + @override + State<_IcalImportPreviewDialog> createState() => + _IcalImportPreviewDialogState(); +} + +class _IcalImportPreviewDialogState extends State<_IcalImportPreviewDialog> { + CalendarSourceEntity? _destination; + + @override + void initState() { + super.initState(); + _destination = widget.destinations.firstOrNull; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final omitted = widget.preview.fieldsThatWillBeOmitted.toList()..sort(); + return BusyMaxDialogShell( + title: l10n.importIcsPreview, + maxWidth: BusyMaxSizes.compactDetailsWidth, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('confirm-ics-import'), + onPressed: _destination == null + ? null + : () => Navigator.of(context).pop(_destination), + child: Text(l10n.importIcsConfirm), + ), + ], + children: [ + Text(l10n.importEventsFound(widget.preview.eventCount)), + if (widget.preview.invalidEventCount > 0) + Text(l10n.importInvalidEvents(widget.preview.invalidEventCount)), + if (omitted.isNotEmpty) + Text(l10n.importFieldsOmitted(omitted.join(', '))), + const SizedBox(height: BusyMaxSpacing.md), + if (widget.destinations.isEmpty) + Text(l10n.noWritableCalendars) + else + BusyMaxComboRow( + key: const ValueKey('ics-import-destination'), + title: l10n.importDestinationCalendar, + values: widget.destinations, + selected: _destination!, + labelFor: (source) => + '${widget.accountLabels[source.accountId] ?? source.provider.displayName} · ${source.summary}', + onSelected: (value) => setState(() => _destination = value), + ), + ], + ); + } +} + +class _IcalImportReportDialog extends StatelessWidget { + const _IcalImportReportDialog({required this.report}); + + final IcalImportReport report; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final omitted = report.fieldsIntentionallyOmitted.toList()..sort(); + final unsupported = report.unsupportedRecurrenceSets + .map((item) => '${item.uid}: ${item.reason}') + .toList(growable: false); + return BusyMaxDialogShell( + title: l10n.importIcsComplete, + actions: [ + BusyMaxPushButton.suggested( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.close), + ), + ], + children: [ + Text( + [ + l10n.importQueued(report.queued), + l10n.importDuplicatesSkipped(report.duplicatesSkipped), + l10n.importUnsupportedSets(report.unsupportedRecurrenceSets.length), + ...unsupported, + l10n.importInvalidEvents(report.invalidEvents), + if (omitted.isNotEmpty) + l10n.importFieldsOmitted(omitted.join(', ')), + ].join('\n'), + ), + ], + ); + } +} diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index 185a444d..18e48db2 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -95,6 +95,18 @@ class NotificationScheduleService { final isDav = provider == BusyProvider.appleICloud || provider == BusyProvider.nextcloud; + final reminderEnabledTaskListIds = { + for (final list + in await (_database.select(_database.taskLists)..where( + (row) => + row.accountId.equals(accountId) & + row.remindersEnabled.equals(true) & + row.pendingDelete.equals(false) & + row.serverMissing.equals(false), + )) + .get()) + list.id, + }; final rows = await (_database.select(_database.tasks)..where( (row) => @@ -106,6 +118,9 @@ class NotificationScheduleService { )) .get(); for (final task in rows) { + if (!reminderEnabledTaskListIds.contains(task.taskListId)) { + continue; + } if (task.status == 'completed') { continue; } diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index e411fb6f..c48054f5 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -14,12 +13,12 @@ import '../../../app/busymax_glyphs.dart'; import '../../../calendar_providers/calendar_colors.dart'; import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../l10n/l10n.dart'; -import '../../../ical/ical_import_service.dart'; -import '../../../ical/ical_ingestion.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../webcal/webcal_subscription_service.dart'; import '../../accounts/data/accounts_repository.dart'; +import '../../accounts/domain/account_collection_creation_capabilities.dart'; +import '../../calendar/data/calendar_collection_creation_service.dart'; import '../../calendar/data/calendar_repository.dart'; import '../../calendar/presentation/calendar_color_dialog.dart'; import '../../sync/sync_auth_error.dart'; @@ -68,12 +67,6 @@ class ScheduleSidebar extends ConsumerWidget { child: ListView( padding: const EdgeInsets.symmetric(vertical: BusyMaxSpacing.sm), children: [ - BusyMaxActionRow( - key: const ValueKey('import-ics-file'), - title: context.l10n.importIcsFile, - leading: const Icon(Icons.file_open_outlined), - onTap: () => unawaited(showIcsImportFlow(context, ref)), - ), for (final account in accounts.where( (account) => !account.isSubscription, )) @@ -128,6 +121,7 @@ class _SourceRow extends ConsumerWidget { }, ), menuButton: BusyMaxMenuButton( + key: ValueKey(('calendar-options', source.id)), tooltip: context.l10n.options, highlightWhenOpen: false, onSelected: (value) { @@ -139,14 +133,7 @@ class _SourceRow extends ConsumerWidget { unawaited(_openProviderWeb(providerWebUri)); } case 'toggle-reminders': - unawaited( - ref - .read(calendarRepositoryProvider) - .setSourceRemindersEnabled( - source.id, - !source.remindersEnabled, - ), - ); + unawaited(_setCalendarRemindersEnabled(context, ref, source)); case 'color': unawaited(_changeCalendarColor(context, ref, source)); case 'rename': @@ -169,10 +156,12 @@ class _SourceRow extends ConsumerWidget { ), BusyMaxMenuEntry( value: 'toggle-reminders', - label: context.l10n.eventReminders, - icon: Icons.notifications_outlined, - role: BusyMaxMenuEntryRole.toggle, - selected: source.remindersEnabled, + label: + '${context.l10n.eventReminders} — ' + '${source.remindersEnabled ? context.l10n.onState : context.l10n.off}', + icon: source.remindersEnabled + ? Icons.notifications_outlined + : Icons.notifications_off_outlined, ), BusyMaxMenuEntry( value: 'color', @@ -325,6 +314,8 @@ class _AccountSourcesGroup extends ConsumerStatefulWidget { class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { var _expanded = true; + var _creatingCalendar = false; + var _creatingTaskList = false; @override void didUpdateWidget(covariant _AccountSourcesGroup oldWidget) { @@ -337,6 +328,27 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { @override Widget build(BuildContext context) { final account = widget.account; + final networkAvailability = + ref.watch(networkAvailabilityProvider).valueOrNull ?? + ref.read(networkConnectivityMonitorProvider).availability; + final capabilities = AccountCollectionCreationCapabilities.resolve( + account: account, + networkAvailability: networkAvailability, + calendarCreationRunning: _creatingCalendar, + taskListCreationRunning: _creatingTaskList, + ); + final headerActions = <_AccountHeaderCollectionAction>[ + if (capabilities.supportsCalendarCreation) + _AccountHeaderCollectionAction( + action: AccountHeaderCollectionAction.newCalendar, + enabled: capabilities.calendarActionEnabled, + ), + if (capabilities.supportsTaskListCreation) + _AccountHeaderCollectionAction( + action: AccountHeaderCollectionAction.newTaskList, + enabled: capabilities.taskListActionEnabled, + ), + ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -344,6 +356,8 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { account: account, expanded: _expanded, onToggleExpanded: _toggleExpanded, + actions: headerActions, + onActionSelected: _handleCollectionAction, ), if (_expanded) ...[ _AccountCalendarSources(account: account), @@ -380,6 +394,29 @@ class _AccountSourcesGroupState extends ConsumerState<_AccountSourcesGroup> { void _toggleExpanded() { setState(() => _expanded = !_expanded); } + + Future _handleCollectionAction( + AccountHeaderCollectionAction action, + ) async { + switch (action) { + case AccountHeaderCollectionAction.newCalendar: + if (_creatingCalendar) return; + setState(() => _creatingCalendar = true); + try { + await _createAccountCalendar(context, ref, widget.account); + } finally { + if (mounted) setState(() => _creatingCalendar = false); + } + case AccountHeaderCollectionAction.newTaskList: + if (_creatingTaskList) return; + setState(() => _creatingTaskList = true); + try { + await _createAccountTaskList(context, ref, widget.account); + } finally { + if (mounted) setState(() => _creatingTaskList = false); + } + } + } } class _SubscriptionSourcesGroup extends ConsumerWidget { @@ -495,11 +532,15 @@ class _AccountHeaderRow extends StatelessWidget { required this.account, required this.expanded, required this.onToggleExpanded, + required this.actions, + required this.onActionSelected, }); final AccountEntity account; final bool expanded; final VoidCallback onToggleExpanded; + final List<_AccountHeaderCollectionAction> actions; + final ValueChanged onActionSelected; @override Widget build(BuildContext context) { @@ -537,7 +578,49 @@ class _AccountHeaderRow extends StatelessWidget { ), ), const SizedBox(width: BusyMaxSpacing.xs), + if (actions.isNotEmpty) ...[ + BusyMaxMenuButton( + key: ValueKey(('account-collection-options', account.id)), + tooltip: context.l10n.options, + highlightWhenOpen: false, + triggerBuilder: (context, trigger) => trigger.anchor( + child: Semantics( + expanded: trigger.isOpen, + button: true, + child: BusyMaxHeaderIconButton( + tooltip: context.l10n.options, + iconSize: BusyMaxSizes.sidebarActionIcon, + icon: const Icon(YaruIcons.view_more), + focusNode: trigger.focusNode, + onPressed: trigger.onPressed, + foregroundColor: colorScheme.onSurfaceVariant, + backgroundColor: busyMaxSubtleButtonBackground(context), + overlayColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + ), + ), + ), + onSelected: onActionSelected, + entries: [ + for (final item in actions) + BusyMaxMenuEntry( + value: item.action, + label: switch (item.action) { + AccountHeaderCollectionAction.newCalendar => + '${context.l10n.newCalendar}…', + AccountHeaderCollectionAction.newTaskList => + '${context.l10n.newTaskList}…', + }, + icon: YaruIcons.plus, + enabled: item.enabled, + ), + ], + ), + const SizedBox(width: BusyMaxSpacing.xs), + ], BusyMaxHeaderIconButton( + key: ValueKey(('account-collapse', account.id)), tooltip: expanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, @@ -582,9 +665,6 @@ class _AccountCalendarSources extends ConsumerWidget { ]), builder: (context, snapshot) { final sources = snapshot.data ?? const []; - final canCreate = calendarManagementCapabilities( - account.provider, - ).supportsCreate; return Column( children: [ if (sources.isEmpty) @@ -602,13 +682,6 @@ class _AccountCalendarSources extends ConsumerWidget { account: account, source: source, ), - if (canCreate) - BusyMaxActionRow( - key: ValueKey(('new-calendar', account.id)), - title: context.l10n.newCalendar, - leading: const Icon(YaruIcons.plus), - onTap: () => unawaited(_createCalendar(context, ref, account)), - ), ], ); }, @@ -703,6 +776,7 @@ class _TaskListScheduleRow extends ConsumerWidget { }, ), menuButton: BusyMaxMenuButton( + key: ValueKey(('task-list-options', list.accountId, list.id)), tooltip: context.l10n.options, highlightWhenOpen: false, onSelected: (value) { @@ -713,6 +787,8 @@ class _TaskListScheduleRow extends ConsumerWidget { if (providerWebUri != null) { unawaited(_openProviderWeb(providerWebUri)); } + case 'toggle-reminders': + unawaited(_setTaskListRemindersEnabled(context, ref, list)); case 'rename': unawaited(_renameTaskList(context, ref, list)); case 'delete': @@ -731,6 +807,15 @@ class _TaskListScheduleRow extends ConsumerWidget { label: context.l10n.openInProvider, icon: Icons.open_in_browser_outlined, ), + BusyMaxMenuEntry( + value: 'toggle-reminders', + label: + '${context.l10n.taskReminders} — ' + '${list.remindersEnabled ? context.l10n.onState : context.l10n.off}', + icon: list.remindersEnabled + ? Icons.notifications_outlined + : Icons.notifications_off_outlined, + ), BusyMaxMenuEntry( value: 'rename', label: context.l10n.rename, @@ -994,7 +1079,19 @@ String? _calendarDeleteRestriction( return context.l10n.calendarCannotRemove; } -Future _createCalendar( +enum AccountHeaderCollectionAction { newCalendar, newTaskList } + +final class _AccountHeaderCollectionAction { + const _AccountHeaderCollectionAction({ + required this.action, + required this.enabled, + }); + + final AccountHeaderCollectionAction action; + final bool enabled; +} + +Future _createAccountCalendar( BuildContext context, WidgetRef ref, AccountEntity account, @@ -1008,10 +1105,16 @@ Future _createCalendar( ); if (!context.mounted || title == null || title.trim().isEmpty) return; try { - await ref - .read(calendarRepositoryProvider) - .createLocalSource(accountId: account.id, summary: title); - _requestCalendarMutationSync(ref, account.id); + final result = await ref + .read(calendarCollectionCreationServiceProvider) + .createCalendar(accountId: account.id, title: title); + if (context.mounted && + result.outcome == + CalendarCollectionCreationOutcome.createdRefreshPending) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.calendarCreatedRefreshPending)), + ); + } } on Object catch (error) { if (context.mounted) { _showCalendarMutationFailure( @@ -1027,6 +1130,73 @@ Future _createCalendar( } } +Future _createAccountTaskList( + BuildContext context, + WidgetRef ref, + AccountEntity account, +) async { + final title = await showBusyMaxTextPrompt( + context, + title: context.l10n.newTaskList, + label: context.l10n.title, + actionLabel: context.l10n.create, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + ); + if (!context.mounted || title == null || title.trim().isEmpty) return; + try { + await ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .createTaskList(title); + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.taskListCreateFailed( + syncFailureMessage( + error, + networkUnavailableMessage: context.l10n.networkOfflineTryAgain, + ), + ), + ), + ), + ); + } + } +} + +Future _setCalendarRemindersEnabled( + BuildContext context, + WidgetRef ref, + CalendarSourceEntity source, +) async { + try { + await ref + .read(calendarRepositoryProvider) + .setSourceRemindersEnabled(source.id, !source.remindersEnabled); + } on Object { + if (context.mounted) { + _showCalendarMutationFailure(context, context.l10n.operationFailed); + } + } +} + +Future _setTaskListRemindersEnabled( + BuildContext context, + WidgetRef ref, + TaskListEntity list, +) async { + try { + await ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .setRemindersEnabled(list.id, !list.remindersEnabled); + } on Object { + if (context.mounted) { + _showCalendarMutationFailure(context, context.l10n.operationFailed); + } + } +} + Future _changeCalendarColor( BuildContext context, WidgetRef ref, @@ -1242,176 +1412,6 @@ Future _deleteTaskList( } } -Future showIcsImportFlow( - BuildContext context, - WidgetRef ref, { - String? filePath, -}) async { - const typeGroup = XTypeGroup( - label: 'iCalendar', - extensions: ['ics'], - mimeTypes: ['text/calendar'], - ); - final file = filePath == null - ? await openFile(acceptedTypeGroups: const [typeGroup]) - : XFile(filePath); - if (file == null || !context.mounted) return; - try { - final length = await file.length(); - if (length > icalIngestionDecodedBodyLimit) { - throw const IcalIngestionException( - 'IcalBodyTooLarge', - 'The calendar data exceeds the 16 MiB limit.', - ); - } - final service = ref.read(icalImportServiceProvider); - final preview = service.parsePreview(await file.readAsBytes()); - final destinations = await service.writableDestinations(); - final accountLabels = { - for (final account - in await ref.read(accountsRepositoryProvider).watchAccounts().first) - account.id: account.selectorLabel, - }; - if (!context.mounted) return; - final destination = await showBusyMaxModalDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - barrierDismissible: false, - builder: (dialogContext) => _IcalImportPreviewDialog( - preview: preview, - destinations: destinations, - accountLabels: accountLabels, - ), - ); - if (destination == null || !context.mounted) return; - final report = await service.importPreview( - preview: preview, - destination: destination, - ); - if (!context.mounted) return; - await showBusyMaxModalDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - builder: (dialogContext) => _IcalImportReportDialog(report: report), - ); - } on Object catch (error) { - if (!context.mounted) return; - final code = switch (error) { - IcalIngestionException(:final code) => code, - _ => 'IcalImportFailed', - }; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.importIcsFailed(code)))); - } -} - -class _IcalImportPreviewDialog extends StatefulWidget { - const _IcalImportPreviewDialog({ - required this.preview, - required this.destinations, - required this.accountLabels, - }); - - final IcalImportPreview preview; - final List destinations; - final Map accountLabels; - - @override - State<_IcalImportPreviewDialog> createState() => - _IcalImportPreviewDialogState(); -} - -class _IcalImportPreviewDialogState extends State<_IcalImportPreviewDialog> { - CalendarSourceEntity? _destination; - - @override - void initState() { - super.initState(); - _destination = widget.destinations.firstOrNull; - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final omitted = widget.preview.fieldsThatWillBeOmitted.toList()..sort(); - return BusyMaxDialogShell( - title: l10n.importIcsPreview, - maxWidth: BusyMaxSizes.compactDetailsWidth, - actions: [ - BusyMaxPushButton.standard( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.cancel), - ), - BusyMaxPushButton.suggested( - key: const ValueKey('confirm-ics-import'), - onPressed: _destination == null - ? null - : () => Navigator.of(context).pop(_destination), - child: Text(l10n.importIcsConfirm), - ), - ], - children: [ - Text(l10n.importEventsFound(widget.preview.eventCount)), - if (widget.preview.invalidEventCount > 0) - Text(l10n.importInvalidEvents(widget.preview.invalidEventCount)), - if (omitted.isNotEmpty) - Text(l10n.importFieldsOmitted(omitted.join(', '))), - const SizedBox(height: BusyMaxSpacing.md), - if (widget.destinations.isEmpty) - Text(l10n.noWritableCalendars) - else - BusyMaxComboRow( - key: const ValueKey('ics-import-destination'), - title: l10n.importDestinationCalendar, - values: widget.destinations, - selected: _destination!, - labelFor: (source) => - '${widget.accountLabels[source.accountId] ?? source.provider.displayName} · ${source.summary}', - onSelected: (value) => setState(() => _destination = value), - ), - ], - ); - } -} - -class _IcalImportReportDialog extends StatelessWidget { - const _IcalImportReportDialog({required this.report}); - - final IcalImportReport report; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final omitted = report.fieldsIntentionallyOmitted.toList()..sort(); - final unsupported = report.unsupportedRecurrenceSets - .map((item) => '${item.uid}: ${item.reason}') - .toList(growable: false); - return BusyMaxDialogShell( - title: l10n.importIcsComplete, - actions: [ - BusyMaxPushButton.suggested( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.close), - ), - ], - children: [ - Text( - [ - l10n.importQueued(report.queued), - l10n.importDuplicatesSkipped(report.duplicatesSkipped), - l10n.importUnsupportedSets(report.unsupportedRecurrenceSets.length), - ...unsupported, - l10n.importInvalidEvents(report.invalidEvents), - if (omitted.isNotEmpty) - l10n.importFieldsOmitted(omitted.join(', ')), - ].join('\n'), - ), - ], - ); - } -} - Future _subscriptionForSource( WidgetRef ref, CalendarSourceEntity source, diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 35a890e9..13d9491e 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -31,6 +31,7 @@ import 'package:busymax/src/providers/busy_provider.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../accounts/domain/account_connection_state.dart'; import '../../auth/data/auth_repository.dart'; +import '../../calendar/presentation/ical_import_flow.dart'; import '../../connectivity/network_connectivity_service.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; import '../../feedback/presentation/feedback_dialog.dart'; @@ -121,8 +122,6 @@ class _SettingsScreenState extends ConsumerState { onCancelConnection: _cancelAccountConnection, onReconnect: (account) => unawaited(_connectAccount(account.provider, reconnecting: account)), - onCreateTaskList: (accountId) => - _createTaskList(context, ref, accountId), removingAccountIds: _removingAccountIds, onRemoveAccount: (account) => unawaited(_removeAccount(context, ref, account)), @@ -142,6 +141,7 @@ class _SettingsScreenState extends ConsumerState { ), onResolveConflict: (conflict, resolution) => unawaited(_resolveConflict(conflict, resolution)), + onImportIcs: () => unawaited(showIcsImportFlow(context, ref)), subscriptions: subscriptions, busySubscriptionIds: _busySubscriptionIds, onAddSubscription: () => unawaited(_addSubscription()), @@ -822,39 +822,6 @@ class _SettingsScreenState extends ConsumerState { } } - Future _createTaskList( - BuildContext context, - WidgetRef ref, - String accountId, - ) async { - final title = await _taskListTitleDialog( - context, - ref.read(linuxHeaderBarServiceProvider), - ); - if (title == null || title.trim().isEmpty) { - return; - } - - try { - await ref - .read(taskListsRepositoryForAccountProvider(accountId)) - .createTaskList(title.trim()); - } on Object catch (error) { - _settingsLogger.warning('Task-list creation failed: $error'); - if (context.mounted) { - _showMessage( - context, - context.l10n.taskListCreateFailed( - syncFailureMessage( - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), - ), - ); - } - } - } - Future _fullSync( BuildContext context, WidgetRef ref, @@ -1120,19 +1087,6 @@ String _timeOfDayLabel(BuildContext context, int minute) { ); } -Future _taskListTitleDialog( - BuildContext context, - LinuxHeaderBarService headerBarService, -) { - return showBusyMaxTextPrompt( - context, - title: context.l10n.newTaskList, - label: context.l10n.title, - actionLabel: context.l10n.create, - headerBarService: headerBarService, - ); -} - class _AccountManagementSection extends StatelessWidget { const _AccountManagementSection({ required this.accounts, @@ -1145,7 +1099,6 @@ class _AccountManagementSection extends StatelessWidget { required this.onAddNextcloud, required this.onCancelConnection, required this.onReconnect, - required this.onCreateTaskList, required this.removingAccountIds, required this.onRemoveAccount, required this.davCollections, @@ -1154,6 +1107,7 @@ class _AccountManagementSection extends StatelessWidget { required this.onEventsSelected, required this.onTasksSelected, required this.onResolveConflict, + required this.onImportIcs, required this.subscriptions, required this.busySubscriptionIds, required this.onAddSubscription, @@ -1173,7 +1127,6 @@ class _AccountManagementSection extends StatelessWidget { final VoidCallback onAddNextcloud; final VoidCallback onCancelConnection; final void Function(AccountEntity account) onReconnect; - final void Function(String accountId) onCreateTaskList; final Set removingAccountIds; final void Function(AccountEntity account) onRemoveAccount; final List davCollections; @@ -1188,6 +1141,7 @@ class _AccountManagementSection extends StatelessWidget { DavConflictResolution resolution, ) onResolveConflict; + final VoidCallback onImportIcs; final List subscriptions; final Set busySubscriptionIds; final VoidCallback onAddSubscription; @@ -1261,12 +1215,6 @@ class _AccountManagementSection extends StatelessWidget { onReconnect: connecting || removingAccountIds.contains(account.id) ? null : () => onReconnect(account), - onCreateTaskList: - account.provider == BusyProvider.google || - account.provider == BusyProvider.microsoft || - account.provider == BusyProvider.nextcloud - ? () => onCreateTaskList(account.id) - : null, onRefreshCollections: account.provider == BusyProvider.appleICloud || account.provider == BusyProvider.nextcloud @@ -1295,6 +1243,7 @@ class _AccountManagementSection extends StatelessWidget { onResolve: onResolveConflict, ), ], + _CalendarImportCard(onImport: onImportIcs), _CalendarSubscriptionsCard( subscriptions: subscriptions, busySubscriptionIds: busySubscriptionIds, @@ -1309,6 +1258,29 @@ class _AccountManagementSection extends StatelessWidget { } } +class _CalendarImportCard extends StatelessWidget { + const _CalendarImportCard({required this.onImport}); + + final VoidCallback onImport; + + @override + Widget build(BuildContext context) { + return BusyMaxGroupedList( + title: context.l10n.calendarImport, + description: context.l10n.calendarImportDescription, + filled: true, + children: [ + BusyMaxActionRow( + key: const ValueKey('import-ics-file'), + title: context.l10n.importIcsFile, + leading: const Icon(Icons.file_open_outlined), + onTap: onImport, + ), + ], + ); + } +} + class _CalendarSubscriptionsCard extends StatelessWidget { const _CalendarSubscriptionsCard({ required this.subscriptions, @@ -1614,7 +1586,6 @@ class _AccountManagementCard extends StatelessWidget { required this.account, required this.removing, required this.onReconnect, - required this.onCreateTaskList, required this.onRefreshCollections, required this.onRemoveAccount, }); @@ -1622,7 +1593,6 @@ class _AccountManagementCard extends StatelessWidget { final AccountEntity account; final bool removing; final VoidCallback? onReconnect; - final VoidCallback? onCreateTaskList; final VoidCallback? onRefreshCollections; final VoidCallback onRemoveAccount; @@ -1677,14 +1647,7 @@ class _AccountManagementCard extends StatelessWidget { color: Theme.of(context).colorScheme.error, ), onTap: account.needsReconnect ? onReconnect : null, - ) - else if (onCreateTaskList != null) ...[ - BusyMaxActionRow( - title: l10n.newTaskList, - leading: const Icon(YaruIcons.plus), - onTap: removing ? null : onCreateTaskList, ), - ], if (onRefreshCollections != null) BusyMaxActionRow( title: l10n.refreshCollections, diff --git a/lib/src/features/task_lists/data/task_lists_repository.dart b/lib/src/features/task_lists/data/task_lists_repository.dart index 5de218e5..0ae681ad 100644 --- a/lib/src/features/task_lists/data/task_lists_repository.dart +++ b/lib/src/features/task_lists/data/task_lists_repository.dart @@ -5,7 +5,9 @@ import 'package:uuid/uuid.dart'; import '../../../db/app_database.dart'; import '../../../dav/mutation/dav_task_list_mutation_service.dart'; -import '../../../providers/busy_provider.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../accounts/domain/account_collection_creation_capabilities.dart'; +import '../../notifications/notification_schedule_service.dart'; import '../../tasks/domain/task_remote_client.dart'; import '../../tasks/domain/task_remote_models.dart'; @@ -17,6 +19,7 @@ class TaskListEntity { required this.localDirty, required this.pendingDelete, required this.rawJson, + this.remindersEnabled = true, this.updatedUtc, this.etag, this.providerListKind, @@ -33,6 +36,7 @@ class TaskListEntity { localDirty: row.localDirty, pendingDelete: row.pendingDelete, rawJson: row.rawJson, + remindersEnabled: row.remindersEnabled, updatedUtc: row.updatedUtc, etag: row.etag, providerListKind: row.providerListKind, @@ -48,6 +52,7 @@ class TaskListEntity { final bool localDirty; final bool pendingDelete; final String rawJson; + final bool remindersEnabled; final String? updatedUtc; final String? etag; final String? providerListKind; @@ -69,6 +74,7 @@ class TaskListsRepository { TaskRemoteClient? apiClient, DavTaskListMutationClient? davMutationClient, void Function()? onMutationQueued, + Future Function()? onNotificationScheduleChanged, Uuid uuid = const Uuid(), DateTime Function()? nowUtc, }) : _database = database, @@ -76,6 +82,7 @@ class TaskListsRepository { _apiClient = apiClient, _davMutationClient = davMutationClient, _onMutationQueued = onMutationQueued, + _onNotificationScheduleChanged = onNotificationScheduleChanged, _uuid = uuid, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); @@ -84,6 +91,7 @@ class TaskListsRepository { final TaskRemoteClient? _apiClient; final DavTaskListMutationClient? _davMutationClient; final void Function()? _onMutationQueued; + final Future Function()? _onNotificationScheduleChanged; final Uuid _uuid; final DateTime Function() _nowUtc; @@ -116,9 +124,23 @@ class TaskListsRepository { } Future createTaskList(String title) async { - if (await _usesNextcloudDav()) { - await _requiredDavMutationClient().createTaskList(title); - return; + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value( + title, + 'title', + 'A task-list name is required.', + ); + } + final mode = await _taskListCreationMode(); + switch (mode) { + case TaskListCreationMode.nextcloudDav: + await _requiredDavMutationClient().createTaskList(trimmedTitle); + return; + case TaskListCreationMode.unavailable: + throw StateError('Task-list creation is unavailable for this account.'); + case TaskListCreationMode.cloudPendingOperation: + break; } final now = _now(); final localId = 'local-tasklist-${_uuid.v4()}'; @@ -127,8 +149,8 @@ class TaskListsRepository { TaskListsCompanion.insert( accountId: _accountId, id: localId, - title: title, - rawJson: jsonEncode({'id': localId, 'title': title}), + title: trimmedTitle, + rawJson: jsonEncode({'id': localId, 'title': trimmedTitle}), localDirty: const Value(true), createdLocalAtUtc: now, updatedLocalAtUtc: now, @@ -138,7 +160,7 @@ class TaskListsRepository { operation: 'create_task_list', taskListId: localId, localTempId: localId, - request: {'title': title}, + request: {'title': trimmedTitle}, createdAtUtc: now, ); }); @@ -251,6 +273,26 @@ class TaskListsRepository { ); } + Future setRemindersEnabled(String id, bool enabled) async { + final updated = + await (_database.update(_database.taskLists)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.equals(id) & + row.pendingDelete.equals(false) & + row.serverMissing.equals(false), + )) + .write(TaskListsCompanion(remindersEnabled: Value(enabled))); + if (updated != 1) { + throw StateError('The task list is no longer available.'); + } + await NotificationScheduleService( + database: _database, + nowUtc: _nowUtc, + ).rebuildUpcomingTaskNotifications(_accountId); + await _onNotificationScheduleChanged?.call(); + } + Future _updateLocalList(String id, TaskListsCompanion companion) { final update = _database.update(_database.taskLists) ..where((row) => row.accountId.equals(_accountId) & row.id.equals(id)); @@ -290,13 +332,16 @@ class TaskListsRepository { .getSingleOrNull(); } - Future _usesNextcloudDav() async { + Future _taskListCreationMode() async { final account = await (_database.select( _database.accounts, )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); - return account != null && - BusyProviderCodec.requireStorageValue(account.provider) == - BusyProvider.nextcloud; + if (account == null) return TaskListCreationMode.unavailable; + final entity = AccountEntity.fromRow(account); + if (!entity.tasksEnabled || !entity.isSignedIn) { + return TaskListCreationMode.unavailable; + } + return accountCollectionCreationModes(entity.provider).taskListMode; } DavTaskListMutationClient _requiredDavMutationClient() { diff --git a/test/app/rtl_glyphs_test.dart b/test/app/rtl_glyphs_test.dart index 229c5050..1db83746 100644 --- a/test/app/rtl_glyphs_test.dart +++ b/test/app/rtl_glyphs_test.dart @@ -38,6 +38,21 @@ void main() { ); }); + test('calendar option glyphs resolve for native GTK menus', () { + expect( + BusyMaxGlyphs.nativeMenuIconName(Icons.palette_outlined), + 'color-select-symbolic', + ); + expect( + BusyMaxGlyphs.nativeMenuIconName(Icons.notifications_outlined), + 'preferences-system-notifications-symbolic', + ); + expect( + BusyMaxGlyphs.nativeMenuIconName(Icons.notifications_off_outlined), + 'notifications-disabled-symbolic', + ); + }); + test('Arabic and Persian glyph coverage is packaged in the snap', () { expect( File('snap/snapcraft.yaml').readAsStringSync(), diff --git a/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart b/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart new file mode 100644 index 00000000..81b8a587 --- /dev/null +++ b/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart @@ -0,0 +1,532 @@ +import 'dart:io'; + +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/dav/dav_errors.dart'; +import 'package:busymax/src/dav/discovery/dav_discovery_models.dart'; +import 'package:busymax/src/dav/http/dav_http_transport.dart'; +import 'package:busymax/src/dav/mutation/dav_calendar_collection_mutation_service.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:drift/drift.dart' hide 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'; + +void main() { + late AppDatabase database; + late InMemorySecretStore secrets; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + secrets = InMemorySecretStore(); + await _seedNextcloudAccount(database, secrets); + }); + + tearDown(() => database.close()); + + test('sends an event-only MKCALENDAR beneath the calendar home', () async { + late http.Request request; + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((incoming) async { + request = incoming; + return http.Response('', HttpStatus.created); + }), + refresh: () async => refreshes += 1, + ); + + final result = await service.createEventCalendar('Work & Travel'); + + expect(result.refreshPending, isFalse); + expect(request.method, 'MKCALENDAR'); + expect( + request.url.toString(), + 'https://cloud.example.test/remote.php/dav/calendars/alex/work-travel/', + ); + expect(request.headers['content-type'], 'application/xml; charset=utf-8'); + expect(request.body, contains('Work & Travel')); + expect(RegExp('name="VEVENT"').allMatches(request.body), hasLength(1)); + expect(request.body, isNot(contains('VTODO'))); + expect(refreshes, 1); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('uses one MKCALENDAR attempt and never falls back to MKCOL', () async { + final methods = []; + final service = _service( + database, + secrets, + MockClient((request) async { + methods.add(request.method); + return http.Response('', HttpStatus.methodNotAllowed); + }), + ); + + await expectLater( + service.createEventCalendar('Unsupported'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavMkcalendarUnsupported', + ), + ), + ); + expect(methods, ['MKCALENDAR']); + }); + + for (final statusAndExpectation in <(int, DavErrorKind, String)>[ + (401, DavErrorKind.authentication, 'DavAuthRejected'), + (403, DavErrorKind.authorization, 'DavCalendarParentNotWritable'), + (409, DavErrorKind.conflict, 'DavCalendarCollectionConflict'), + ]) { + test('maps HTTP ${statusAndExpectation.$1} to a safe DAV error', () async { + final service = _service( + database, + secrets, + MockClient( + (_) async => http.Response( + 'private response', + statusAndExpectation.$1, + ), + ), + ); + + await expectLater( + service.createEventCalendar('Rejected'), + throwsA( + isA() + .having((error) => error.kind, 'kind', statusAndExpectation.$2) + .having((error) => error.code, 'code', statusAndExpectation.$3) + .having( + (error) => error.toString(), + 'diagnostic', + isNot(contains('private response')), + ), + ), + ); + }); + } + + test( + 'reconciles an ambiguous connection failure with an exact probe', + () async { + final methods = []; + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((request) async { + methods.add(request.method); + if (request.method == 'MKCALENDAR') { + throw const SocketException('connection closed after commit'); + } + expect(request.headers['depth'], '0'); + expect(request.url.path, endsWith('/recovered/')); + return http.Response(_createdEventCalendar('Recovered'), 207); + }), + refresh: () async => refreshes += 1, + ); + + await service.createEventCalendar('Recovered'); + + expect(methods, ['MKCALENDAR', 'PROPFIND']); + expect(refreshes, 1); + }, + ); + + test( + 'preserves an ambiguous failure when the probe does not match', + () async { + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((request) async { + if (request.method == 'MKCALENDAR') { + throw const SocketException('connection closed after request'); + } + return http.Response(_createdEventCalendar('Different'), 207); + }), + refresh: () async => refreshes += 1, + ); + + await expectLater( + service.createEventCalendar('Expected'), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + DavErrorKind.network, + ), + ), + ); + expect(refreshes, 0); + }, + ); + + test( + 'malformed ambiguous-outcome probe preserves the original failure', + () async { + final service = _service( + database, + secrets, + MockClient((request) async { + if (request.method == 'MKCALENDAR') { + throw const SocketException('connection closed after request'); + } + return http.Response('', HttpStatus.multiStatus); + }), + ); + + await expectLater( + service.createEventCalendar('Malformed probe'), + throwsA( + isA() + .having((error) => error.kind, 'kind', DavErrorKind.network) + .having((error) => error.code, 'code', 'DavNetworkFailure'), + ), + ); + }, + ); + + test('transport timeout does not retry MKCALENDAR', () async { + var mkcalendarRequests = 0; + final service = _service( + database, + secrets, + MockClient((request) async { + if (request.method == 'MKCALENDAR') mkcalendarRequests += 1; + await Future.delayed(const Duration(milliseconds: 40)); + return http.Response('', HttpStatus.serviceUnavailable); + }), + transportLimits: const DavTransportLimits( + connectTimeout: Duration(milliseconds: 5), + responseTimeout: Duration(milliseconds: 5), + operationTimeout: Duration(milliseconds: 50), + ), + ); + + await expectLater( + service.createEventCalendar('Timeout'), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + DavErrorKind.timeout, + ), + ), + ); + expect(mkcalendarRequests, 1); + }); + + test('only 201 Created is accepted as direct success', () async { + final service = _service( + database, + secrets, + MockClient((_) async => http.Response('', HttpStatus.noContent)), + ); + + await expectLater( + service.createEventCalendar('Unexpected success status'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarCollectionCreationRejected', + ), + ), + ); + }); + + test('reports created with refresh pending after remote success', () async { + final service = _service( + database, + secrets, + MockClient((_) async => http.Response('', HttpStatus.created)), + refresh: () async => throw StateError('sync unavailable'), + ); + + final result = await service.createEventCalendar('Created remotely'); + + expect( + result.status, + DavCalendarCollectionCreationStatus.createdRefreshPending, + ); + }); + + test( + 'rejects duplicate event-calendar display names before transport', + () async { + await _seedEventCollection(database, displayName: 'Work'); + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarNameConflict', + ), + ), + ); + expect(requests, 0); + }, + ); + + test('rejects a missing calendar home before transport', () async { + await database.delete(database.davAccountServices).go(); + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarHomeUnavailable', + ), + ), + ); + expect(requests, 0); + }); + + test( + 'rejects invalid credentials and a mismatched canonical server', + () async { + await secrets.saveCredential( + 'account', + NextcloudSecretRecord( + canonicalServer: Uri.parse('https://different.example.test'), + loginName: 'alex', + appPassword: 'secret', + ), + ); + final service = _service( + database, + secrets, + MockClient((_) async => http.Response('', HttpStatus.created)), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCredentialsRevoked', + ), + ), + ); + }, + ); + + test('rejects a cross-origin calendar home before transport', () async { + await database + .update(database.davAccountServices) + .write( + const DavAccountServicesCompanion( + calendarHomeHref: Value( + 'https://evil.example.test/remote.php/dav/calendars/alex/', + ), + ), + ); + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + ); + + await expectLater( + service.createEventCalendar('Work'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCalendarMutationContextInvalid', + ), + ), + ); + expect(requests, 0); + }); + + test('enforces offline state before context or transport work', () async { + var networkChecks = 0; + var requests = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + requireNetwork: () async { + networkChecks += 1; + throw const _OfflineTestException(); + }, + ); + + await expectLater( + service.createEventCalendar('Offline'), + throwsA(isA<_OfflineTestException>()), + ); + expect(networkChecks, 1); + expect(requests, 0); + }); + + test('honors cancellation without probing or refreshing', () async { + final token = DavCancellationToken()..cancel(); + var requests = 0; + var refreshes = 0; + final service = _service( + database, + secrets, + MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + refresh: () async => refreshes += 1, + ); + + await expectLater( + service.createEventCalendar('Cancelled', cancellationToken: token), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + DavErrorKind.cancelled, + ), + ), + ); + expect(requests, 0); + expect(refreshes, 0); + }); +} + +DavCalendarCollectionMutationService _service( + AppDatabase database, + SecretStore secrets, + http.Client client, { + Future Function()? refresh, + Future Function()? requireNetwork, + DavTransportLimits transportLimits = const DavTransportLimits(), +}) { + var correlation = 0; + return DavCalendarCollectionMutationService( + database: database, + secretStore: secrets, + httpClient: client, + accountId: 'account', + refreshAfterMutation: refresh ?? () async {}, + requireNetwork: requireNetwork, + transportLimits: transportLimits, + correlationIdFactory: () => 'correlation-${correlation += 1}', + ); +} + +Future _seedNextcloudAccount( + AppDatabase database, + InMemorySecretStore secrets, +) async { + const now = '2026-08-29T12:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'nextcloud', + authority: 'https://cloud.example.test', + providerAccountId: 'alex', + credentialKind: 'nextcloud_app_password', + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + await database + .into(database.davAccountServices) + .insert( + DavAccountServicesCompanion.insert( + accountId: 'account', + canonicalServiceUri: 'https://cloud.example.test/remote.php/dav/', + canonicalOrigin: 'https://cloud.example.test', + principalHref: const Value('/remote.php/dav/principals/users/alex/'), + calendarHomeHref: const Value( + 'https://cloud.example.test/remote.php/dav/calendars/alex/', + ), + discoveredAtUtc: now, + ), + ); + await secrets.saveCredential( + 'account', + NextcloudSecretRecord( + canonicalServer: Uri.parse('https://cloud.example.test'), + loginName: 'alex', + appPassword: 'application-password', + ), + ); +} + +Future _seedEventCollection( + AppDatabase database, { + required String displayName, +}) async { + const now = '2026-08-29T12:00:00.000Z'; + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: 'calendar', + accountId: 'account', + hrefKey: '/remote.php/dav/calendars/alex/work/', + requestUri: + 'https://cloud.example.test/remote.php/dav/calendars/alex/work/', + displayName: displayName, + supportedComponentMask: const Value(davComponentEvent), + eventProjectionEnabled: const Value(true), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); +} + +String _createdEventCalendar(String displayName) => + '' + '' + '/remote.php/dav/calendars/alex/recovered/' + '' + '' + '$displayName' + '' + '' + 'HTTP/1.1 200 OK' + ''; + +final class _OfflineTestException implements Exception { + const _OfflineTestException(); +} diff --git a/test/dav/mutation/dav_task_list_mutation_service_test.dart b/test/dav/mutation/dav_task_list_mutation_service_test.dart index b48497cd..3b0f5e68 100644 --- a/test/dav/mutation/dav_task_list_mutation_service_test.dart +++ b/test/dav/mutation/dav_task_list_mutation_service_test.dart @@ -93,6 +93,32 @@ void main() { }, ); + test('create enforces network availability before DAV transport', () async { + var requests = 0; + var networkChecks = 0; + final service = DavTaskListMutationService( + database: database, + secretStore: secrets, + httpClient: MockClient((_) async { + requests += 1; + return http.Response('', HttpStatus.created); + }), + accountId: 'account', + refreshAfterMutation: () async {}, + requireNetwork: () async { + networkChecks += 1; + throw const _OfflineTestException(); + }, + ); + + await expectLater( + service.createTaskList('Offline'), + throwsA(isA<_OfflineTestException>()), + ); + expect(networkChecks, 1); + expect(requests, 0); + }); + test('a lost MKCOL response is reconciled by a matching PROPFIND', () async { var requests = 0; var refreshes = 0; @@ -334,3 +360,7 @@ const _forbiddenProppatch = '' 'HTTP/1.1 403 Forbidden' ''; + +final class _OfflineTestException implements Exception { + const _OfflineTestException(); +} diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index 8c4b79a1..f4dc1c0f 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -21,7 +21,7 @@ void main() { await database.close(); }); - test('opens schema version 11 and creates required indexes', () async { + test('opens schema version 12 and creates required indexes', () async { final version = await database .customSelect('PRAGMA user_version') .getSingle(); @@ -38,8 +38,11 @@ void main() { final calendarSourceColumns = await database .customSelect('PRAGMA table_info(calendar_sources)') .get(); + final taskListColumns = await database + .customSelect('PRAGMA table_info(task_lists)') + .get(); - expect(version.data['user_version'], 11); + expect(version.data['user_version'], 12); expect( taskColumns.map((row) => row.read('name')), contains('microsoft_checklist_items_json'), @@ -48,6 +51,10 @@ void main() { calendarSourceColumns.map((row) => row.read('name')), contains('reminders_enabled'), ); + expect( + taskListColumns.map((row) => row.read('name')), + contains('reminders_enabled'), + ); expect(indexes.map((row) => row.data['name']).toSet(), { 'idx_accounts_provider', 'idx_accounts_remote_identity', @@ -106,7 +113,7 @@ void main() { expect( (await database.customSelect('PRAGMA user_version').getSingle()) .read('user_version'), - 11, + 12, ); await database @@ -136,6 +143,51 @@ void main() { await tempDir.delete(recursive: true); }); + test( + 'schema 11 migration enables task-list reminders without data loss', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v11-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaElevenDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaElevenDatabase); + await schemaElevenDatabase.taskListsDao.upsertTaskList( + _taskList(id: 'list-1', title: 'Existing list'), + ); + await schemaElevenDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('ALTER TABLE task_lists DROP COLUMN reminders_enabled'); + raw.execute('PRAGMA user_version = 11'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final version = await database + .customSelect('PRAGMA user_version') + .getSingle(); + final list = await (database.select( + database.taskLists, + )..where((row) => row.id.equals('list-1'))).getSingle(); + + expect(version.read('user_version'), 12); + expect(list.title, 'Existing list'); + expect(list.remindersEnabled, isTrue); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + test( 'schema 9 migration adds calendar ownership columns without data loss', () async { @@ -181,7 +233,7 @@ void main() { database.calendarSources, )..where((row) => row.id.equals('calendar'))).getSingle(); - expect(version.read('user_version'), 11); + expect(version.read('user_version'), 12); expect( columns.map((row) => row.read('name')), containsAll(['data_owner', 'is_removable']), @@ -381,7 +433,7 @@ void main() { final version = await database .customSelect('PRAGMA user_version') .getSingle(); - expect(version.read('user_version'), 11); + expect(version.read('user_version'), 12); final accounts = await database.select(database.accounts).get(); expect(accounts, hasLength(2)); @@ -582,7 +634,7 @@ void main() { .getSingle(); final op = await database.pendingOpsDao.getOp('op-1'); - expect(version.data['user_version'], 11); + expect(version.data['user_version'], 12); expect(op, isNot(equals(null))); expect(op!.baselineRawJson, equals(null)); diff --git a/test/features/accounts/domain/account_collection_creation_capabilities_test.dart b/test/features/accounts/domain/account_collection_creation_capabilities_test.dart new file mode 100644 index 00000000..f0b1d295 --- /dev/null +++ b/test/features/accounts/domain/account_collection_creation_capabilities_test.dart @@ -0,0 +1,133 @@ +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/accounts/domain/account_collection_creation_capabilities.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('provider matrix resolves distinct collection creation modes', () { + expect( + accountCollectionCreationModes(BusyProvider.google), + _hasModes( + CalendarCollectionCreationMode.cloudPendingOperation, + TaskListCreationMode.cloudPendingOperation, + ), + ); + expect( + accountCollectionCreationModes(BusyProvider.microsoft), + _hasModes( + CalendarCollectionCreationMode.cloudPendingOperation, + TaskListCreationMode.cloudPendingOperation, + ), + ); + expect( + accountCollectionCreationModes(BusyProvider.nextcloud), + _hasModes( + CalendarCollectionCreationMode.nextcloudDav, + TaskListCreationMode.nextcloudDav, + ), + ); + for (final provider in [BusyProvider.appleICloud, BusyProvider.webCal]) { + expect( + accountCollectionCreationModes(provider), + _hasModes( + CalendarCollectionCreationMode.unavailable, + TaskListCreationMode.unavailable, + ), + ); + } + }); + + test('account service switches omit their corresponding actions', () { + final withoutCalendars = _resolve( + _account(BusyProvider.google, calendarsEnabled: false), + ); + final withoutTasks = _resolve( + _account(BusyProvider.microsoft, tasksEnabled: false), + ); + + expect( + withoutCalendars.calendarMode, + CalendarCollectionCreationMode.unavailable, + ); + expect(withoutCalendars.supportsTaskListCreation, isTrue); + expect(withoutTasks.supportsCalendarCreation, isTrue); + expect(withoutTasks.taskListMode, TaskListCreationMode.unavailable); + }); + + test('reconnect and disconnected states disable supported entries', () { + for (final authState in [ + accountAuthStateReauthRequired, + accountAuthStateTemporarilyUnavailable, + ]) { + final capabilities = _resolve( + _account(BusyProvider.google, authState: authState), + ); + expect(capabilities.supportsCalendarCreation, isTrue); + expect(capabilities.supportsTaskListCreation, isTrue); + expect(capabilities.calendarActionEnabled, isFalse); + expect(capabilities.taskListActionEnabled, isFalse); + } + }); + + test('offline behavior preserves cloud queueing and disables DAV', () { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + final capabilities = _resolve( + _account(provider), + network: NetworkAvailability.offline, + ); + expect(capabilities.calendarActionEnabled, isTrue); + expect(capabilities.taskListActionEnabled, isTrue); + } + + final nextcloud = _resolve( + _account(BusyProvider.nextcloud), + network: NetworkAvailability.offline, + ); + expect(nextcloud.calendarMode, CalendarCollectionCreationMode.nextcloudDav); + expect(nextcloud.taskListMode, TaskListCreationMode.nextcloudDav); + expect(nextcloud.calendarActionEnabled, isFalse); + expect(nextcloud.taskListActionEnabled, isFalse); + }); + + test('running state disables only the matching creation action', () { + final capabilities = AccountCollectionCreationCapabilities.resolve( + account: _account(BusyProvider.google), + networkAvailability: NetworkAvailability.online, + calendarCreationRunning: true, + ); + + expect(capabilities.calendarActionEnabled, isFalse); + expect(capabilities.taskListActionEnabled, isTrue); + }); +} + +Matcher _hasModes( + CalendarCollectionCreationMode calendarMode, + TaskListCreationMode taskListMode, +) => isA() + .having((value) => value.calendarMode, 'calendarMode', calendarMode) + .having((value) => value.taskListMode, 'taskListMode', taskListMode); + +AccountCollectionCreationCapabilities _resolve( + AccountEntity account, { + NetworkAvailability network = NetworkAvailability.online, +}) => AccountCollectionCreationCapabilities.resolve( + account: account, + networkAvailability: network, +); + +AccountEntity _account( + BusyProvider provider, { + bool calendarsEnabled = true, + bool tasksEnabled = true, + String authState = accountAuthStateSignedIn, +}) => AccountEntity( + id: provider.storageValue, + provider: provider, + authority: 'https://example.test', + providerAccountId: 'account', + authState: authState, + calendarsEnabled: calendarsEnabled, + tasksEnabled: tasksEnabled, +); diff --git a/test/features/calendar/data/calendar_collection_creation_service_test.dart b/test/features/calendar/data/calendar_collection_creation_service_test.dart new file mode 100644 index 00000000..67ce2865 --- /dev/null +++ b/test/features/calendar/data/calendar_collection_creation_service_test.dart @@ -0,0 +1,190 @@ +import 'package:busymax/src/dav/http/dav_http_transport.dart'; +import 'package:busymax/src/dav/mutation/dav_calendar_collection_mutation_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/calendar/data/calendar_collection_creation_service.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:drift/drift.dart' hide isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late _FakeDavCalendarClient davClient; + late List syncRequests; + + setUp(() { + database = AppDatabase(NativeDatabase.memory()); + davClient = _FakeDavCalendarClient(); + syncRequests = []; + }); + + tearDown(() => database.close()); + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test('${provider.displayName} preserves cloud pending creation', () async { + await _seedAccount(database, provider); + final result = await _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Projects'); + + expect(result.outcome, CalendarCollectionCreationOutcome.queued); + expect(result.localSourceId, isNotNull); + final source = await database + .select(database.calendarSources) + .getSingle(); + expect(source.providerCalendarId, startsWith('local:')); + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'calendar.create'); + expect(operation.accountId, 'account'); + expect(syncRequests, ['account']); + expect(davClient.titles, isEmpty); + }); + } + + test( + 'Nextcloud uses DAV without a local source or pending operation', + () async { + await _seedAccount(database, BusyProvider.nextcloud); + final result = await _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Nextcloud calendar'); + + expect(result.outcome, CalendarCollectionCreationOutcome.created); + expect(davClient.titles, ['Nextcloud calendar']); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(syncRequests, isEmpty); + }, + ); + + test('maps a successful remote creation with pending refresh', () async { + await _seedAccount(database, BusyProvider.nextcloud); + davClient.result = const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.createdRefreshPending, + ); + + final result = await _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Remote'); + + expect( + result.outcome, + CalendarCollectionCreationOutcome.createdRefreshPending, + ); + }); + + for (final provider in [BusyProvider.appleICloud, BusyProvider.webCal]) { + test('${provider.displayName} is rejected before local mutation', () async { + await _seedAccount(database, provider); + + await expectLater( + _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Unsupported'), + throwsA(isA()), + ); + expect(await database.select(database.calendarSources).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(davClient.titles, isEmpty); + expect(syncRequests, isEmpty); + }); + } + + test('disabled calendar service rejects before local mutation', () async { + await _seedAccount(database, BusyProvider.google, calendarsEnabled: false); + + await expectLater( + _service( + database, + davClient, + syncRequests, + ).createCalendar(accountId: 'account', title: 'Disabled'), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('cloud repository defensively rejects Nextcloud', () async { + await _seedAccount(database, BusyProvider.nextcloud); + + await expectLater( + CalendarRepository( + database: database, + ).createLocalSource(accountId: 'account', summary: 'Wrong route'), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); +} + +CalendarCollectionCreationService _service( + AppDatabase database, + _FakeDavCalendarClient davClient, + List syncRequests, +) => CalendarCollectionCreationService( + accountsRepository: AccountsRepository(database: database), + calendarRepository: CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 8, 29, 12), + ), + davClientForAccount: (_) => davClient, + requestCloudSynchronization: syncRequests.add, +); + +Future _seedAccount( + AppDatabase database, + BusyProvider provider, { + bool calendarsEnabled = true, +}) async { + const now = '2026-08-29T12:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: provider.storageValue, + authority: 'https://example.test', + providerAccountId: 'identity', + credentialKind: provider == BusyProvider.webCal + ? 'webcal_subscription' + : provider == BusyProvider.nextcloud + ? 'nextcloud_app_password' + : provider == BusyProvider.appleICloud + ? 'apple_app_specific_password' + : 'oauth', + authState: const Value(accountAuthStateSignedIn), + calendarsEnabled: Value(calendarsEnabled), + tasksEnabled: Value(provider != BusyProvider.webCal), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); +} + +final class _FakeDavCalendarClient + implements DavCalendarCollectionMutationClient { + final titles = []; + DavCalendarCollectionCreationResult result = + const DavCalendarCollectionCreationResult( + DavCalendarCollectionCreationStatus.created, + ); + + @override + Future createEventCalendar( + String title, { + DavCancellationToken? cancellationToken, + }) async { + titles.add(title); + return result; + } +} diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index 7b4898bb..3f71320e 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -570,6 +570,30 @@ void main() { expect(await database.select(database.notificationSchedule).get(), isEmpty); }); + test('task-list reminder policy controls scheduled task reminders', () async { + await _insertTaskReminder(database, status: 'needsAction'); + await service.rebuildUpcomingTaskNotifications('microsoft:m'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + + await database + .update(database.taskLists) + .write(const TaskListsCompanion(remindersEnabled: Value(false))); + await service.rebuildUpcomingTaskNotifications('microsoft:m'); + expect(await database.select(database.notificationSchedule).get(), isEmpty); + + await database + .update(database.taskLists) + .write(const TaskListsCompanion(remindersEnabled: Value(true))); + await service.rebuildUpcomingTaskNotifications('microsoft:m'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + }); + test('server-missing task removes scheduled task reminder', () async { await _insertTaskReminder(database, status: 'needsAction'); await service.rebuildUpcomingTaskNotifications('microsoft:m'); diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart new file mode 100644 index 00000000..e7870b38 --- /dev/null +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -0,0 +1,452 @@ +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_collection_creation_service.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/platform/native_menu_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _nativeMenuChannel, + (_) async => throw MissingPluginException(), + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeMenuChannel, null); + }); + + testWidgets('Google, Microsoft, and Nextcloud headers expose both actions', ( + tester, + ) async { + for (final provider in [ + BusyProvider.google, + BusyProvider.microsoft, + BusyProvider.nextcloud, + ]) { + await _pumpSidebar(tester, [_account(provider)]); + + expect(find.byTooltip('Options'), findsOneWidget); + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('New task list…'), findsOneWidget); + + await tester.tapAt(const Offset(790, 790)); + await tester.pumpAndSettle(); + } + }); + + testWidgets('Apple has no empty collection Options menu', (tester) async { + await _pumpSidebar(tester, [_account(BusyProvider.appleICloud)]); + + expect(find.byTooltip('Options'), findsNothing); + expect(find.text('No calendars synced yet.'), findsOneWidget); + expect(find.text('No task lists synced yet.'), findsOneWidget); + }); + + testWidgets('WebCal is not rendered as a normal provider account group', ( + tester, + ) async { + await _pumpSidebar(tester, [_account(BusyProvider.webCal)]); + + expect(find.text('Subscriptions'), findsOneWidget); + expect(find.text('WebCal account'), findsNothing); + expect(find.byTooltip('Options'), findsNothing); + }); + + testWidgets('Options remains available while collapsed and does not toggle', ( + tester, + ) async { + const accountId = 'google-account'; + await _pumpSidebar(tester, [_account(BusyProvider.google, id: accountId)]); + + expect(find.text('No calendars synced yet.'), findsOneWidget); + await tester.tap( + find.byKey(const ValueKey(('account-collapse', accountId))), + ); + await tester.pumpAndSettle(); + expect(find.text('No calendars synced yet.'), findsNothing); + expect(find.byTooltip('Options'), findsOneWidget); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('No calendars synced yet.'), findsNothing); + }); + + testWidgets('creation actions stay in the header for empty accounts', ( + tester, + ) async { + await _pumpSidebar(tester, [_account(BusyProvider.google)]); + + expect(find.text('No calendars synced yet.'), findsOneWidget); + expect(find.text('No task lists synced yet.'), findsOneWidget); + expect( + find.byKey(const ValueKey(('new-calendar', 'google-account'))), + findsNothing, + ); + expect(find.text('New calendar'), findsNothing); + expect(find.text('New task list'), findsNothing); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('New task list…'), findsOneWidget); + }); + + testWidgets('service switches omit only the disabled collection action', ( + tester, + ) async { + await _pumpSidebar(tester, [ + _account( + BusyProvider.google, + calendarsEnabled: false, + tasksEnabled: true, + ), + ]); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + expect(find.text('New calendar…'), findsNothing); + expect(find.text('New task list…'), findsOneWidget); + }); + + testWidgets('offline Nextcloud keeps actions visible but disabled', ( + tester, + ) async { + await _pumpSidebar(tester, [ + _account(BusyProvider.nextcloud), + ], networkAvailability: NetworkAvailability.offline); + final menu = tester + .widget>( + find.byKey( + const ValueKey(('account-collection-options', 'nextcloud-account')), + ), + ); + + expect(menu.entries, hasLength(2)); + expect(menu.entries.every((entry) => !entry.enabled), isTrue); + }); + + testWidgets('active creation disables repeat activation and runs once', ( + tester, + ) async { + final creationService = _FakeCalendarCollectionCreationService(); + await _pumpSidebar(tester, [ + _account(BusyProvider.google), + ], creationService: creationService); + + await tester.tap(find.byTooltip('Options')); + await tester.pumpAndSettle(); + await tester.tap(find.text('New calendar…')); + await tester.pumpAndSettle(); + + final menu = tester + .widget>( + find.byKey( + const ValueKey(('account-collection-options', 'google-account')), + ), + ); + expect( + menu.entries + .singleWhere( + (entry) => entry.value == AccountHeaderCollectionAction.newCalendar, + ) + .enabled, + isFalse, + ); + + await tester.enterText(find.byType(TextField), 'One calendar'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(creationService.titles, ['One calendar']); + }); + + testWidgets('header Options supports keyboard activation', (tester) async { + await _pumpSidebar(tester, [_account(BusyProvider.google)]); + final menuFinder = find.byKey( + const ValueKey(('account-collection-options', 'google-account')), + ); + final focusFinder = find + .descendant(of: menuFinder, matching: find.byType(Focus)) + .first; + tester.widget(focusFinder).focusNode?.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text('New calendar…'), findsOneWidget); + expect(find.text('New task list…'), findsOneWidget); + }); + + testWidgets('account-header controls follow RTL without overlap', ( + tester, + ) async { + await _pumpSidebar(tester, [ + _account(BusyProvider.google), + ], locale: const Locale('ar')); + final options = find.byKey( + const ValueKey(('account-collection-options', 'google-account')), + ); + final collapse = find.byKey( + const ValueKey(('account-collapse', 'google-account')), + ); + + expect(Directionality.of(tester.element(options)), TextDirection.rtl); + expect(tester.getRect(options).overlaps(tester.getRect(collapse)), isFalse); + }); + + testWidgets('calendar reminder actions show their current state', ( + tester, + ) async { + await _pumpSidebar( + tester, + [_account(BusyProvider.google)], + calendarSources: const [ + CalendarSourceEntity( + id: 'reminders-on', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'on', + summary: 'Reminders on', + selected: true, + remindersEnabled: true, + hidden: false, + readOnly: false, + isDeleted: false, + ), + CalendarSourceEntity( + id: 'reminders-off', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'off', + summary: 'Reminders off', + selected: true, + remindersEnabled: false, + hidden: false, + readOnly: false, + isDeleted: false, + ), + ], + ); + + final enabledEntry = tester + .widget>( + find.byKey(const ValueKey(('calendar-options', 'reminders-on'))), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(enabledEntry.label, 'Event reminders — On'); + expect(enabledEntry.icon, Icons.notifications_outlined); + expect(enabledEntry.role, BusyMaxMenuEntryRole.command); + expect(enabledEntry.selected, isFalse); + + final disabledEntry = tester + .widget>( + find.byKey(const ValueKey(('calendar-options', 'reminders-off'))), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(disabledEntry.label, 'Event reminders — Off'); + expect(disabledEntry.icon, Icons.notifications_off_outlined); + expect(disabledEntry.role, BusyMaxMenuEntryRole.command); + expect(disabledEntry.selected, isFalse); + }); + + testWidgets('task-list reminder actions show their current state', ( + tester, + ) async { + await _pumpSidebar( + tester, + [_account(BusyProvider.google)], + taskLists: const [ + TaskListEntity( + accountId: 'google-account', + id: 'reminders-on', + title: 'Reminders on', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + TaskListEntity( + accountId: 'google-account', + id: 'reminders-off', + title: 'Reminders off', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + remindersEnabled: false, + ), + ], + ); + + final enabledEntry = tester + .widget>( + find.byKey( + const ValueKey(( + 'task-list-options', + 'google-account', + 'reminders-on', + )), + ), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(enabledEntry.label, 'Task reminders — On'); + expect(enabledEntry.icon, Icons.notifications_outlined); + expect(enabledEntry.role, BusyMaxMenuEntryRole.command); + expect(enabledEntry.selected, isFalse); + + final disabledEntry = tester + .widget>( + find.byKey( + const ValueKey(( + 'task-list-options', + 'google-account', + 'reminders-off', + )), + ), + ) + .entries + .singleWhere((entry) => entry.value == 'toggle-reminders'); + expect(disabledEntry.label, 'Task reminders — Off'); + expect(disabledEntry.icon, Icons.notifications_off_outlined); + expect(disabledEntry.role, BusyMaxMenuEntryRole.command); + expect(disabledEntry.selected, isFalse); + }); +} + +Future _pumpSidebar( + WidgetTester tester, + List accounts, { + CalendarCollectionCreator? creationService, + Locale locale = const Locale('en'), + NetworkAvailability? networkAvailability, + List calendarSources = const [], + List taskLists = const [], +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(900, 1200); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + ProviderScope( + overrides: [ + accountsStreamProvider.overrideWith((ref) => Stream.value(accounts)), + if (networkAvailability != null) + networkAvailabilityProvider.overrideWith( + (ref) => Stream.value(networkAvailability), + ), + calendarRepositoryProvider.overrideWithValue( + _CalendarSourcesRepository(calendarSources), + ), + taskListsRepositoryForAccountProvider.overrideWith( + (ref, accountId) => _TaskListsRepository(taskLists), + ), + if (creationService != null) + calendarCollectionCreationServiceProvider.overrideWithValue( + creationService, + ), + ], + child: localizedTestApp( + locale: locale, + child: SizedBox( + width: 320, + height: 800, + child: ScheduleSidebar( + selectedDate: DateTime(2026, 8, 29), + firstWeekday: DateTime.monday, + items: const [], + onDateSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +final class _CalendarSourcesRepository implements CalendarRepository { + const _CalendarSourcesRepository(this.sources); + + final List sources; + + @override + Stream> watchSourcesForAccounts( + List accountIds, + ) => Stream.value([ + for (final source in sources) + if (accountIds.contains(source.accountId)) source, + ]); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _TaskListsRepository implements TaskListsRepository { + const _TaskListsRepository(this.lists); + + final List lists; + + @override + Stream> watchTaskLists() => Stream.value(lists); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeCalendarCollectionCreationService + implements CalendarCollectionCreator { + final titles = []; + + @override + Future createCalendar({ + required String accountId, + required String title, + }) async { + titles.add(title); + return const CalendarCollectionCreationResult( + outcome: CalendarCollectionCreationOutcome.queued, + ); + } +} + +AccountEntity _account( + BusyProvider provider, { + String? id, + bool calendarsEnabled = true, + bool tasksEnabled = true, +}) => AccountEntity( + id: id ?? '${provider.storageValue}-account', + provider: provider, + authority: 'https://example.test', + providerAccountId: 'identity', + displayName: provider == BusyProvider.webCal + ? 'WebCal account' + : provider.displayName, + authState: accountAuthStateSignedIn, + calendarsEnabled: calendarsEnabled, + tasksEnabled: tasksEnabled, +); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 5b1bea90..445e560d 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -3778,7 +3778,7 @@ void main() { expect(sidebar, contains('visibilityButton: _SourceVisibilityButton')); expect(sidebar, contains('menuButton: BusyMaxMenuButton')); expect(sidebar, contains('tooltip: context.l10n.options')); - expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(3)); + expect('highlightWhenOpen: false'.allMatches(sidebar), hasLength(4)); expect(sidebar, contains('value ? context.l10n.hide : context.l10n.show')); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceCalendar'))); expect(sidebar, isNot(contains('tooltip: context.l10n.sourceTaskList'))); @@ -3794,6 +3794,15 @@ void main() { expect(sidebar, isNot(contains('_SourceVisibilityIndicator'))); }); + test('sidebar does not expose the one-time calendar import action', () { + final sidebar = File( + 'lib/src/features/schedule/presentation/schedule_sidebar.dart', + ).readAsStringSync(); + + expect(sidebar, isNot(contains("ValueKey('import-ics-file')"))); + expect(sidebar, isNot(contains('showIcsImportFlow'))); + }); + test('schedule Create uses a native popover before refresh', () { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index ad31c8c7..831fd944 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -25,7 +25,6 @@ import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; -import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/providers/provider_capabilities.dart'; @@ -745,44 +744,31 @@ void main() { expect(second.state.scheduleDayEndMinute, 24 * 60); }); - testWidgets('Settings creates a new task list for the account card', ( + testWidgets('Settings owns calendar import but not task-list creation', ( tester, ) async { - final googleLists = _FakeTaskListsRepository(); - final microsoftLists = _FakeTaskListsRepository(); final container = _container( selectedAccountId: 'google:g', authRepository: _FakeAuthRepository(), accounts: const [_googleAccount, _microsoftAccount], buildConfig: _configuredBuildConfig, - taskListRepositories: { - 'google:g': googleLists, - 'microsoft:m': microsoftLists, - }, ); addTearDown(container.dispose); await _pumpSettings(tester, container); - final newTaskListButtons = find.text('New task list'); - expect(newTaskListButtons, findsNWidgets(2)); - - await tester.ensureVisible(newTaskListButtons.at(1)); - await tester.tap(newTaskListButtons.at(1)); - await tester.pumpAndSettle(); - - final promptField = find.descendant( - of: find.byType(BusyMaxPromptDialog), - matching: find.byType(TextField), + expect(find.text('New task list'), findsNothing); + expect(find.text('Calendar import'), findsOneWidget); + expect( + find.text( + 'Select a file, review its events, then choose the writable calendar ' + 'that should receive them.', + ), + findsOneWidget, ); - expect(promptField, findsOneWidget); - await tester.enterText(promptField, 'Client work'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - - expect(googleLists.createdTitles, isEmpty); - expect(microsoftLists.createdTitles, ['Client work']); - expect(container.read(selectedAccountIdProvider), 'google:g'); + expect(find.byKey(const ValueKey('import-ics-file')), findsOneWidget); + expect(find.text('Import .ics file'), findsOneWidget); + expect(find.text('Remove account…'), findsNWidgets(2)); }); testWidgets('Settings lists accounts without account switching controls', ( @@ -906,7 +892,6 @@ ProviderContainer _container({ required _FakeAuthRepository authRepository, required List accounts, BuildConfig buildConfig = _emptyBuildConfig, - Map? taskListRepositories, String? activeAccountIdOverride = _useDefaultActiveAccountId, bool useFlutterHeader = false, DavAccountOnboardingService? davOnboardingService, @@ -942,9 +927,6 @@ ProviderContainer _container({ ref.onDispose(service.dispose); return service; }), - taskListsRepositoryForAccountProvider.overrideWith((ref, accountId) { - return taskListRepositories?[accountId] ?? _FakeTaskListsRepository(); - }), ], ); } @@ -1086,18 +1068,6 @@ class _FakeAccountsRepository implements AccountsRepository { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakeTaskListsRepository implements TaskListsRepository { - final createdTitles = []; - - @override - Future createTaskList(String title) async { - createdTitles.add(title); - } - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - class _MemorySettingsStore implements LocalSettingsStore { Map value = {}; diff --git a/test/features/task_lists/data/task_lists_repository_test.dart b/test/features/task_lists/data/task_lists_repository_test.dart index 465bea78..97548af1 100644 --- a/test/features/task_lists/data/task_lists_repository_test.dart +++ b/test/features/task_lists/data/task_lists_repository_test.dart @@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/dav/mutation/dav_task_list_mutation_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; void main() { late AppDatabase database; @@ -48,6 +50,56 @@ void main() { expect(mutationQueuedCalls, 1); }); + test('Microsoft createTaskList keeps the cloud pending path', () async { + await (database.update( + database.accounts, + )..where((row) => row.id.equals('account'))).write( + const AccountsCompanion( + provider: Value('microsoft'), + authority: Value('https://login.microsoftonline.com/common'), + providerAccountId: Value('microsoft-account'), + ), + ); + + await repository.createTaskList('Microsoft list'); + + final list = await database.select(database.taskLists).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + expect(list.id, startsWith('local-tasklist-')); + expect(operation.operation, 'create_task_list'); + expect(jsonDecode(operation.requestJson), {'title': 'Microsoft list'}); + }); + + test('createTaskList requires a non-empty title', () async { + await expectLater(repository.createTaskList(' '), throwsArgumentError); + expect(await database.select(database.taskLists).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + for (final provider in [BusyProvider.appleICloud, BusyProvider.webCal]) { + test('${provider.displayName} task-list creation is rejected', () async { + await (database.update( + database.accounts, + )..where((row) => row.id.equals('account'))).write( + AccountsCompanion( + provider: Value(provider.storageValue), + credentialKind: Value( + provider == BusyProvider.webCal + ? 'webcal_subscription' + : 'apple_app_specific_password', + ), + ), + ); + + await expectLater( + repository.createTaskList('Unsupported'), + throwsStateError, + ); + expect(await database.select(database.taskLists).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + } + test('renameTaskList updates local row and queues patch op', () async { var mutationQueuedCalls = 0; repository = _repository( @@ -119,6 +171,54 @@ void main() { expect(watchedLists.map((list) => list.id), ['visible']); }); + test( + 'setRemindersEnabled updates only the local notification policy', + () async { + var scheduleChangedCalls = 0; + repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + onNotificationScheduleChanged: () async { + scheduleChangedCalls += 1; + }, + ); + await database.taskListsDao.upsertTaskList(_taskList('list-1')); + + await repository.setRemindersEnabled('list-1', false); + + final list = await database.select(database.taskLists).getSingle(); + expect(list.remindersEnabled, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(scheduleChangedCalls, 1); + }, + ); + + test( + 'provider refresh preserves the local task-list reminder policy', + () async { + await database.taskListsDao.upsertTaskList( + _taskList('list-1', remindersEnabled: const Value(false)), + ); + + await database.taskListsDao.upsertTaskList( + taskListFromDto( + 'account', + const TaskListDto( + id: 'list-1', + title: 'Renamed remotely', + rawJson: {'id': 'list-1', 'title': 'Renamed remotely'}, + ), + _now, + ), + ); + + final list = await database.select(database.taskLists).getSingle(); + expect(list.title, 'Renamed remotely'); + expect(list.remindersEnabled, isFalse); + }, + ); + test( 'Nextcloud list mutations use CalDAV instead of REST pending ops', () async { @@ -205,6 +305,7 @@ Future _insertAccount(AppDatabase database) { authority: 'https://accounts.google.com', providerAccountId: 'google-account', credentialKind: 'oauth', + authState: const Value('signed_in'), createdAtUtc: _now, updatedAtUtc: _now, ), @@ -217,6 +318,7 @@ TaskListsCompanion _taskList( String id, { Value pendingDelete = const Value.absent(), Value serverMissing = const Value.absent(), + Value remindersEnabled = const Value.absent(), }) { return TaskListsCompanion.insert( accountId: 'account', @@ -224,6 +326,7 @@ TaskListsCompanion _taskList( title: 'Inbox', pendingDelete: pendingDelete, serverMissing: serverMissing, + remindersEnabled: remindersEnabled, rawJson: '{}', createdLocalAtUtc: _now, updatedLocalAtUtc: _now, From 1aeca96489c6ed85c5e391eaed902a296937b1d0 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 29 Aug 2026 19:57:00 -0700 Subject: [PATCH 11/69] feat: add projection range columns to WebCal subscriptions for extended data handling and querying --- lib/src/app/app_bootstrap.dart | 10 +- lib/src/core/secrets/secret_store.dart | 56 ++- lib/src/db/app_database.g.dart | 172 +++++++++ lib/src/db/migrations.dart | 41 ++- lib/src/db/tables.dart | 9 + .../accounts/data/accounts_repository.dart | 28 +- lib/src/ical/ical_ingestion.dart | 9 +- lib/src/schedule/schedule_repository.dart | 9 +- lib/src/webcal/webcal_http_client.dart | 43 ++- .../webcal/webcal_subscription_service.dart | 342 ++++++++++++++++-- test/db/app_database_test.dart | 199 +++++++++- .../data/accounts_repository_test.dart | 17 + .../schedule/schedule_search_test.dart | 21 ++ .../oauth/oauth_token_store_test.dart | 24 ++ test/ical/ical_ingestion_test.dart | 16 +- test/webcal/webcal_http_client_test.dart | 151 +++++--- .../webcal_subscription_service_test.dart | 180 ++++++++- 17 files changed, 1182 insertions(+), 145 deletions(-) diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 423660c7..31e76987 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -700,7 +700,15 @@ final icalImportServiceProvider = Provider((ref) { }); final scheduleRepositoryProvider = Provider((ref) { - return ScheduleRepository(ref.watch(databaseProvider)); + return ScheduleRepository( + ref.watch(databaseProvider), + ensureProjectionCoverage: (range) => ref + .read(webCalSubscriptionServiceProvider) + .ensureProjectionCoverage( + rangeStartUtc: range.start.toUtc(), + rangeEndUtc: range.end.toUtc(), + ), + ); }); final Provider activeAccountProvider = Provider((ref) { diff --git a/lib/src/core/secrets/secret_store.dart b/lib/src/core/secrets/secret_store.dart index 6c9de7cc..c329b18a 100644 --- a/lib/src/core/secrets/secret_store.dart +++ b/lib/src/core/secrets/secret_store.dart @@ -28,6 +28,30 @@ extension CredentialKindValue on CredentialKind { }; } +CredentialKind credentialKindForProvider(BusyProvider provider) => + switch (provider) { + BusyProvider.google || BusyProvider.microsoft => CredentialKind.oauth, + BusyProvider.appleICloud => CredentialKind.appleAppSpecificPassword, + BusyProvider.nextcloud => CredentialKind.nextcloudAppPassword, + BusyProvider.webCal => CredentialKind.webCalSubscription, + }; + +bool credentialKindMatchesProvider( + BusyProvider provider, + CredentialKind kind, +) => credentialKindForProvider(provider) == kind; + +CredentialKind _credentialKindFromSecretStorage(Object? value) => + switch (value) { + 'oauth' => CredentialKind.oauth, + 'apple_app_specific_password' => CredentialKind.appleAppSpecificPassword, + 'nextcloud_app_password' => CredentialKind.nextcloudAppPassword, + 'webcal_subscription' => CredentialKind.webCalSubscription, + _ => throw SecretStoreCorruptException( + 'Unsupported credential kind $value.', + ), + }; + sealed class SecretRecord { const SecretRecord({required this.provider, required this.kind}); @@ -45,8 +69,14 @@ sealed class SecretRecord { final provider = BusyProviderCodec.requireStorageValue( json['provider']?.toString(), ); - return switch (json['kind']) { - 'oauth' => OAuthSecretRecord( + final kind = _credentialKindFromSecretStorage(json['kind']); + if (!credentialKindMatchesProvider(provider, kind)) { + throw const SecretStoreCorruptException( + 'The credential provider and kind do not match.', + ); + } + return switch (kind) { + CredentialKind.oauth => OAuthSecretRecord( provider: provider, tokenSet: OAuthTokenSet( accessToken: _requiredSecretString(json, 'accessToken'), @@ -59,18 +89,18 @@ sealed class SecretRecord { scopes: _stringList(json['scopes']).toSet(), ), ), - 'apple_app_specific_password' => AppleICloudSecretRecord( + CredentialKind.appleAppSpecificPassword => AppleICloudSecretRecord( username: _requiredSecretString(json, 'username'), appSpecificPassword: _requiredSecretString(json, 'appSpecificPassword'), ), - 'nextcloud_app_password' => NextcloudSecretRecord( + CredentialKind.nextcloudAppPassword => NextcloudSecretRecord( canonicalServer: Uri.parse( _requiredSecretString(json, 'canonicalServer'), ), loginName: _requiredSecretString(json, 'loginName'), appPassword: _requiredSecretString(json, 'appPassword'), ), - 'webcal_subscription' => WebCalSecretRecord( + CredentialKind.webCalSubscription => WebCalSecretRecord( normalizedSubscriptionUri: _requiredSecretString( json, 'normalizedSubscriptionUri', @@ -83,9 +113,6 @@ sealed class SecretRecord { null => null, }, ), - final Object? value => throw SecretStoreCorruptException( - 'Unsupported credential kind $value.', - ), }; } @@ -95,11 +122,14 @@ sealed class SecretRecord { } final class OAuthSecretRecord extends SecretRecord { - const OAuthSecretRecord({required super.provider, required this.tokenSet}) - : assert( - provider == BusyProvider.google || provider == BusyProvider.microsoft, - ), - super(kind: CredentialKind.oauth); + OAuthSecretRecord({required super.provider, required this.tokenSet}) + : super(kind: CredentialKind.oauth) { + if (!credentialKindMatchesProvider(provider, kind)) { + throw const SecretStoreCorruptException( + 'OAuth credentials require a Google or Microsoft provider.', + ); + } + } final OAuthTokenSet tokenSet; diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart index 5fad58c6..df3cb91d 100644 --- a/lib/src/db/app_database.g.dart +++ b/lib/src/db/app_database.g.dart @@ -19555,6 +19555,28 @@ class $WebCalSubscriptionsTable extends WebCalSubscriptions requiredDuringInsert: false, defaultValue: const Constant(1), ); + static const VerificationMeta _projectionRangeStartUtcMeta = + const VerificationMeta('projectionRangeStartUtc'); + @override + late final GeneratedColumn projectionRangeStartUtc = + GeneratedColumn( + 'projection_range_start_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _projectionRangeEndUtcMeta = + const VerificationMeta('projectionRangeEndUtc'); + @override + late final GeneratedColumn projectionRangeEndUtc = + GeneratedColumn( + 'projection_range_end_utc', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _createdAtUtcMeta = const VerificationMeta( 'createdAtUtc', ); @@ -19603,6 +19625,8 @@ class $WebCalSubscriptionsTable extends WebCalSubscriptions generation, parserVersion, projectionVersion, + projectionRangeStartUtc, + projectionRangeEndUtc, createdAtUtc, updatedAtUtc, ]; @@ -19836,6 +19860,24 @@ class $WebCalSubscriptionsTable extends WebCalSubscriptions ), ); } + if (data.containsKey('projection_range_start_utc')) { + context.handle( + _projectionRangeStartUtcMeta, + projectionRangeStartUtc.isAcceptableOrUnknown( + data['projection_range_start_utc']!, + _projectionRangeStartUtcMeta, + ), + ); + } + if (data.containsKey('projection_range_end_utc')) { + context.handle( + _projectionRangeEndUtcMeta, + projectionRangeEndUtc.isAcceptableOrUnknown( + data['projection_range_end_utc']!, + _projectionRangeEndUtcMeta, + ), + ); + } if (data.containsKey('created_at_utc')) { context.handle( _createdAtUtcMeta, @@ -19968,6 +20010,14 @@ class $WebCalSubscriptionsTable extends WebCalSubscriptions DriftSqlType.int, data['${effectivePrefix}projection_version'], )!, + projectionRangeStartUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_range_start_utc'], + ), + projectionRangeEndUtc: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_range_end_utc'], + ), createdAtUtc: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}created_at_utc'], @@ -20011,6 +20061,8 @@ class WebCalSubscription extends DataClass final int generation; final int parserVersion; final int projectionVersion; + final String? projectionRangeStartUtc; + final String? projectionRangeEndUtc; final String createdAtUtc; final String updatedAtUtc; const WebCalSubscription({ @@ -20038,6 +20090,8 @@ class WebCalSubscription extends DataClass required this.generation, required this.parserVersion, required this.projectionVersion, + this.projectionRangeStartUtc, + this.projectionRangeEndUtc, required this.createdAtUtc, required this.updatedAtUtc, }); @@ -20094,6 +20148,14 @@ class WebCalSubscription extends DataClass map['generation'] = Variable(generation); map['parser_version'] = Variable(parserVersion); map['projection_version'] = Variable(projectionVersion); + if (!nullToAbsent || projectionRangeStartUtc != null) { + map['projection_range_start_utc'] = Variable( + projectionRangeStartUtc, + ); + } + if (!nullToAbsent || projectionRangeEndUtc != null) { + map['projection_range_end_utc'] = Variable(projectionRangeEndUtc); + } map['created_at_utc'] = Variable(createdAtUtc); map['updated_at_utc'] = Variable(updatedAtUtc); return map; @@ -20145,6 +20207,12 @@ class WebCalSubscription extends DataClass generation: Value(generation), parserVersion: Value(parserVersion), projectionVersion: Value(projectionVersion), + projectionRangeStartUtc: projectionRangeStartUtc == null && nullToAbsent + ? const Value.absent() + : Value(projectionRangeStartUtc), + projectionRangeEndUtc: projectionRangeEndUtc == null && nullToAbsent + ? const Value.absent() + : Value(projectionRangeEndUtc), createdAtUtc: Value(createdAtUtc), updatedAtUtc: Value(updatedAtUtc), ); @@ -20190,6 +20258,12 @@ class WebCalSubscription extends DataClass generation: serializer.fromJson(json['generation']), parserVersion: serializer.fromJson(json['parserVersion']), projectionVersion: serializer.fromJson(json['projectionVersion']), + projectionRangeStartUtc: serializer.fromJson( + json['projectionRangeStartUtc'], + ), + projectionRangeEndUtc: serializer.fromJson( + json['projectionRangeEndUtc'], + ), createdAtUtc: serializer.fromJson(json['createdAtUtc']), updatedAtUtc: serializer.fromJson(json['updatedAtUtc']), ); @@ -20230,6 +20304,12 @@ class WebCalSubscription extends DataClass 'generation': serializer.toJson(generation), 'parserVersion': serializer.toJson(parserVersion), 'projectionVersion': serializer.toJson(projectionVersion), + 'projectionRangeStartUtc': serializer.toJson( + projectionRangeStartUtc, + ), + 'projectionRangeEndUtc': serializer.toJson( + projectionRangeEndUtc, + ), 'createdAtUtc': serializer.toJson(createdAtUtc), 'updatedAtUtc': serializer.toJson(updatedAtUtc), }; @@ -20260,6 +20340,8 @@ class WebCalSubscription extends DataClass int? generation, int? parserVersion, int? projectionVersion, + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), String? createdAtUtc, String? updatedAtUtc, }) => WebCalSubscription( @@ -20302,6 +20384,12 @@ class WebCalSubscription extends DataClass generation: generation ?? this.generation, parserVersion: parserVersion ?? this.parserVersion, projectionVersion: projectionVersion ?? this.projectionVersion, + projectionRangeStartUtc: projectionRangeStartUtc.present + ? projectionRangeStartUtc.value + : this.projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc.present + ? projectionRangeEndUtc.value + : this.projectionRangeEndUtc, createdAtUtc: createdAtUtc ?? this.createdAtUtc, updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, ); @@ -20373,6 +20461,12 @@ class WebCalSubscription extends DataClass projectionVersion: data.projectionVersion.present ? data.projectionVersion.value : this.projectionVersion, + projectionRangeStartUtc: data.projectionRangeStartUtc.present + ? data.projectionRangeStartUtc.value + : this.projectionRangeStartUtc, + projectionRangeEndUtc: data.projectionRangeEndUtc.present + ? data.projectionRangeEndUtc.value + : this.projectionRangeEndUtc, createdAtUtc: data.createdAtUtc.present ? data.createdAtUtc.value : this.createdAtUtc, @@ -20411,6 +20505,8 @@ class WebCalSubscription extends DataClass ..write('generation: $generation, ') ..write('parserVersion: $parserVersion, ') ..write('projectionVersion: $projectionVersion, ') + ..write('projectionRangeStartUtc: $projectionRangeStartUtc, ') + ..write('projectionRangeEndUtc: $projectionRangeEndUtc, ') ..write('createdAtUtc: $createdAtUtc, ') ..write('updatedAtUtc: $updatedAtUtc') ..write(')')) @@ -20443,6 +20539,8 @@ class WebCalSubscription extends DataClass generation, parserVersion, projectionVersion, + projectionRangeStartUtc, + projectionRangeEndUtc, createdAtUtc, updatedAtUtc, ]); @@ -20475,6 +20573,8 @@ class WebCalSubscription extends DataClass other.generation == this.generation && other.parserVersion == this.parserVersion && other.projectionVersion == this.projectionVersion && + other.projectionRangeStartUtc == this.projectionRangeStartUtc && + other.projectionRangeEndUtc == this.projectionRangeEndUtc && other.createdAtUtc == this.createdAtUtc && other.updatedAtUtc == this.updatedAtUtc); } @@ -20504,6 +20604,8 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { final Value generation; final Value parserVersion; final Value projectionVersion; + final Value projectionRangeStartUtc; + final Value projectionRangeEndUtc; final Value createdAtUtc; final Value updatedAtUtc; final Value rowid; @@ -20532,6 +20634,8 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { this.generation = const Value.absent(), this.parserVersion = const Value.absent(), this.projectionVersion = const Value.absent(), + this.projectionRangeStartUtc = const Value.absent(), + this.projectionRangeEndUtc = const Value.absent(), this.createdAtUtc = const Value.absent(), this.updatedAtUtc = const Value.absent(), this.rowid = const Value.absent(), @@ -20561,6 +20665,8 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { this.generation = const Value.absent(), this.parserVersion = const Value.absent(), this.projectionVersion = const Value.absent(), + this.projectionRangeStartUtc = const Value.absent(), + this.projectionRangeEndUtc = const Value.absent(), required String createdAtUtc, required String updatedAtUtc, this.rowid = const Value.absent(), @@ -20601,6 +20707,8 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { Expression? generation, Expression? parserVersion, Expression? projectionVersion, + Expression? projectionRangeStartUtc, + Expression? projectionRangeEndUtc, Expression? createdAtUtc, Expression? updatedAtUtc, Expression? rowid, @@ -20635,6 +20743,10 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { if (generation != null) 'generation': generation, if (parserVersion != null) 'parser_version': parserVersion, if (projectionVersion != null) 'projection_version': projectionVersion, + if (projectionRangeStartUtc != null) + 'projection_range_start_utc': projectionRangeStartUtc, + if (projectionRangeEndUtc != null) + 'projection_range_end_utc': projectionRangeEndUtc, if (createdAtUtc != null) 'created_at_utc': createdAtUtc, if (updatedAtUtc != null) 'updated_at_utc': updatedAtUtc, if (rowid != null) 'rowid': rowid, @@ -20666,6 +20778,8 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { Value? generation, Value? parserVersion, Value? projectionVersion, + Value? projectionRangeStartUtc, + Value? projectionRangeEndUtc, Value? createdAtUtc, Value? updatedAtUtc, Value? rowid, @@ -20700,6 +20814,10 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { generation: generation ?? this.generation, parserVersion: parserVersion ?? this.parserVersion, projectionVersion: projectionVersion ?? this.projectionVersion, + projectionRangeStartUtc: + projectionRangeStartUtc ?? this.projectionRangeStartUtc, + projectionRangeEndUtc: + projectionRangeEndUtc ?? this.projectionRangeEndUtc, createdAtUtc: createdAtUtc ?? this.createdAtUtc, updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, rowid: rowid ?? this.rowid, @@ -20791,6 +20909,16 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { if (projectionVersion.present) { map['projection_version'] = Variable(projectionVersion.value); } + if (projectionRangeStartUtc.present) { + map['projection_range_start_utc'] = Variable( + projectionRangeStartUtc.value, + ); + } + if (projectionRangeEndUtc.present) { + map['projection_range_end_utc'] = Variable( + projectionRangeEndUtc.value, + ); + } if (createdAtUtc.present) { map['created_at_utc'] = Variable(createdAtUtc.value); } @@ -20832,6 +20960,8 @@ class WebCalSubscriptionsCompanion extends UpdateCompanion { ..write('generation: $generation, ') ..write('parserVersion: $parserVersion, ') ..write('projectionVersion: $projectionVersion, ') + ..write('projectionRangeStartUtc: $projectionRangeStartUtc, ') + ..write('projectionRangeEndUtc: $projectionRangeEndUtc, ') ..write('createdAtUtc: $createdAtUtc, ') ..write('updatedAtUtc: $updatedAtUtc, ') ..write('rowid: $rowid') @@ -39637,6 +39767,8 @@ typedef $$WebCalSubscriptionsTableCreateCompanionBuilder = Value generation, Value parserVersion, Value projectionVersion, + Value projectionRangeStartUtc, + Value projectionRangeEndUtc, required String createdAtUtc, required String updatedAtUtc, Value rowid, @@ -39667,6 +39799,8 @@ typedef $$WebCalSubscriptionsTableUpdateCompanionBuilder = Value generation, Value parserVersion, Value projectionVersion, + Value projectionRangeStartUtc, + Value projectionRangeEndUtc, Value createdAtUtc, Value updatedAtUtc, Value rowid, @@ -39846,6 +39980,16 @@ class $$WebCalSubscriptionsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get createdAtUtc => $composableBuilder( column: $table.createdAtUtc, builder: (column) => ColumnFilters(column), @@ -40022,6 +40166,16 @@ class $$WebCalSubscriptionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get createdAtUtc => $composableBuilder( column: $table.createdAtUtc, builder: (column) => ColumnOrderings(column), @@ -40194,6 +40348,16 @@ class $$WebCalSubscriptionsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, + builder: (column) => column, + ); + + GeneratedColumn get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, + builder: (column) => column, + ); + GeneratedColumn get createdAtUtc => $composableBuilder( column: $table.createdAtUtc, builder: (column) => column, @@ -40312,6 +40476,8 @@ class $$WebCalSubscriptionsTableTableManager Value generation = const Value.absent(), Value parserVersion = const Value.absent(), Value projectionVersion = const Value.absent(), + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), Value createdAtUtc = const Value.absent(), Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), @@ -40340,6 +40506,8 @@ class $$WebCalSubscriptionsTableTableManager generation: generation, parserVersion: parserVersion, projectionVersion: projectionVersion, + projectionRangeStartUtc: projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc, createdAtUtc: createdAtUtc, updatedAtUtc: updatedAtUtc, rowid: rowid, @@ -40371,6 +40539,8 @@ class $$WebCalSubscriptionsTableTableManager Value generation = const Value.absent(), Value parserVersion = const Value.absent(), Value projectionVersion = const Value.absent(), + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), required String createdAtUtc, required String updatedAtUtc, Value rowid = const Value.absent(), @@ -40399,6 +40569,8 @@ class $$WebCalSubscriptionsTableTableManager generation: generation, parserVersion: parserVersion, projectionVersion: projectionVersion, + projectionRangeStartUtc: projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc, createdAtUtc: createdAtUtc, updatedAtUtc: updatedAtUtc, rowid: rowid, diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index e08f16c2..362d0a4c 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; -const latestSchemaVersion = 12; +const latestSchemaVersion = 13; /// A recoverable, non-secret diagnostic raised when an on-disk schema cannot /// be migrated without guessing remote identity or losing synchronized data. @@ -60,6 +60,9 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { if (from < 12) { await _migrateToV12(migrator, database); } + if (from < 13) { + await _migrateToV13(migrator, database); + } await _createIndexes(database); await _verifyForeignKeys(database); }, @@ -69,6 +72,24 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { ); } +Future _migrateToV13(Migrator migrator, AppDatabase database) async { + await _verifyAccountIdentity(database); + await migrator.alterTable(TableMigration(database.accounts)); + if (await _hasTable(database, 'web_cal_subscriptions')) { + for (final column in [ + database.webCalSubscriptions.projectionRangeStartUtc, + database.webCalSubscriptions.projectionRangeEndUtc, + ]) { + await _addColumnIfMissing( + migrator, + database, + database.webCalSubscriptions, + column, + ); + } + } +} + Future _migrateToV12(Migrator migrator, AppDatabase database) async { await _addColumnIfMissing( migrator, @@ -183,7 +204,7 @@ Future _migrateToV6(Migrator migrator, AppDatabase database) async { await _migrateGenericCursors(migrator, database); await _verifyPreservedCounts(database, preservedCounts); - await _verifyV6AccountIdentity(database); + await _verifyAccountIdentity(database); } Future _addProjectionLinks( @@ -409,14 +430,22 @@ Future _verifyPreservedCounts( } } -Future _verifyV6AccountIdentity(AppDatabase database) async { +Future _verifyAccountIdentity(AppDatabase database) async { final invalid = await database.customSelect(''' SELECT id FROM accounts - WHERE provider NOT IN ('google', 'microsoft', 'apple_icloud', 'nextcloud') + WHERE provider NOT IN ( + 'google', 'microsoft', 'apple_icloud', 'nextcloud', 'webcal' + ) OR trim(authority) = '' OR trim(provider_account_id) = '' - OR credential_kind NOT IN ( - 'oauth', 'apple_app_specific_password', 'nextcloud_app_password' + OR NOT ( + (provider IN ('google', 'microsoft') AND credential_kind = 'oauth') + OR (provider = 'apple_icloud' AND + credential_kind = 'apple_app_specific_password') + OR (provider = 'nextcloud' AND + credential_kind = 'nextcloud_app_password') + OR (provider = 'webcal' AND + credential_kind = 'webcal_subscription') ) LIMIT 1 ''').getSingleOrNull(); diff --git a/lib/src/db/tables.dart b/lib/src/db/tables.dart index 780d9719..faec3c87 100644 --- a/lib/src/db/tables.dart +++ b/lib/src/db/tables.dart @@ -33,6 +33,13 @@ class Accounts extends Table { "'webcal'))", "CHECK (credential_kind IN ('oauth', 'apple_app_specific_password', " "'nextcloud_app_password', 'webcal_subscription'))", + "CHECK ((provider IN ('google', 'microsoft') AND credential_kind = 'oauth') " + "OR (provider = 'apple_icloud' AND credential_kind = " + "'apple_app_specific_password') " + "OR (provider = 'nextcloud' AND credential_kind = " + "'nextcloud_app_password') " + "OR (provider = 'webcal' AND credential_kind = " + "'webcal_subscription'))", 'CHECK (length(trim(authority)) > 0)', 'CHECK (length(trim(provider_account_id)) > 0)', ]; @@ -526,6 +533,8 @@ class WebCalSubscriptions extends Table { IntColumn get generation => integer().withDefault(const Constant(1))(); IntColumn get parserVersion => integer().withDefault(const Constant(1))(); IntColumn get projectionVersion => integer().withDefault(const Constant(1))(); + TextColumn get projectionRangeStartUtc => text().nullable()(); + TextColumn get projectionRangeEndUtc => text().nullable()(); TextColumn get createdAtUtc => text()(); TextColumn get updatedAtUtc => text()(); diff --git a/lib/src/features/accounts/data/accounts_repository.dart b/lib/src/features/accounts/data/accounts_repository.dart index b91560cc..86bcb86f 100644 --- a/lib/src/features/accounts/data/accounts_repository.dart +++ b/lib/src/features/accounts/data/accounts_repository.dart @@ -42,12 +42,19 @@ class AccountEntity { }); factory AccountEntity.fromRow(Account row) { + final provider = BusyProviderCodec.requireStorageValue(row.provider); + final credentialKind = _credentialKindFromStorage(row.credentialKind); + if (!credentialKindMatchesProvider(provider, credentialKind)) { + throw const SecretStoreCorruptException( + 'Stored account provider and credential kind do not match.', + ); + } return AccountEntity( id: row.id, - provider: BusyProviderCodec.requireStorageValue(row.provider), + provider: provider, authority: row.authority, providerAccountId: row.providerAccountId, - credentialKind: _credentialKindFromStorage(row.credentialKind), + credentialKind: credentialKind, providerProfileVersion: row.providerProfileVersion, displayName: row.displayName, email: row.email, @@ -267,7 +274,14 @@ class AccountsRepository { providerAccountId ?? id, ); final resolvedCredentialKind = - credentialKind ?? _defaultCredentialKind(provider); + credentialKind ?? credentialKindForProvider(provider); + if (!credentialKindMatchesProvider(provider, resolvedCredentialKind)) { + throw ArgumentError.value( + resolvedCredentialKind, + 'credentialKind', + 'The credential kind does not match the account provider.', + ); + } final existing = await (_database.select( _database.accounts, )..where((account) => account.id.equals(id))).getSingleOrNull(); @@ -353,14 +367,6 @@ class AccountsRepository { String _now() => _nowUtc().toIso8601String(); } -CredentialKind _defaultCredentialKind(BusyProvider provider) => - switch (provider) { - BusyProvider.google || BusyProvider.microsoft => CredentialKind.oauth, - BusyProvider.appleICloud => CredentialKind.appleAppSpecificPassword, - BusyProvider.nextcloud => CredentialKind.nextcloudAppPassword, - BusyProvider.webCal => CredentialKind.webCalSubscription, - }; - CredentialKind _credentialKindFromStorage(String value) => switch (value) { 'oauth' => CredentialKind.oauth, 'apple_app_specific_password' => CredentialKind.appleAppSpecificPassword, diff --git a/lib/src/ical/ical_ingestion.dart b/lib/src/ical/ical_ingestion.dart index 22a9aa60..5e68cf92 100644 --- a/lib/src/ical/ical_ingestion.dart +++ b/lib/src/ical/ical_ingestion.dart @@ -233,8 +233,13 @@ String sanitizedCanonicalSnapshot( if (topLevel && (node.name == 'SOURCE' || node.name == 'URL')) { return true; } - final value = node.rawValue.trim(); - return secrets.any(value.contains); + final values = [ + node.rawValue.trim(), + for (final parameter in node.parameters) ...parameter.values, + ]; + return secrets.any( + (secret) => values.any((value) => value.contains(secret)), + ); }); for (final child in component.components) { sanitize(child, topLevel: false); diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 17c2fc05..a64e7daf 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -17,9 +17,13 @@ import 'schedule_range.dart'; import 'schedule_sorting.dart'; class ScheduleRepository { - const ScheduleRepository(this._database); + const ScheduleRepository( + this._database, { + Future Function(ScheduleRange range)? ensureProjectionCoverage, + }) : _ensureProjectionCoverage = ensureProjectionCoverage; final AppDatabase _database; + final Future Function(ScheduleRange range)? _ensureProjectionCoverage; Future findTaskTarget({ required String accountId, @@ -101,6 +105,9 @@ class ScheduleRepository { required ScheduleRange range, ScheduleFilters filters = const ScheduleFilters(), }) async { + if (filters.includeCalendarEvents) { + await _ensureProjectionCoverage?.call(range); + } final context = await _accountContext(filters); if (context == null) { return const []; diff --git a/lib/src/webcal/webcal_http_client.dart b/lib/src/webcal/webcal_http_client.dart index 88c64db7..bbce0eb0 100644 --- a/lib/src/webcal/webcal_http_client.dart +++ b/lib/src/webcal/webcal_http_client.dart @@ -87,7 +87,6 @@ final class IoWebCalHttpTransport implements WebCalHttpTransport { final visited = {}; var current = uri; var redirects = 0; - var allowValidators = validatorTarget == current && !validators.isEmpty; while (true) { _requireHttpsTarget(current); if (!visited.add(current.toString())) { @@ -98,7 +97,9 @@ final class IoWebCalHttpTransport implements WebCalHttpTransport { } final response = await _singleGet( current, - validators: allowValidators ? validators : const WebCalHttpValidators(), + validators: validatorTarget == current + ? validators + : const WebCalHttpValidators(), deadline: deadline, ); if (!_redirectStatuses.contains(response.statusCode)) return response; @@ -112,7 +113,6 @@ final class IoWebCalHttpTransport implements WebCalHttpTransport { final next = current.resolveUri(location); _requireHttpsTarget(next); current = next; - allowValidators = false; redirects += 1; } } @@ -171,8 +171,6 @@ final class IoWebCalHttpTransport implements WebCalHttpTransport { inactivityTimeout: limits.streamInactivityTimeout, maximumBytes: limits.maximumDecodedBodyBytes, ); - } else { - await response.drain(); } return _SingleResponse( statusCode: response.statusCode, @@ -216,20 +214,29 @@ Future _readLimitedBody( required int maximumBytes, }) async { final builder = BytesBuilder(copy: false); - final stream = response.timeout( - inactivityTimeout, - onTimeout: (sink) => sink.addError( - const WebCalHttpException('WebCalStreamInactivityTimeout'), - ), - ); - await for (final chunk in stream) { - if (DateTime.now().isAfter(deadline)) { - throw const WebCalHttpException('WebCalTotalTimeout'); - } - if (builder.length + chunk.length > maximumBytes) { - throw const WebCalHttpException('WebCalBodyTooLarge'); + final iterator = StreamIterator>(response); + try { + while (true) { + final remaining = _remaining(deadline); + final limitedByDeadline = remaining <= inactivityTimeout; + final wait = limitedByDeadline ? remaining : inactivityTimeout; + final hasNext = await iterator.moveNext().timeout( + wait, + onTimeout: () => throw WebCalHttpException( + limitedByDeadline + ? 'WebCalTotalTimeout' + : 'WebCalStreamInactivityTimeout', + ), + ); + if (!hasNext) break; + final chunk = iterator.current; + if (builder.length + chunk.length > maximumBytes) { + throw const WebCalHttpException('WebCalBodyTooLarge'); + } + builder.add(chunk); } - builder.add(chunk); + } finally { + await iterator.cancel(); } return builder.takeBytes(); } diff --git a/lib/src/webcal/webcal_subscription_service.dart b/lib/src/webcal/webcal_subscription_service.dart index d61ae14f..e1a4b619 100644 --- a/lib/src/webcal/webcal_subscription_service.dart +++ b/lib/src/webcal/webcal_subscription_service.dart @@ -18,7 +18,7 @@ import 'webcal_http_client.dart'; import 'webcal_uri.dart'; const webCalParserVersion = 1; -const webCalProjectionVersion = 1; +const webCalProjectionVersion = 2; enum WebCalRefreshMode { automatic, hourly, sixHours, daily } @@ -107,7 +107,7 @@ final class WebCalSubscriptionService { final DateTime Function() _nowUtc; final String Function() _idFactory; final IcalEventProjector _eventProjector; - final Map> _refreshTails = {}; + final Map> _operationTails = {}; Stream> watchSubscriptions() { final query = _database.select(_database.webCalSubscriptions).join([ @@ -253,6 +253,12 @@ final class WebCalSubscriptionService { lastChangedAtUtc: Value(now.toIso8601String()), parserVersion: const Value(webCalParserVersion), projectionVersion: const Value(webCalProjectionVersion), + projectionRangeStartUtc: Value( + candidate.projectionRangeStartUtc.toIso8601String(), + ), + projectionRangeEndUtc: Value( + candidate.projectionRangeEndUtc.toIso8601String(), + ), createdAtUtc: now.toIso8601String(), updatedAtUtc: now.toIso8601String(), ), @@ -277,15 +283,21 @@ final class WebCalSubscriptionService { Future refreshSubscription( String subscriptionId, { bool force = false, - }) { - final previous = _refreshTails[subscriptionId] ?? Future.value(); - final next = previous - .catchError((Object _) {}) - .then((_) => _refreshSubscription(subscriptionId, force: force)); - _refreshTails[subscriptionId] = next; + }) => _serializeSubscriptionOperation( + subscriptionId, + () => _refreshSubscription(subscriptionId, force: force), + ); + + Future _serializeSubscriptionOperation( + String subscriptionId, + Future Function() operation, + ) { + final previous = _operationTails[subscriptionId] ?? Future.value(); + final next = previous.catchError((Object _) {}).then((_) => operation()); + _operationTails[subscriptionId] = next; return next.whenComplete(() { - if (identical(_refreshTails[subscriptionId], next)) { - _refreshTails.remove(subscriptionId); + if (identical(_operationTails[subscriptionId], next)) { + _operationTails.remove(subscriptionId); } }); } @@ -327,7 +339,7 @@ final class WebCalSubscriptionService { targetFingerprint == subscription.validatorTargetFingerprint; try { final response = await _httpTransport.get( - validatorsBound ? target : subscriptionUri, + subscriptionUri, validators: validatorsBound ? WebCalHttpValidators( etag: subscription.etag, @@ -343,7 +355,27 @@ final class WebCalSubscriptionService { subscription.snapshotIcsBody.isEmpty) { throw const WebCalSubscriptionException('WebCalInvalidNotModified'); } + final cachedProjection = _prepareCachedProjectionForRefresh( + subscription, + now, + ); + final source = cachedProjection == null + ? null + : await (_database.select(_database.calendarSources)..where( + (row) => row.id.equals(subscription.calendarSourceId), + )) + .getSingle(); await _database.transaction(() async { + if (cachedProjection != null) { + await _replaceEvents( + subscriptionId: subscription.id, + accountId: subscription.accountId, + sourceId: subscription.calendarSourceId, + color: source!.backgroundColor, + projections: cachedProjection.projections, + now: now, + ); + } await (_database.update( _database.webCalSubscriptions, )..where((row) => row.id.equals(subscription.id))).write( @@ -357,6 +389,18 @@ final class WebCalSubscriptionService { consecutiveFailureCount: const Value(0), lastFailureCode: const Value(null), lastFailureHttpStatus: const Value(null), + parserVersion: cachedProjection == null + ? const Value.absent() + : const Value(webCalParserVersion), + projectionVersion: cachedProjection == null + ? const Value.absent() + : const Value(webCalProjectionVersion), + projectionRangeStartUtc: cachedProjection == null + ? const Value.absent() + : Value(cachedProjection.rangeStartUtc.toIso8601String()), + projectionRangeEndUtc: cachedProjection == null + ? const Value.absent() + : Value(cachedProjection.rangeEndUtc.toIso8601String()), nextRefreshAtUtc: Value( _nextRefresh( now, @@ -379,6 +423,9 @@ final class WebCalSubscriptionService { ), ); }); + if (cachedProjection != null) { + await _rebuildNotifications(subscription.accountId); + } return; } final candidate = _prepareAcceptedResponse( @@ -428,6 +475,14 @@ final class WebCalSubscriptionService { lastFailureCode: const Value(null), lastFailureHttpStatus: const Value(null), generation: Value(subscription.generation + 1), + parserVersion: const Value(webCalParserVersion), + projectionVersion: const Value(webCalProjectionVersion), + projectionRangeStartUtc: Value( + candidate.projectionRangeStartUtc.toIso8601String(), + ), + projectionRangeEndUtc: Value( + candidate.projectionRangeEndUtc.toIso8601String(), + ), nextRefreshAtUtc: Value( _nextRefresh( now, @@ -463,8 +518,18 @@ final class WebCalSubscriptionService { } } - Future renameSubscription(String subscriptionId, String name) async { + Future renameSubscription(String subscriptionId, String name) { final trimmed = _subscriptionName(name); + return _serializeSubscriptionOperation( + subscriptionId, + () => _renameSubscription(subscriptionId, trimmed), + ); + } + + Future _renameSubscription( + String subscriptionId, + String trimmed, + ) async { final subscription = await _subscription(subscriptionId); final now = _nowUtc(); await _database.transaction(() async { @@ -487,12 +552,19 @@ final class WebCalSubscriptionService { }); } - Future changeSubscriptionColor( + Future changeSubscriptionColor(String subscriptionId, String? color) { + final normalized = _normalizedColor(color); + return _serializeSubscriptionOperation( + subscriptionId, + () => _changeSubscriptionColor(subscriptionId, normalized), + ); + } + + Future _changeSubscriptionColor( String subscriptionId, - String? color, + String? normalized, ) async { final subscription = await _subscription(subscriptionId); - final normalized = _normalizedColor(color); final now = _nowUtc().millisecondsSinceEpoch; await _database.transaction(() async { await (_database.update( @@ -518,6 +590,14 @@ final class WebCalSubscriptionService { Future changeRefreshMode( String subscriptionId, WebCalRefreshMode mode, + ) => _serializeSubscriptionOperation( + subscriptionId, + () => _changeRefreshMode(subscriptionId, mode), + ); + + Future _changeRefreshMode( + String subscriptionId, + WebCalRefreshMode mode, ) async { final subscription = await _subscription(subscriptionId); final now = _nowUtc(); @@ -539,7 +619,13 @@ final class WebCalSubscriptionService { ); } - Future unsubscribe(String subscriptionId) async { + Future unsubscribe(String subscriptionId) => + _serializeSubscriptionOperation( + subscriptionId, + () => _unsubscribe(subscriptionId), + ); + + Future _unsubscribe(String subscriptionId) async { final subscription = await _subscription(subscriptionId); final secret = await _secretStore.readCredential(subscription.accountId); await _secretStore.deleteCredential(subscription.accountId); @@ -568,6 +654,164 @@ final class WebCalSubscriptionService { return value; } + Future ensureProjectionCoverage({ + required DateTime rangeStartUtc, + required DateTime rangeEndUtc, + }) async { + final requestedStart = rangeStartUtc.toUtc(); + final requestedEnd = rangeEndUtc.toUtc(); + if (!requestedEnd.isAfter(requestedStart)) return; + final subscriptions = await _database + .select(_database.webCalSubscriptions) + .get(); + for (final subscription in subscriptions) { + await _serializeSubscriptionOperation( + subscription.id, + () => _ensureProjectionCoverage( + subscription.id, + requestedStart: requestedStart, + requestedEnd: requestedEnd, + ), + ); + } + } + + Future _ensureProjectionCoverage( + String subscriptionId, { + required DateTime requestedStart, + required DateTime requestedEnd, + }) async { + final subscription = await (_database.select( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscriptionId))).getSingleOrNull(); + if (subscription == null || + !_projectionCoverageRequired( + subscription, + requestedStart: requestedStart, + requestedEnd: requestedEnd, + )) { + return; + } + final range = _projectionRange( + _nowUtc(), + requestedStart: requestedStart, + requestedEnd: requestedEnd, + ); + final projection = _prepareCachedProjection( + subscription.snapshotIcsBody, + range: range, + ); + final source = + await (_database.select(_database.calendarSources) + ..where((row) => row.id.equals(subscription.calendarSourceId))) + .getSingleOrNull(); + if (source == null) return; + final now = _nowUtc(); + await _database.transaction(() async { + await _replaceEvents( + subscriptionId: subscription.id, + accountId: subscription.accountId, + sourceId: subscription.calendarSourceId, + color: source.backgroundColor, + projections: projection.projections, + now: now, + ); + await (_database.update( + _database.webCalSubscriptions, + )..where((row) => row.id.equals(subscription.id))).write( + WebCalSubscriptionsCompanion( + parserVersion: const Value(webCalParserVersion), + projectionVersion: const Value(webCalProjectionVersion), + projectionRangeStartUtc: Value( + projection.rangeStartUtc.toIso8601String(), + ), + projectionRangeEndUtc: Value( + projection.rangeEndUtc.toIso8601String(), + ), + updatedAtUtc: Value(now.toIso8601String()), + ), + ); + }); + await _rebuildNotifications(subscription.accountId); + } + + _PreparedProjection? _prepareCachedProjectionForRefresh( + WebCalSubscription subscription, + DateTime now, + ) { + final minimumFuture = DateTime.utc(now.year + 1, now.month, now.day); + if (!_projectionCoverageRequired( + subscription, + requestedStart: now, + requestedEnd: minimumFuture, + includePadding: false, + )) { + return null; + } + return _prepareCachedProjection( + subscription.snapshotIcsBody, + range: _projectionRange(now), + ); + } + + bool _projectionCoverageRequired( + WebCalSubscription subscription, { + required DateTime requestedStart, + required DateTime requestedEnd, + bool includePadding = true, + }) { + if (subscription.parserVersion != webCalParserVersion || + subscription.projectionVersion != webCalProjectionVersion) { + return true; + } + final storedStart = DateTime.tryParse( + subscription.projectionRangeStartUtc ?? '', + )?.toUtc(); + final storedEnd = DateTime.tryParse( + subscription.projectionRangeEndUtc ?? '', + )?.toUtc(); + if (storedStart == null || storedEnd == null) return true; + final requiredStart = includePadding + ? requestedStart.subtract(const Duration(days: 30)) + : requestedStart; + final requiredEnd = includePadding + ? requestedEnd.add(const Duration(days: 90)) + : requestedEnd; + return storedStart.isAfter(requiredStart) || + storedEnd.isBefore(requiredEnd); + } + + _PreparedProjection _prepareCachedProjection( + String snapshot, { + required ({DateTime start, DateTime end}) range, + }) { + final ingestion = IcalIngestion.parseString( + snapshot, + policy: IcalIngestionPolicy.webCal, + ); + final projections = []; + try { + for (final set in ingestion.recurrenceSets) { + projections.addAll( + _eventProjector.project( + set, + rangeStartUtc: range.start, + rangeEndUtc: range.end, + transport: 'webcal', + ), + ); + } + } on DavException catch (error) { + throw WebCalSubscriptionException(error.code); + } + return _PreparedProjection( + ingestion: ingestion, + projections: projections, + rangeStartUtc: range.start, + rangeEndUtc: range.end, + ); + } + _PreparedCandidate _prepareAcceptedResponse( WebCalHttpResponse response, { required Iterable secretUris, @@ -620,34 +864,18 @@ final class WebCalSubscriptionService { originalIngestion.document, secretUris: secretUris, ); - final ingestion = IcalIngestion.parseString( + final projection = _prepareCachedProjection( snapshot, - policy: IcalIngestionPolicy.webCal, + range: _projectionRange(_nowUtc()), ); - final now = _nowUtc(); - final rangeStart = DateTime.utc(now.year - 1, now.month, now.day); - final rangeEnd = DateTime.utc(now.year + 3, now.month, now.day); - final projections = []; - try { - for (final set in ingestion.recurrenceSets) { - projections.addAll( - _eventProjector.project( - set, - rangeStartUtc: rangeStart, - rangeEndUtc: rangeEnd, - transport: 'webcal', - ), - ); - } - } on DavException catch (error) { - throw WebCalSubscriptionException(error.code); - } return _PreparedCandidate( - ingestion: ingestion, - projections: projections, + ingestion: projection.ingestion, + projections: projection.projections, snapshot: snapshot, rawBodyHash: sha256.convert(body).toString(), semanticHash: sha256.convert(utf8.encode(snapshot)).toString(), + projectionRangeStartUtc: projection.rangeStartUtc, + projectionRangeEndUtc: projection.rangeEndUtc, ); } @@ -791,6 +1019,8 @@ final class _PreparedCandidate { required this.snapshot, required this.rawBodyHash, required this.semanticHash, + required this.projectionRangeStartUtc, + required this.projectionRangeEndUtc, }); final IcalIngestionResult ingestion; @@ -798,6 +1028,42 @@ final class _PreparedCandidate { final String snapshot; final String rawBodyHash; final String semanticHash; + final DateTime projectionRangeStartUtc; + final DateTime projectionRangeEndUtc; +} + +final class _PreparedProjection { + const _PreparedProjection({ + required this.ingestion, + required this.projections, + required this.rangeStartUtc, + required this.rangeEndUtc, + }); + + final IcalIngestionResult ingestion; + final List projections; + final DateTime rangeStartUtc; + final DateTime rangeEndUtc; +} + +({DateTime start, DateTime end}) _projectionRange( + DateTime now, { + DateTime? requestedStart, + DateTime? requestedEnd, +}) { + final utc = now.toUtc(); + final baseStart = DateTime.utc(utc.year - 1, utc.month, utc.day); + final baseEnd = DateTime.utc(utc.year + 3, utc.month, utc.day); + final paddedStart = requestedStart?.toUtc().subtract( + const Duration(days: 30), + ); + final paddedEnd = requestedEnd?.toUtc().add(const Duration(days: 90)); + return ( + start: paddedStart != null && paddedStart.isBefore(baseStart) + ? paddedStart + : baseStart, + end: paddedEnd != null && paddedEnd.isAfter(baseEnd) ? paddedEnd : baseEnd, + ); } String _subscriptionName(String? value) { diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index f4dc1c0f..807b068e 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -21,7 +21,7 @@ void main() { await database.close(); }); - test('opens schema version 12 and creates required indexes', () async { + test('opens schema version 13 and creates required indexes', () async { final version = await database .customSelect('PRAGMA user_version') .getSingle(); @@ -41,8 +41,11 @@ void main() { final taskListColumns = await database .customSelect('PRAGMA table_info(task_lists)') .get(); + final webCalColumns = await database + .customSelect('PRAGMA table_info(web_cal_subscriptions)') + .get(); - expect(version.data['user_version'], 12); + expect(version.data['user_version'], 13); expect( taskColumns.map((row) => row.read('name')), contains('microsoft_checklist_items_json'), @@ -55,6 +58,10 @@ void main() { taskListColumns.map((row) => row.read('name')), contains('reminders_enabled'), ); + expect( + webCalColumns.map((row) => row.read('name')), + containsAll(['projection_range_start_utc', 'projection_range_end_utc']), + ); expect(indexes.map((row) => row.data['name']).toSet(), { 'idx_accounts_provider', 'idx_accounts_remote_identity', @@ -113,7 +120,7 @@ void main() { expect( (await database.customSelect('PRAGMA user_version').getSingle()) .read('user_version'), - 12, + 13, ); await database @@ -174,7 +181,7 @@ void main() { database.taskLists, )..where((row) => row.id.equals('list-1'))).getSingle(); - expect(version.read('user_version'), 12); + expect(version.read('user_version'), 13); expect(list.title, 'Existing list'); expect(list.remindersEnabled, isTrue); expect( @@ -188,6 +195,116 @@ void main() { }, ); + test( + 'schema 12 migration adds WebCal coverage and account pair constraints', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v12-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaTwelveDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaTwelveDatabase); + await schemaTwelveDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute( + 'ALTER TABLE web_cal_subscriptions ' + 'DROP COLUMN projection_range_start_utc', + ); + raw.execute( + 'ALTER TABLE web_cal_subscriptions ' + 'DROP COLUMN projection_range_end_utc', + ); + _removeProviderCredentialPairConstraint(raw); + raw.execute('PRAGMA user_version = 12'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + final columns = await database + .customSelect('PRAGMA table_info(web_cal_subscriptions)') + .get(); + expect( + (await database.customSelect('PRAGMA user_version').getSingle()) + .read('user_version'), + 13, + ); + expect( + columns.map((row) => row.read('name')), + containsAll(['projection_range_start_utc', 'projection_range_end_utc']), + ); + await expectLater( + database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'invalid-pair', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'invalid', + credentialKind: 'webcal_subscription', + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ), + throwsA(anything), + ); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + + test( + 'schema 12 migration rejects a corrupt provider credential pair', + () async { + await database.close(); + final tempDir = await Directory.systemTemp.createTemp( + 'busymax-db-v12-corrupt-test-', + ); + final file = File('${tempDir.path}/busymax.sqlite'); + final schemaTwelveDatabase = AppDatabase(NativeDatabase(file)); + await _insertAccount(schemaTwelveDatabase); + await schemaTwelveDatabase.close(); + + final raw = sqlite3.sqlite3.open(file.path); + try { + raw.execute('PRAGMA ignore_check_constraints = ON'); + raw.execute( + "UPDATE accounts SET credential_kind = 'webcal_subscription' " + "WHERE id = 'account'", + ); + raw.execute('PRAGMA user_version = 12'); + } finally { + raw.close(); + } + + database = AppDatabase(NativeDatabase(file)); + await expectLater( + database.customSelect('SELECT 1').get(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'account_identity_invariant_failed', + ), + ), + ); + + await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await tempDir.delete(recursive: true); + }, + ); + test( 'schema 9 migration adds calendar ownership columns without data loss', () async { @@ -233,7 +350,7 @@ void main() { database.calendarSources, )..where((row) => row.id.equals('calendar'))).getSingle(); - expect(version.read('user_version'), 12); + expect(version.read('user_version'), 13); expect( columns.map((row) => row.read('name')), containsAll(['data_owner', 'is_removable']), @@ -433,7 +550,7 @@ void main() { final version = await database .customSelect('PRAGMA user_version') .getSingle(); - expect(version.read('user_version'), 12); + expect(version.read('user_version'), 13); final accounts = await database.select(database.accounts).get(); expect(accounts, hasLength(2)); @@ -634,7 +751,7 @@ void main() { .getSingle(); final op = await database.pendingOpsDao.getOp('op-1'); - expect(version.data['user_version'], 12); + expect(version.data['user_version'], 13); expect(op, isNot(equals(null))); expect(op!.baselineRawJson, equals(null)); @@ -712,6 +829,74 @@ PendingOpsCompanion _pendingOp({ const _now = '2026-06-04T00:00:00.000Z'; +void _removeProviderCredentialPairConstraint(sqlite3.Database database) { + final sql = + database + .select( + "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'accounts'", + ) + .single['sql'] + as String; + const marker = + "CHECK ((provider IN ('google', 'microsoft') AND credential_kind = 'oauth')"; + final start = sql.indexOf(marker); + if (start < 0) { + throw StateError('Provider credential constraint fixture not found.'); + } + + var quoted = false; + var depth = 0; + var opened = false; + int? end; + for (var index = start; index < sql.length; index += 1) { + final character = sql[index]; + if (character == "'") { + if (quoted && index + 1 < sql.length && sql[index + 1] == "'") { + index += 1; + } else { + quoted = !quoted; + } + continue; + } + if (quoted) continue; + if (character == '(') { + opened = true; + depth += 1; + } else if (character == ')') { + depth -= 1; + if (opened && depth == 0) { + end = index + 1; + break; + } + } + } + if (end == null) { + throw StateError('Provider credential constraint fixture is malformed.'); + } + + var removalStart = start; + while (removalStart > 0 && + (sql[removalStart - 1] == ' ' || sql[removalStart - 1] == '\n')) { + removalStart -= 1; + } + if (removalStart > 0 && sql[removalStart - 1] == ',') { + removalStart -= 1; + } + final legacySql = sql.replaceRange(removalStart, end, ''); + database.execute('PRAGMA writable_schema = ON'); + try { + database.execute( + "UPDATE sqlite_schema SET sql = ? WHERE type = 'table' AND name = 'accounts'", + [legacySql], + ); + } finally { + database.execute('PRAGMA writable_schema = OFF'); + } + final schemaVersion = + database.select('PRAGMA schema_version').single.values.first as int; + database.execute('PRAGMA schema_version = ${schemaVersion + 1}'); +} + Future<({AppDatabase database, Directory directory})> _openSchema5Fixture({ void Function(sqlite3.Database raw)? prepare, }) async { diff --git a/test/features/accounts/data/accounts_repository_test.dart b/test/features/accounts/data/accounts_repository_test.dart index fff2d973..a94cb35d 100644 --- a/test/features/accounts/data/accounts_repository_test.dart +++ b/test/features/accounts/data/accounts_repository_test.dart @@ -118,4 +118,21 @@ void main() { ); }, ); + + test('rejects a credential kind that does not match the provider', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final repository = AccountsRepository(database: database); + + await expectLater( + repository.upsertSignedInAccount( + id: 'invalid', + provider: BusyProvider.google, + grantedScopes: '', + credentialKind: CredentialKind.webCalSubscription, + ), + throwsArgumentError, + ); + expect(await database.select(database.accounts).get(), isEmpty); + }); } diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 38ff3c69..3661804f 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -83,6 +83,27 @@ void main() { }, ); + test( + 'repository ensures calendar projection coverage before reading', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final requestedRange = ScheduleRange.day(DateTime(2035, 8, 29)); + ScheduleRange? ensuredRange; + final repository = ScheduleRepository( + database, + ensureProjectionCoverage: (range) async => ensuredRange = range, + ); + + await repository.listItems( + range: requestedRange, + filters: const ScheduleFilters(includeTasks: false), + ); + + expect(ensuredRange, requestedRange); + }, + ); + test( 'Microsoft all-day calendar event appears from Graph dateTime fields', () async { diff --git a/test/google_tasks/oauth/oauth_token_store_test.dart b/test/google_tasks/oauth/oauth_token_store_test.dart index d903b6bb..ad9f4dd4 100644 --- a/test/google_tasks/oauth/oauth_token_store_test.dart +++ b/test/google_tasks/oauth/oauth_token_store_test.dart @@ -66,6 +66,10 @@ void main() { loginName: 'alex', appPassword: 'nextcloud-app-secret', ), + WebCalSecretRecord( + normalizedSubscriptionUri: + 'https://calendar.example.test/feed?token=webcal-secret', + ), ]; for (final record in records) { @@ -85,6 +89,26 @@ void main() { }, ); + test('credential decoding rejects mismatched provider and kind', () { + final webCal = WebCalSecretRecord( + normalizedSubscriptionUri: + 'https://calendar.example.test/feed?token=secret', + ).toJson(); + webCal['provider'] = BusyProvider.google.storageValue; + + expect( + () => SecretRecord.fromJson(webCal), + throwsA(isA()), + ); + expect( + () => OAuthSecretRecord( + provider: BusyProvider.appleICloud, + tokenSet: _tokenSet(), + ), + throwsA(isA()), + ); + }); + test('typed reads reject a credential from another provider', () async { final store = InMemorySecretStore(); await store.saveOAuthTokenSet( diff --git a/test/ical/ical_ingestion_test.dart b/test/ical/ical_ingestion_test.dart index a17e5f5c..3e599c24 100644 --- a/test/ical/ical_ingestion_test.dart +++ b/test/ical/ical_ingestion_test.dart @@ -285,19 +285,31 @@ END:VTODO test('sanitized snapshot removes secret-bearing properties', () { final secret = Uri.parse('https://example.test/private.ics?token=secret'); + final target = Uri.parse('https://cdn.example.test/current.ics?key=two'); final result = IcalIngestion.parseString( _calendar(''' SOURCE:${secret.toString()} -${_event('safe', 'Safe')} +BEGIN:VEVENT +UID:safe +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Safe +DESCRIPTION;ALTREP="${secret.toString()}":Quoted +DESCRIPTION;ALTREP="https://cdn.example.test/current.ics? + key=two":Folded +DESCRIPTION;ALTREP="${secret.toString()}^nprivate":Escaped +END:VEVENT '''), policy: IcalIngestionPolicy.webCal, ); final snapshot = sanitizedCanonicalSnapshot( result.document, - secretUris: [secret], + secretUris: [secret, target], ); expect(snapshot, isNot(contains(secret.toString()))); + expect(snapshot, isNot(contains(target.toString()))); + expect(snapshot, isNot(contains('ALTREP'))); expect(snapshot, contains('UID:safe')); }); diff --git a/test/webcal/webcal_http_client_test.dart b/test/webcal/webcal_http_client_test.dart index 1899918c..4980cb93 100644 --- a/test/webcal/webcal_http_client_test.dart +++ b/test/webcal/webcal_http_client_test.dart @@ -5,51 +5,49 @@ import 'package:busymax/src/webcal/webcal_http_client.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test( - 'follows safe redirects without forwarding validators or credentials', - () async { - final first = _FakeResponse(302, headers: {'location': '/final.ics'}); - final second = _FakeResponse( - 200, - chunks: const [ - [1, 2, 3], - ], - ); - final client = _FakeHttpClient([first, second]); - final transport = IoWebCalHttpTransport(clientFactory: () => client); - final start = Uri.parse('https://calendar.example.test/start'); - - final result = await transport.get( - start, - validators: const WebCalHttpValidators( - etag: '"etag"', - lastModified: 'Sat, 29 Aug 2026 20:00:00 GMT', - ), - validatorTarget: start, - ); + test('binds validators only to the exact matching redirect hop', () async { + final first = _FakeResponse(302, headers: {'location': '/final.ics'}); + final second = _FakeResponse( + 200, + chunks: const [ + [1, 2, 3], + ], + ); + final client = _FakeHttpClient([first, second]); + final transport = IoWebCalHttpTransport(clientFactory: () => client); + final start = Uri.parse('https://calendar.example.test/start'); + final target = Uri.parse('https://calendar.example.test/final.ics'); + + final result = await transport.get( + start, + validators: const WebCalHttpValidators( + etag: '"etag"', + lastModified: 'Sat, 29 Aug 2026 20:00:00 GMT', + ), + validatorTarget: target, + ); - expect( - result.finalUri.toString(), - 'https://calendar.example.test/final.ics', - ); - expect(result.body, [1, 2, 3]); - expect(client.requests, hasLength(2)); - final firstHeaders = client.requests.first.recordedHeaders; - expect(firstHeaders['accept'], 'text/calendar'); - expect(firstHeaders['if-none-match'], '"etag"'); - expect( - firstHeaders['if-modified-since'], - 'Sat, 29 Aug 2026 20:00:00 GMT', - ); - expect(firstHeaders, isNot(contains('authorization'))); - expect(firstHeaders, isNot(contains('cookie'))); - expect(firstHeaders, isNot(contains('referer'))); - expect(firstHeaders, isNot(contains('origin'))); - final redirectedHeaders = client.requests.last.recordedHeaders; - expect(redirectedHeaders, isNot(contains('if-none-match'))); - expect(redirectedHeaders, isNot(contains('if-modified-since'))); - }, - ); + expect( + result.finalUri.toString(), + 'https://calendar.example.test/final.ics', + ); + expect(result.body, [1, 2, 3]); + expect(client.requests, hasLength(2)); + final firstHeaders = client.requests.first.recordedHeaders; + expect(firstHeaders['accept'], 'text/calendar'); + expect(firstHeaders, isNot(contains('if-none-match'))); + expect(firstHeaders, isNot(contains('if-modified-since'))); + expect(firstHeaders, isNot(contains('authorization'))); + expect(firstHeaders, isNot(contains('cookie'))); + expect(firstHeaders, isNot(contains('referer'))); + expect(firstHeaders, isNot(contains('origin'))); + final redirectedHeaders = client.requests.last.recordedHeaders; + expect(redirectedHeaders['if-none-match'], '"etag"'); + expect( + redirectedHeaders['if-modified-since'], + 'Sat, 29 Aug 2026 20:00:00 GMT', + ); + }); test( 'rejects redirect downgrade, user information, fragments, and loops', @@ -198,6 +196,71 @@ void main() { ); expect(unboundResponse.conditionalRequestSent, isFalse); }); + + test('does not wait for stalled redirect or 304 response bodies', () async { + final redirectBody = StreamController>(); + final notModifiedBody = StreamController>(); + final redirectClient = _FakeHttpClient([ + _FakeResponse( + 302, + headers: {'location': '/final.ics'}, + stream: redirectBody.stream, + ), + _FakeResponse( + 200, + chunks: const [ + [1], + ], + ), + ]); + + final redirected = + await IoWebCalHttpTransport(clientFactory: () => redirectClient) + .get(Uri.parse('https://calendar.example.test/start')) + .timeout(const Duration(milliseconds: 100)); + expect(redirected.body, [1]); + expect(redirectBody.hasListener, isFalse); + + final notModifiedClient = _FakeHttpClient([ + _FakeResponse(304, stream: notModifiedBody.stream), + ]); + final target = Uri.parse('https://calendar.example.test/feed'); + final notModified = + await IoWebCalHttpTransport(clientFactory: () => notModifiedClient) + .get( + target, + validators: const WebCalHttpValidators(etag: '"one"'), + validatorTarget: target, + ) + .timeout(const Duration(milliseconds: 100)); + expect(notModified.statusCode, 304); + expect(notModifiedBody.hasListener, isFalse); + }); + + test('body waits never exceed the remaining total deadline', () async { + final controller = StreamController>(); + scheduleMicrotask(() => controller.add([1])); + final client = _FakeHttpClient([ + _FakeResponse(200, stream: controller.stream), + ]); + + await expectLater( + IoWebCalHttpTransport( + clientFactory: () => client, + limits: const WebCalHttpLimits( + streamInactivityTimeout: Duration(seconds: 1), + totalDeadline: Duration(milliseconds: 20), + ), + ).get(Uri.parse('https://calendar.example.test/start')), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'WebCalTotalTimeout', + ), + ), + ); + }); } final class _FakeHttpClient implements HttpClient { diff --git a/test/webcal/webcal_subscription_service_test.dart b/test/webcal/webcal_subscription_service_test.dart index c95a2326..f15491cf 100644 --- a/test/webcal/webcal_subscription_service_test.dart +++ b/test/webcal/webcal_subscription_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -15,9 +16,10 @@ void main() { late _FakeTransport transport; late WebCalSubscriptionService service; late int notificationRebuilds; - final now = DateTime.utc(2026, 8, 29, 20); + late DateTime now; setUp(() { + now = DateTime.utc(2026, 8, 29, 20); database = AppDatabase(NativeDatabase.memory()); secrets = InMemorySecretStore(); transport = _FakeTransport(); @@ -139,6 +141,44 @@ END:VEVENT expect(transport.requests, hasLength(1)); }); + test( + 'snapshot removes original and validator-target URIs from parameters', + () async { + final origin = Uri.parse( + 'https://calendar.example.test/feed?private-token=one', + ); + final target = Uri.parse( + 'https://cdn.example.test/current.ics?private-token=two', + ); + transport.responses.add( + _response( + uri: target, + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:parameter-secret +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Safe title +DESCRIPTION;ALTREP="${origin.toString()}":Origin +LOCATION;ALTREP="${target.toString()}":Target +END:VEVENT +'''), + ), + ), + ); + + await service.addSubscription(subscriptionUrl: origin.toString()); + + final snapshot = + (await database.select(database.webCalSubscriptions).getSingle()) + .snapshotIcsBody; + expect(snapshot, isNot(contains(origin.toString()))); + expect(snapshot, isNot(contains(target.toString()))); + expect(snapshot, isNot(contains('ALTREP'))); + }, + ); + test( 'malformed refresh preserves snapshot, events, validators, and alarms', () async { @@ -220,7 +260,7 @@ END:VEVENT await service.refreshSubscription('subscription-1', force: true); - expect(transport.requests.last.uri, target); + expect(transport.requests.last.uri, origin); expect(transport.requests.last.validatorTarget, target); expect(transport.requests.last.validators.etag, '"one"'); expect( @@ -383,6 +423,141 @@ END:VEVENT isNull, ); }); + + test( + 'unsubscribe waits for an in-flight refresh and cannot restore secret', + () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + + final response = Completer(); + transport.responses.add(response.future); + final refresh = service.refreshSubscription( + 'subscription-1', + force: true, + ); + while (transport.requests.length < 2) { + await Future.delayed(Duration.zero); + } + final unsubscribe = service.unsubscribe('subscription-1'); + var unsubscribeFinished = false; + unawaited(unsubscribe.whenComplete(() => unsubscribeFinished = true)); + await Future.delayed(Duration.zero); + expect(unsubscribeFinished, isFalse); + + response.complete(_response(uri: uri, etag: '"two"', body: _oneEvent)); + await refresh; + await unsubscribe; + + expect(await database.select(database.accounts).get(), isEmpty); + expect( + await database.select(database.webCalSubscriptions).get(), + isEmpty, + ); + expect( + await secrets.readCredential('webcal-account-subscription-1'), + isNull, + ); + }, + ); + + test('304 reprojects an aging cached recurring snapshot', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add( + _response( + uri: uri, + etag: '"one"', + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:long-series +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +RRULE:FREQ=YEARLY;COUNT=20 +SUMMARY:Annual event +END:VEVENT +'''), + ), + ), + ); + await service.addSubscription(subscriptionUrl: uri.toString()); + expect( + (await database.select(database.calendarEvents).get()).any( + (event) => event.startDateTime?.startsWith('2031-') == true, + ), + isFalse, + ); + + now = DateTime.utc(2029, 8, 29, 20); + transport.responses.add( + WebCalHttpResponse( + statusCode: 304, + finalUri: uri, + body: null, + etag: '"one"', + lastModified: null, + contentType: null, + conditionalRequestSent: true, + ), + ); + await service.refreshSubscription('subscription-1', force: true); + + final events = await database.select(database.calendarEvents).get(); + expect( + events.any((event) => event.startDateTime?.startsWith('2031-') == true), + isTrue, + ); + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + expect(subscription.projectionVersion, webCalProjectionVersion); + expect( + DateTime.parse( + subscription.projectionRangeEndUtc!, + ).isAfter(DateTime.utc(2031)), + isTrue, + ); + expect(transport.requests.last.uri, uri); + expect(notificationRebuilds, 2); + }); + + test( + 'requested schedule range extends projection without a network fetch', + () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add( + _response( + uri: uri, + body: utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:long-series +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +RRULE:FREQ=YEARLY;COUNT=20 +SUMMARY:Annual event +END:VEVENT +'''), + ), + ), + ); + await service.addSubscription(subscriptionUrl: uri.toString()); + + await service.ensureProjectionCoverage( + rangeStartUtc: DateTime.utc(2035, 8, 1), + rangeEndUtc: DateTime.utc(2035, 9, 1), + ); + + expect(transport.requests, hasLength(1)); + expect( + (await database.select(database.calendarEvents).get()).any( + (event) => event.startDateTime?.startsWith('2035-') == true, + ), + isTrue, + ); + }, + ); } final class _FakeTransport implements WebCalHttpTransport { @@ -403,6 +578,7 @@ final class _FakeTransport implements WebCalHttpTransport { )); final next = responses.removeAt(0); if (next is WebCalHttpResponse) return next; + if (next is Future) return next; throw next; } } From 228d42e0b6534f0f2e4702c0cc6c9c79601e1f7f Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 14:48:40 -0700 Subject: [PATCH 12/69] fix translations --- lib/l10n/app_ar.arb | 692 +++++--- lib/l10n/app_de.arb | 704 ++++---- lib/l10n/app_en.arb | 60 +- lib/l10n/app_es.arb | 696 +++++--- lib/l10n/app_et.arb | 692 +++++--- lib/l10n/app_fa.arb | 682 +++++--- lib/l10n/app_fi.arb | 676 +++++--- lib/l10n/app_fr.arb | 704 ++++---- lib/l10n/app_hi.arb | 696 +++++--- lib/l10n/app_it.arb | 688 +++++--- lib/l10n/app_ja.arb | 682 +++++--- lib/l10n/app_ko.arb | 694 +++++--- lib/l10n/app_pt.arb | 704 ++++---- lib/l10n/app_ru.arb | 680 +++++--- lib/l10n/app_vi.arb | 694 +++++--- lib/l10n/app_zh.arb | 674 +++++--- lib/l10n/app_zh_Hans.arb | 674 +++++--- lib/l10n/app_zh_Hant.arb | 694 +++++--- lib/l10n/generated/app_localizations.dart | 34 +- lib/l10n/generated/app_localizations_ar.dart | 532 +++--- lib/l10n/generated/app_localizations_de.dart | 544 +++--- lib/l10n/generated/app_localizations_en.dart | 6 +- lib/l10n/generated/app_localizations_es.dart | 538 +++--- lib/l10n/generated/app_localizations_et.dart | 531 +++--- lib/l10n/generated/app_localizations_fa.dart | 517 +++--- lib/l10n/generated/app_localizations_fi.dart | 512 +++--- lib/l10n/generated/app_localizations_fr.dart | 547 +++--- lib/l10n/generated/app_localizations_hi.dart | 530 +++--- lib/l10n/generated/app_localizations_it.dart | 527 +++--- lib/l10n/generated/app_localizations_ja.dart | 528 +++--- lib/l10n/generated/app_localizations_ko.dart | 539 +++--- lib/l10n/generated/app_localizations_pt.dart | 545 +++--- lib/l10n/generated/app_localizations_ru.dart | 527 +++--- lib/l10n/generated/app_localizations_vi.dart | 530 +++--- lib/l10n/generated/app_localizations_zh.dart | 1610 +++++++++--------- test/app/localization_audit_test.dart | 50 +- 36 files changed, 11615 insertions(+), 9318 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 82a82728..5d29196c 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,14 +1,14 @@ { "@@locale": "ar", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "اربط حسابات Google أو Microsoft أو تقويم Apple iCloud أو Nextcloud.", "googlePermissionsConsentNotice": "في شاشة أذونات Google، حدّد أذونات التقويم والمهام معًا.", "googlePermissionsRequiredRetry": "أذونات تقويم Google وGoogle Tasks مطلوبة. حاول مرة أخرى وحدّد مربعي الاختيار.", "finishSetup": "إنهاء الإعداد", "continueSetup": "متابعة", "onboardingSetupTitle": "إعداد BusyMax", "onboardingAccountsStepTitle": "ربط الحسابات", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "أضف كل الحسابات التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام المدعومة من كل حساب.", "onboardingPreferencesStepTitle": "اختيار إعدادات النظام", "onboardingPreferencesStepDescription": "اضبط سلوك التطبيق على سطح المكتب والتذكيرات ومستوى تفاصيل الإشعارات والمظهر قبل فتح جدولك.", "signInWithGoogle": "تسجيل الدخول باستخدام Google", @@ -36,16 +36,40 @@ "newCalendar": "تقويم جديد", "calendarColor": "لون التقويم", "calendarColorOption": "اللون {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "لا يدعم هذا المزوّد إدارة التقويمات في BusyMax.", "primaryCalendarCannotDelete": "لا يمكن حذف التقويم الأساسي.", "calendarCreateFailed": "تعذّر إنشاء التقويم: {error}", "calendarCreatedRefreshPending": "تم إنشاء التقويم، لكن تعذّر على BusyMax تحديث الحساب. سيظهر بعد المزامنة التالية.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "تعذّر تحديث التقويم: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "تعذّر حذف التقويم: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "حدث جديد", "refreshCalendar": "تحديث التقويم", "openInProvider": "فتح في الخدمة", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "جرّب بحثًا مختلفًا أو امسح عوامل التصفية الحالية.", "refresh": "تحديث", "trayOpenBusyMax": "فتح BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "إظهار BusyMax", + "trayNewEvent": "حدث جديد…", + "trayNewTask": "مهمة جديدة…", + "trayToday": "اليوم", + "trayAllDay": "طوال اليوم", + "trayNow": "الآن", + "trayCalendarEvent": "حدث في التقويم", + "trayUntitledEvent": "حدث بلا عنوان", + "trayNothingElseToday": "لا شيء آخر اليوم", + "trayTasksDueToday": "{count, plural, =0{لا توجد مهام مستحقة اليوم} =1{مهمة واحدة مستحقة اليوم} =2{مهمتان مستحقتان اليوم} few{{count} مهام مستحقة اليوم} many{{count} مهمة مستحقة اليوم} other{{count} مهمة مستحقة اليوم}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "فتح جدول أعمال اليوم", + "traySyncNow": "مزامنة الآن", + "traySyncing": "جارٍ العمل على المزامنة…", + "trayNotConnected": "غير متصل", + "trayNotYetSynced": "لم تتم المزامنة بعد", + "trayLastSyncedJustNow": "تمت المزامنة للتو", + "trayLastSyncedMinutesAgo": "{count, plural, =1{تمت المزامنة قبل دقيقة واحدة} other{تمت المزامنة قبل {count} دقائق}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{تمت المزامنة قبل ساعة واحدة} other{تمت المزامنة قبل {count} ساعات}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{تمت المزامنة قبل يوم واحد} other{تمت المزامنة قبل {count} أيام}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "الإعدادات", + "trayQuitBusyMax": "إنهاء BusyMax", "agendaLoadMoreOverdue": "تحميل المزيد من المهام المتأخرة", "agendaLoadMoreNoDate": "تحميل المزيد من المهام بلا تاريخ", "viewDay": "يوم", @@ -116,7 +164,7 @@ "doesNotRepeat": "لا يتكرر", "defaultReminder": "التذكير الافتراضي", "guests": "المدعوون", - "noGuests": "لا يوجد مدعوون", + "noGuests": "لا يوجد ضيوف", "description": "الوصف", "availabilityShowAs": "التوفر / إظهار كـ", "busy": "مشغول", @@ -217,8 +265,8 @@ "removeAccount": "إزالة الحساب…", "removingAccount": "جارٍ إزالة الحساب…", "removeAccountDescription": "إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.", - "removeAccountTitle": "إزالة ⁨{account}⁩ من BusyMax؟", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "إزالة {account} من BusyMax؟", + "removeAccountConfirmation": "سيؤدي هذا إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المخزنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات التي لم تتم مزامنتها، ولن تُحذف نسخ التقويمات والأحداث وقوائم المهام والمهام لدى موفّر الخدمة.", "revokeGoogleAccess": "إلغاء وصول BusyMax إلى حساب Google هذا أيضًا", "revokeGoogleAccessDescription": "ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.", "removeAccountAction": "إزالة الحساب", @@ -234,7 +282,7 @@ "deleteList": "حذف القائمة", "builtInMicrosoftList": "مدمجة", "builtInMicrosoftListCannotRenameDelete": "لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.", - "deleteListConfirmation": "حذف «⁨{title}⁩» من Google Tasks؟", + "deleteListConfirmation": "حذف \"{title}\" من Google Tasks؟", "deleteEvent": "حذف الحدث", "title": "العنوان", "create": "إنشاء", @@ -271,7 +319,7 @@ "primarySection": "أساسي", "statusSection": "الحالة", "openStatus": "مفتوحة", - "doneStatus": "منجزة", + "doneStatus": "منجز", "notes": "ملاحظات", "dueDate": "تاريخ الاستحقاق", "clearDueDate": "مسح تاريخ الاستحقاق", @@ -391,7 +439,7 @@ "syncFailureNotificationTitle": "فشلت مزامنة BusyMax", "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. ⁨{message}⁩", "conflictNotificationTitle": "تعارض في مزامنة BusyMax", - "conflictNotificationBody": "تم حظر تغيير محلي معلّق. ⁨{summary}⁩", + "conflictNotificationBody": "تم حظر تغيير محلي معلق. {summary}", "dueTodayNotificationTitle": "المهام المستحقة اليوم", "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك ⁨{count}⁩ مهام مستحقة اليوم.} many{هناك ⁨{count}⁩ مهمة مستحقة اليوم.} other{هناك ⁨{count}⁩ مهمة مستحقة اليوم.}}", "eventReminderNotificationTitle": "تذكير بحدث", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "ستحلّ مهلة المهمة قريبًا.", "notificationOpenAction": "فتح", "notificationSnoozeAction": "غفوة لمدة 10 دقائق", - "notificationDismissAction": "تجاهل", + "notificationDismissAction": "إغلاق", "notificationDetailsHidden": "التفاصيل مخفية وفقًا لإعدادات الخصوصية.", "previousMonth": "الشهر السابق", "nextMonth": "الشهر التالي", @@ -413,52 +461,52 @@ "scheduleItemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{⁨{count}⁩ عناصر} many{⁨{count}⁩ عنصرًا} other{⁨{count}⁩ عنصر}}", "readOnlyCalendar": "هذا التقويم للقراءة فقط.", "selectTimeZone": "اختيار المنطقة الزمنية", - "searchLocations": "البحث عن مواقع", + "searchLocations": "البحث عن المواقع", "noLocationsFound": "لم يتم العثور على مواقع", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "هذا الحقل مطلوب.", + "providerConnectionDescription": "اربط التقويمات والمهام من أحد موفّري الخدمة هؤلاء.", + "appleICloudProvider": "تقويم Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "مهام Nextcloud", + "addAppleICloudAccount": "إضافة حساب تقويم Apple iCloud", + "addNextcloudAccount": "إضافة حساب Nextcloud", + "waitingForAppleICloud": "جارٍ الاتصال بـ Apple iCloud…", + "waitingForNextcloud": "بانتظار تفويض Nextcloud…", + "connectAppleICloudTitle": "ربط تقويم Apple iCloud", + "appleAccountEmail": "البريد الإلكتروني لحساب Apple", + "appleAppSpecificPassword": "كلمة مرور خاصة بالتطبيق", + "appleAppSpecificPasswordHelp": "أنشئ كلمة مرور خاصة بالتطبيق بعد تفعيل المصادقة الثنائية لحساب Apple.", + "appleAppSpecificPasswordResetWarning": "تؤدي إعادة تعيين كلمة مرور حساب Apple إلى إبطال كلمات المرور الخاصة بالتطبيقات.", + "connectNextcloudTitle": "ربط Nextcloud", + "nextcloudServerUrl": "خادم Nextcloud أو عنوان CalDAV", + "nextcloudServerUrlHelp": "أدخل عنوان URL لخادم Nextcloud أو الصق عنوان CalDAV الأساسي المنسوخ من Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "سيفتح BusyMax متصفحك. وافق على الوصول هناك، ثم عد إلى BusyMax.", + "connectAccountAction": "ربط", + "cancelAccountConnection": "إلغاء الربط", + "nextcloudAccountRemovedRevokeFailed": "تمت إزالة الحساب محليًا، لكن تعذّر إبطال كلمة مرور تطبيق Nextcloud.", + "davCachedOfflineNotice": "تُخزّن بيانات التقويم والمهام محليًا للاستخدام دون اتصال.", + "davReauthenticationRequired": "أعد ربط هذا الحساب لاستئناف المزامنة.", + "davTemporarilyUnavailable": "هذا الحساب غير متاح مؤقتًا.", + "davPermissionChanged": "تغيّرت أذونات الخادم. تم إيقاف التعديلات المعلقة مؤقتًا.", + "davUnsupportedServer": "هذا الخادم أو ملف موفّر الخدمة غير مدعوم.", + "collectionSettings": "التقويمات وقوائم المهام", + "calendarContent": "أحداث التقويم", + "taskContent": "المهام", + "readOnlySharedCollection": "للقراءة فقط", + "pendingLocally": "معلق محليًا", + "conflictBlocked": "محظور بسبب تعارض", + "authenticationBlocked": "محظور حتى إعادة الاتصال", + "operationFailed": "فشلت العملية", + "keepServerVersion": "الاحتفاظ بإصدار الخادم", + "reapplyLocalChange": "مراجعة التغيير المحلي وإعادة تطبيقه", + "duplicateLocalItem": "تكرار كعنصر جديد", "deleteCalendarConfirmation": "حذف «⁨{title}⁩»؟", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "حالة الاتصال", + "davConnected": "متصل", + "davConnecting": "جارٍ الاتصال…", + "davSignedOut": "تم تسجيل الخروج", + "davLastSuccessfulSync": "آخر مزامنة ناجحة: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "لم تتم المزامنة بعد", + "refreshCollections": "تحديث التقويمات وقوائم المهام", + "nextcloudServerHost": "الخادم: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "تقويم أحداث", + "collectionSupportsTasks": "قائمة مهام", + "collectionSupportsEventsAndTasks": "الأحداث والمهام", + "writableCollection": "قابل للكتابة", + "sharedCollection": "مشترك", + "collectionLastSynced": "آخر مزامنة: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "مشكلة في المزامنة: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "تعارضات المزامنة", + "remoteChangedAt": "غيّر الخادم في: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "تعديل محلي: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "تعذّر حل التعارض.", + "recurringEventScope": "نطاق الحدث المتكرر", + "entireSeries": "السلسلة بأكملها", "singleOccurrence": "هذا الحدث", "thisAndFollowingEvents": "هذا الحدث والأحداث التالية", "thisAndFutureUnavailable": "غير مدعوم من موفّر الخدمة هذا.", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "هل تريد نسخ الحدث وحذف الأصل؟", "copyEventMoveWarning": "لا يستطيع BusyMax نقل هذا الحدث مباشرةً من {source} إلى {destination}. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.", "copyAndDelete": "نسخ وحذف", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "اختر ما إذا كان هذا التغيير ينطبق على السلسلة بأكملها، أو هذا الحدث فقط، أو هذا الحدث والأحداث التالية.", "taskDueBeforeStart": "يجب ألا يكون موعد الاستحقاق قبل وقت البدء.", "taskStartDueTimeModeMismatch": "عيّن وقتًا لكل من البدء والاستحقاق، أو اجعل المهمة طوال اليوم.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "تعذّر إنشاء قائمة المهام: {error}", + "taskListRenameFailed": "تعذّر إعادة تسمية قائمة المهام: {error}", + "taskListDeleteFailed": "تعذّر حذف قائمة المهام: {error}", "unshare": "إلغاء المشاركة", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "قائمة المهام هذه للقراءة فقط ولا يمكن إعادة تسميتها.", + "taskListCannotDelete": "لا يمكن حذف قائمة المهام هذه باستخدام أذوناتك الحالية.", + "deleteTaskListConfirmation": "حذف \"{title}\" وجميع مهامها؟", + "unshareTaskListConfirmation": "إلغاء مشاركة \"{title}\" من هذا الحساب؟", + "taskStatus": "الحالة", + "taskStatusNone": "بلا حالة", "taskStatusNeedsAction": "تحتاج إلى إجراء", - "taskStatusInProcess": "تحت الإجراء", - "taskStatusCompleted": "مُكتمِل", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "قيد التنفيذ", + "taskStatusCompleted": "مكتملة", + "taskStatusCancelled": "ملغاة", + "completionPercent": "اكتمل بنسبة {percent}٪", + "completionDate": "تاريخ الإكمال", "priority": "الأولوية", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "لا توجد أي تذكيرات", - "editReminder": "Edit reminder", - "beforeTaskStarts": "قبل أن تبدأ المهمة", - "beforeTaskDue": "قبل اكتمال المهمة", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "تكرار كل", - "repeatOn": "Repeat on", - "repeatEnd": "نهاية التكرار", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "في يوم {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", + "priorityNone": "بلا أولوية", + "priorityHighValue": "الأولوية {priority} · عالية", + "priorityMediumValue": "الأولوية {priority} · متوسطة", + "priorityLowValue": "الأولوية {priority} · منخفضة", + "taskUrl": "URL المهمة", + "invalidTaskUrl": "أدخل عنوان URL مطلقًا يتضمن مخططه.", + "classification": "التصنيف", + "classificationPublic": "عند المشاركة، أظهر المهمة كاملة", + "classificationConfidential": "عند المشاركة، أظهر الانشغال فقط", + "classificationPrivate": "عند المشاركة، أخفِ هذه المهمة", + "pinTask": "تثبيت المهمة", + "reminders": "التذكيرات", + "noReminders": "لا توجد تذكيرات", + "editReminder": "تعديل التذكير", + "beforeTaskStarts": "قبل بدء المهمة", + "beforeTaskDue": "قبل موعد استحقاق المهمة", + "afterTaskStarts": "بعد بدء المهمة", + "afterTaskDue": "بعد استحقاق المهمة", + "relativeToTaskStart": "بالنسبة إلى تاريخ بدء المهمة", + "relativeToTaskDue": "بالنسبة إلى تاريخ استحقاق المهمة", + "reminderTimeOfDay": "وقت اليوم", + "absoluteReminder": "في تاريخ ووقت", + "reminderAmount": "الكمية", + "reminderUnitSeconds": "ثوانٍ", + "reminderUnitMinutes": "دقائق", + "reminderUnitHours": "ساعات", + "reminderUnitDays": "أيام", + "reminderUnitWeeks": "أسابيع", + "reminderAtTaskStart": "عند بدء المهمة", + "reminderAtTaskDue": "عند وقت استحقاق المهمة", + "unsupportedReminder": "يُحتفظ بنوع هذا التذكير، لكن لا يمكن تعديل وقته.", + "relatedRemindersTitle": "الاحتفاظ بالتذكيرات المرتبطة؟", + "relatedRemindersDescription": "يحتوي هذا التاريخ على {count} من التذكيرات المرتبطة. هل تريد الاحتفاظ بها في تاريخها ووقتها الحاليين؟", + "discardRelatedReminders": "تجاهل التذكيرات", + "keepRelatedReminders": "الاحتفاظ بالتذكيرات", + "repeatEvery": "الفاصل الزمني", + "repeatOn": "التكرار في", + "repeatEnd": "إنهاء التكرار", + "repeatNever": "مطلقًا", + "repeatUntil": "في تاريخ", + "repeatAfter": "بعد عدد من التكرارات", + "repeatCount": "عدد التكرارات", + "repeatDayOfMonth": "أيام الشهر", + "repeatMonths": "الأشهر", + "repeatOrdinal": "ترتيب يوم الأسبوع", + "repeatSpecificDays": "أيام محددة", + "repeatFirst": "الأول", + "repeatSecond": "الثاني", + "repeatThird": "الثالث", + "repeatFourth": "الرابع", + "repeatFifth": "الخامس", + "repeatSecondToLast": "ما قبل الأخير", + "repeatLast": "الأخير", + "repeatAnyDay": "اليوم", + "repeatWeekday": "يوم من أيام الأسبوع", + "repeatWeekendDay": "يوم عطلة نهاية الأسبوع", + "repeatEveryDays": "كل {count} أيام", + "repeatEveryWeeks": "كل {count} أسابيع", + "repeatEveryMonths": "كل {count} أشهر", + "repeatEveryYears": "كل {count} سنوات", + "repeatOnDaysSummary": "في {days}", + "repeatOnMonthDaysSummary": "في اليوم {days} من الشهر", + "repeatOnOrdinalSummary": "في {ordinal} {days}", + "repeatInMonthsSummary": "في {months}", "repeatTimesSummary": "{count} مرات", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatUntilSummary": "حتى {date}", + "unsupportedRecurrencePreserved": "تستخدم قاعدة التكرار هذه خيارات لا يغيّرها هذا المحرر.", "recurrenceUnsupportedByProvider": "لا يمكن استخدام هذا التكرار مع {provider}.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "إخْفِ المهام الفرعية", - "hideClosedSubtasks": "إخف المهام الفرعية المغلقة", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "duplicateTask": "تكرار المهمة", + "taskDuplicated": "تم تكرار المهمة.", + "taskDuplicateFailed": "تعذّر تكرار المهمة: {error}", + "hideSubtasks": "إخفاء المهام الفرعية", + "hideClosedSubtasks": "إخفاء المهام الفرعية المغلقة", + "reminderUnit": "الوحدة", + "attendeeRequired": "مطلوب", + "attendeeOptional": "اختياري", + "meetingSection": "الاجتماع", + "addGoogleMeet": "إضافة Google Meet", + "addTeamsMeeting": "إضافة اجتماع Microsoft Teams", + "onlineMeetingAdded": "تمت إضافة الاجتماع عبر الإنترنت", + "requestResponses": "طلب الردود", + "requestResponsesDescription": "اطلب من الضيوف الرد على الدعوة.", + "hideGuestList": "إخفاء قائمة الضيوف", + "hideGuestListDescription": "لا يمكن للضيوف رؤية المدعوين الآخرين.", + "allowNewTimeProposals": "السماح باقتراح أوقات جديدة", + "allowNewTimeProposalsDescription": "يمكن للضيوف اقتراح وقت مختلف للاجتماع.", + "notifyGuestsTitle": "إبلاغ الضيوف؟", + "notifyGuestsSaveMessage": "يضم هذا الاجتماع ضيوفًا. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟", + "notifyGuestsDeleteMessage": "يضم هذا الاجتماع ضيوفًا. هل تريد إرسال إلغاء عند حذفه؟", + "sendUpdates": "إرسال التحديثات", + "sendCancellation": "إرسال الإلغاء", + "doNotSend": "عدم الإرسال", + "microsoftNotifyGuestsSaveTitle": "حفظ الاجتماع؟", + "microsoftNotifyGuestsSaveMessage": "سترسل Microsoft الدعوات أو تحديثات الحدث إلى الضيوف.", + "microsoftNotifyGuestsDeleteTitle": "حذف الاجتماع؟", + "microsoftNotifyGuestsDeleteMessage": "سترسل Microsoft إلغاءً إلى الضيوف.", + "organizer": "المنظّم", + "yourResponse": "ردك", + "guestResponses": "ردود الضيوف", + "respond": "الرد", + "acceptInvitation": "قبول", + "tentativeInvitation": "مبدئي", + "declineInvitation": "رفض", + "joinMeeting": "الانضمام إلى الاجتماع", + "responseAccepted": "مقبول", + "responseTentative": "مبدئي", + "responseDeclined": "مرفوض", + "responseNeedsAction": "بانتظار الرد", + "responseNotResponded": "لم يتم الرد", + "responseOrganizer": "المنظّم", + "invitationResponseFailed": "تعذّر إرسال ردك: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "تعذّر فتح رابط الاجتماع.", "setCustomCalendarName": "تعيين اسم مخصص", "setAction": "تعيين", "removeFromMyCalendars": "إزالة من تقاويمي", "removeAction": "إزالة", "removeCalendarConfirmation": "هل تريد إزالة \"{title}\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", "calendarCannotRemove": "لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.", - "calendarPendingChangesPreventRemoval": "انتظر حتى تنتهي مزامنة التغييرات المعلّقة لهذا التقويم قبل حذفه أو إزالته.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", + "calendarPendingChangesPreventRemoval": "انتظر حتى تنتهي مزامنة التغييرات المعلقة لهذا التقويم قبل حذفه أو إزالته.", + "calendarSubscriptions": "اشتراكات التقويم", + "calendarSubscriptionsDescription": "أضف تقاويم للقراءة فقط يتم تحديثها من عنوان WebCal آمن.", + "addCalendarSubscription": "إضافة اشتراك تقويم", + "subscriptionName": "الاسم المحلي", + "subscriptionUrl": "عنوان URL للاشتراك", + "subscriptionUrlHelp": "أدخل عنوان HTTPS أو webcal. يحتفظ BusyMax بعنوان URL الكامل في التخزين الآمن.", + "subscriptionUrlInvalid": "أدخل عنوان HTTPS أو webcal صالحًا من دون معلومات مستخدم أو جزء.", + "subscriptionColor": "اللون المحلي", + "subscriptionColorHelp": "استخدم لونًا من ستة أرقام مثل #3584E4.", + "subscriptionColorInvalid": "أدخل لونًا سداسيًا عشريًا من ستة أرقام.", + "subscriptionRefreshMode": "تكرار التحديث", + "subscriptionAutomatic": "تلقائي", + "subscriptionHourly": "كل ساعة", + "subscriptionSixHours": "كل ست ساعات", + "subscriptionDaily": "يوميًا", + "subscriptionSafeOrigin": "المصدر: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "أدخل عنوان URL صالحًا لمعاينة مصدره الآمن.", + "subscriptionReadOnly": "اشتراك للقراءة فقط", + "subscriptionNeverRefreshed": "لم يتم التحديث بعد", + "subscriptionLastRefresh": "آخر تحديث ناجح: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "التحديث التالي: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "محدّث", + "subscriptionStatusIssue": "مشكلة في التحديث: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "تحديث الآن", + "unsubscribe": "إلغاء الاشتراك", + "unsubscribeCalendarTitle": "إلغاء الاشتراك من «{name}»؟", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "يؤدي هذا إلى إزالة الاشتراك المحلي والأحداث المخزنة مؤقتًا. لن يتغير التقويم المنشور.", + "addSubscriptionAction": "إضافة اشتراك", + "subscriptionOperationFailed": "فشل اشتراك التقويم: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "الاشتراكات", "calendarImport": "استيراد التقويم", - "calendarImportDescription": "حدّد ملفًا وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي سيتلقاها.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarImportDescription": "حدد ملفًا، وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي ينبغي أن يستقبلها.", + "importIcsFile": "استيراد ملف ‎.ics", + "importIcsPreview": "استيراد أحداث التقويم", + "importEventsFound": "مجموعات أحداث قابلة للاستيراد: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "أحداث غير صالحة: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "تم الاستبعاد عمدًا: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "لا يوجد تقويم وجهة قابل للكتابة.", + "importDestinationCalendar": "تقويم الوجهة", + "importIcsConfirm": "استيراد الأحداث", + "importIcsComplete": "اكتمل الاستيراد", + "importQueued": "تم الاستيراد أو وضعه في قائمة الانتظار: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "تم تخطي التكرارات: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "مجموعات تكرار غير مدعومة: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "تعذّر استيراد ملف التقويم: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "غير متصل", "networkOfflineDescription": "ستتم مزامنة التغييرات عند استعادة الاتصال.", "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى." diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad162541..e68fb2b2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,14 +1,14 @@ { "@@locale": "de", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Verbinden Sie Google-, Microsoft-, Apple-iCloud-Kalender- oder Nextcloud-Konten.", "googlePermissionsConsentNotice": "Wählen Sie auf dem Google-Berechtigungsbildschirm sowohl Kalender- als auch Aufgabenberechtigungen aus.", "googlePermissionsRequiredRetry": "Die Berechtigungen für Google Kalender und Google Tasks sind erforderlich. Versuchen Sie es erneut und aktivieren Sie beide Kontrollkästchen.", "finishSetup": "Einrichtung abschließen", "continueSetup": "Weiter", "onboardingSetupTitle": "BusyMax einrichten", "onboardingAccountsStepTitle": "Konten verbinden", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Fügen Sie alle Konten hinzu, die Sie verwenden möchten. BusyMax synchronisiert unterstützte Kalender, Ereignisse, Aufgabenlisten und Aufgaben aus jedem Konto.", "onboardingPreferencesStepTitle": "Systemeinstellungen wählen", "onboardingPreferencesStepDescription": "Legen Sie Desktop-Verhalten, Erinnerungen, Benachrichtigungsdetails und Darstellung fest, bevor Sie Ihren Zeitplan öffnen.", "signInWithGoogle": "Mit Google anmelden", @@ -36,16 +36,40 @@ "newCalendar": "Neuer Kalender", "calendarColor": "Kalenderfarbe", "calendarColorOption": "Farbe {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "Dieser Anbieter unterstützt die Kalenderverwaltung in BusyMax nicht.", "primaryCalendarCannotDelete": "Der primäre Kalender kann nicht gelöscht werden.", "calendarCreateFailed": "Kalender konnte nicht erstellt werden: {error}", "calendarCreatedRefreshPending": "Der Kalender wurde erstellt, aber BusyMax konnte das Konto nicht aktualisieren. Er erscheint nach der nächsten Synchronisierung.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Kalender konnte nicht aktualisiert werden: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Kalender konnte nicht gelöscht werden: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Neuer Termin", "refreshCalendar": "Kalender aktualisieren", "openInProvider": "Beim Anbieter öffnen", @@ -65,38 +89,62 @@ "scheduleNoSearchResultsDescription": "Versuchen Sie es mit einer anderen Suche oder setzen Sie die aktuellen Filter zurück.", "refresh": "Aktualisieren", "trayOpenBusyMax": "BusyMax öffnen", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "BusyMax anzeigen", + "trayNewEvent": "Neuer Termin…", + "trayNewTask": "Neue Aufgabe…", + "trayToday": "Heute", + "trayAllDay": "Ganztägig", + "trayNow": "Jetzt", + "trayCalendarEvent": "Kalendertermin", + "trayUntitledEvent": "Termin ohne Titel", + "trayNothingElseToday": "Heute nichts Weiteres", + "trayTasksDueToday": "{count, plural, =1{1 Aufgabe heute fällig} other{{count} Aufgaben heute fällig}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Heutige Agenda öffnen", + "traySyncNow": "Jetzt synchronisieren", + "traySyncing": "Synchronisierung…", + "trayNotConnected": "Nicht verbunden", + "trayNotYetSynced": "Noch nicht synchronisiert", + "trayLastSyncedJustNow": "Gerade synchronisiert", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Vor 1 Minute synchronisiert} other{Vor {count} Minuten synchronisiert}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Vor 1 Stunde synchronisiert} other{Vor {count} Stunden synchronisiert}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Vor 1 Tag synchronisiert} other{Vor {count} Tagen synchronisiert}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Einstellungen", + "trayQuitBusyMax": "BusyMax beenden", "agendaLoadMoreOverdue": "Weitere überfällige Aufgaben laden", "agendaLoadMoreNoDate": "Weitere Aufgaben ohne Datum laden", "viewDay": "Tag", "viewWeek": "Woche", "viewMonth": "Monat", "viewYear": "Jahr", - "viewAgenda": "Agenda", + "viewAgenda": "Tagesübersicht", "scheduleSettings": "Zeitplan", "scheduleDisplaySettings": "Zeitplananzeige", "scheduleDisplayHoursDescription": "In der Tages- und Wochenansicht wird zunächst dieser Zeitraum angezeigt. Frühere oder spätere Einträge erweitern ihn bei Bedarf.", @@ -164,18 +212,18 @@ "visibilityPublic": "Öffentlich", "visibilityPrivate": "Privat", "visibilityConfidential": "Vertraulich", - "sensitivityNormal": "Normal", + "sensitivityNormal": "Gewöhnlich", "sensitivityPersonal": "Persönlich", "tasks": "Aufgaben", "allTasks": "Alle Aufgaben", "tasksInList": "Aufgaben in {title}", "taskLists": "Aufgabenlisten", - "navigation": "Navigation", + "navigation": "Bedienung", "mainMenu": "Hauptmenü", "keyboardShortcuts": "Tastenkürzel", "shortcutGroupGeneral": "Allgemein", "shortcutKeyboardShortcutsDescription": "Diese Übersicht der Tastenkürzel anzeigen", - "shortcutGroupNavigation": "Navigation", + "shortcutGroupNavigation": "Bedienung", "shortcutNextPeriod": "Nächster Zeitraum", "shortcutNextPeriodDescription": "Nächste Woche in der Wochenansicht, nächster Monat in der Monatsansicht usw.", "shortcutPreviousPeriod": "Vorheriger Zeitraum", @@ -197,7 +245,7 @@ "aboutBusyMaxDescription": "Kalender und Aufgaben", "license": "Lizenz", "apacheLicenseName": "Apache License 2.0", - "website": "Website", + "website": "Webseite", "sourceCode": "Quellcode", "reportAnIssue": "Problem melden", "sendFeedback": "Feedback senden", @@ -246,7 +294,7 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Kopien der Kalender, Termine, Aufgabenlisten und Aufgaben beim Anbieter werden nicht gelöscht.", "revokeGoogleAccess": "BusyMax-Zugriff auf dieses Google-Konto ebenfalls widerrufen", "revokeGoogleAccessDescription": "Vor einer erneuten Verbindung müssen Sie den Zugriff wieder gewähren.", "removeAccountAction": "Konto entfernen", @@ -262,7 +310,7 @@ "deleteList": "Liste löschen", "builtInMicrosoftList": "Integriert", "builtInMicrosoftListCannotRenameDelete": "Integrierte Microsoft To Do-Listen können nicht umbenannt oder gelöscht werden.", - "deleteListConfirmation": "\"{title}\" aus Google Tasks löschen?", + "deleteListConfirmation": "„{title}“ aus Google Tasks löschen?", "deleteEvent": "Termin löschen", "title": "Titel", "create": "Erstellen", @@ -311,7 +359,7 @@ "signInToEditTasks": "Melden Sie sich an, um Aufgaben zu bearbeiten.", "refreshTask": "Aufgabe aktualisieren", "primarySection": "Primär", - "statusSection": "Status", + "statusSection": "Aufgabenstatus", "openStatus": "Offen", "doneStatus": "Erledigt", "notes": "Notizen", @@ -339,12 +387,12 @@ "repeatYearly": "Jährlich", "importance": "Wichtigkeit", "importanceLow": "Niedrig", - "importanceNormal": "Normal", + "importanceNormal": "Mittel", "importanceHigh": "Hoch", "categories": "Kategorien", "scheduleSection": "Zeitplan", "dueGroup": "Fällig", - "startGroup": "Start", + "startGroup": "Beginn", "reminderGroup": "Erinnerung", "organizationSection": "Organisation", "actionsSection": "Aktionen", @@ -363,7 +411,7 @@ "etag": "ETag", "updated": "Aktualisiert", "parent": "Übergeordnete Aufgabe", - "position": "Position", + "position": "Reihenfolge", "webLink": "Weblink", "assignment": "Zuweisung", "localState": "Lokaler Status", @@ -389,7 +437,7 @@ "taskReminders": "Aufgabenerinnerungen", "notificationDetailLevel": "Detailgrad der Benachrichtigungen", "notificationDetailPrivate": "Privat", - "notificationDetailNormal": "Normal", + "notificationDetailNormal": "Standard", "quietHours": "Ruhezeiten", "quietHoursDescription": "Benachrichtigungen während dieses Zeitraums pausieren.", "quietHoursStart": "Beginn der Ruhezeit", @@ -397,7 +445,7 @@ "notifications": "Benachrichtigungen", "appearance": "Darstellung", "theme": "Design", - "themeSystem": "System", + "themeSystem": "Systemstandard", "themeLight": "Hell", "themeDark": "Dunkel", "themeFamily": "Designfamilie", @@ -442,7 +490,7 @@ "taskReminderNotificationBody": "Die Aufgabe ist bald fällig.", "notificationOpenAction": "Öffnen", "notificationSnoozeAction": "In 10 Minuten erinnern", - "notificationDismissAction": "Verwerfen", + "notificationDismissAction": "Schließen", "notificationDetailsHidden": "Details werden durch Datenschutzeinstellungen ausgeblendet.", "previousMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat", @@ -471,44 +519,44 @@ "selectTimeZone": "Zeitzone auswählen", "searchLocations": "Orte suchen", "noLocationsFound": "Keine Orte gefunden", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Dieses Feld ist erforderlich.", + "providerConnectionDescription": "Verbinden Sie Kalender und Aufgaben mit einem dieser Anbieter.", + "appleICloudProvider": "Apple-iCloud-Kalender", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud-Aufgaben", + "addAppleICloudAccount": "Apple-iCloud-Kalenderkonto hinzufügen", + "addNextcloudAccount": "Nextcloud-Konto hinzufügen", + "waitingForAppleICloud": "Verbindung mit Apple iCloud wird hergestellt…", + "waitingForNextcloud": "Warten auf die Nextcloud-Autorisierung…", + "connectAppleICloudTitle": "Apple-iCloud-Kalender verbinden", + "appleAccountEmail": "E-Mail des Apple-Accounts", + "appleAppSpecificPassword": "App-spezifisches Passwort", + "appleAppSpecificPasswordHelp": "Erstellen Sie nach dem Aktivieren der Zwei-Faktor-Authentifizierung für Ihren Apple-Account ein app-spezifisches Passwort.", + "appleAppSpecificPasswordResetWarning": "Durch das Zurücksetzen Ihres Apple-Account-Passworts werden app-spezifische Passwörter widerrufen.", + "connectNextcloudTitle": "Nextcloud verbinden", + "nextcloudServerUrl": "Nextcloud-Server oder CalDAV-Adresse", + "nextcloudServerUrlHelp": "Geben Sie die URL Ihres Nextcloud-Servers ein oder fügen Sie die aus Nextcloud kopierte primäre CalDAV-Adresse ein.", + "nextcloudBrowserAuthorizationHelp": "BusyMax öffnet Ihren Browser. Genehmigen Sie dort den Zugriff und kehren Sie anschließend zu BusyMax zurück.", + "connectAccountAction": "Verbinden", + "cancelAccountConnection": "Verbindung abbrechen", + "nextcloudAccountRemovedRevokeFailed": "Das Konto wurde lokal entfernt, aber das Nextcloud-App-Passwort konnte nicht widerrufen werden.", + "davCachedOfflineNotice": "Kalender- und Aufgabendaten werden für die Offline-Nutzung lokal zwischengespeichert.", + "davReauthenticationRequired": "Verbinden Sie dieses Konto erneut, um die Synchronisierung fortzusetzen.", + "davTemporarilyUnavailable": "Dieses Konto ist vorübergehend nicht verfügbar.", + "davPermissionChanged": "Die Serverberechtigungen wurden geändert. Ausstehende Bearbeitungen sind pausiert.", + "davUnsupportedServer": "Dieser Server oder dieses Anbieterprofil wird nicht unterstützt.", + "collectionSettings": "Kalender und Aufgabenlisten", + "calendarContent": "Kalendertermine", + "taskContent": "Aufgaben", + "readOnlySharedCollection": "Schreibgeschützt", + "pendingLocally": "Lokal ausstehend", + "conflictBlocked": "Durch Konflikt blockiert", + "authenticationBlocked": "Bis zur erneuten Verbindung blockiert", + "operationFailed": "Vorgang fehlgeschlagen", + "keepServerVersion": "Serverversion behalten", + "reapplyLocalChange": "Lokale Änderung prüfen und erneut anwenden", + "duplicateLocalItem": "Als neues Element duplizieren", "deleteCalendarConfirmation": "\"{title}\" löschen?", "@deleteCalendarConfirmation": { "placeholders": { @@ -517,11 +565,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Verbindungsstatus", + "davConnected": "Verbunden", + "davConnecting": "Verbindung wird hergestellt…", + "davSignedOut": "Abgemeldet", + "davLastSuccessfulSync": "Letzte erfolgreiche Synchronisierung: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -529,9 +577,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Noch nicht synchronisiert", + "refreshCollections": "Kalender und Aufgabenlisten aktualisieren", + "nextcloudServerHost": "Serveradresse: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -539,12 +587,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Terminkalender", + "collectionSupportsTasks": "Aufgabenliste", + "collectionSupportsEventsAndTasks": "Termine und Aufgaben", + "writableCollection": "Beschreibbar", + "sharedCollection": "Freigegeben", + "collectionLastSynced": "Zuletzt synchronisiert: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -552,7 +600,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Synchronisierungsproblem: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -560,8 +608,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Synchronisierungskonflikte", + "remoteChangedAt": "Server geändert: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -569,7 +617,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Lokale Änderung: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -577,216 +625,300 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", - "singleOccurrence": "Dieses Ereignis", - "thisAndFollowingEvents": "Dieses und folgende Ereignisse", + "conflictResolutionFailed": "Der Konflikt konnte nicht gelöst werden.", + "recurringEventScope": "Bereich des wiederkehrenden Termins", + "entireSeries": "Gesamte Serie", + "singleOccurrence": "Dieser Termin", + "thisAndFollowingEvents": "Dieser und die folgenden Termine", "thisAndFutureUnavailable": "Von diesem Anbieter nicht unterstützt.", - "thisAndFutureMoveUnavailable": "Dieses und die folgenden Ereignisse können nicht sicher verschoben werden. Wählen Sie dieses Ereignis oder die gesamte Serie.", - "entireSeriesMoveUnavailable": "Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen dieses Ereignis.", - "copyEventAndDeleteOriginal": "Ereignis kopieren und Original löschen?", - "copyEventMoveWarning": "BusyMax kann dieses Ereignis nicht direkt von {source} nach {destination} verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Ereignis-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.", + "thisAndFutureMoveUnavailable": "Dieser Termin und die folgenden Termine können nicht sicher verschoben werden. Wählen Sie diesen Termin oder die gesamte Serie.", + "entireSeriesMoveUnavailable": "Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen diesen Termin.", + "copyEventAndDeleteOriginal": "Termin kopieren und Original löschen?", + "copyEventMoveWarning": "BusyMax kann diesen Termin nicht direkt von {source} nach {destination} verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Termin-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.", "copyAndDelete": "Kopieren und löschen", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Wählen Sie, ob diese Änderung für die gesamte Serie, nur diesen Termin oder diesen und die folgenden Termine gilt.", "taskDueBeforeStart": "Die Fälligkeit darf nicht vor dem Beginn liegen.", - "taskStartDueTimeModeMismatch": "Lege für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder mache die Aufgabe ganztägig.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskStartDueTimeModeMismatch": "Legen Sie für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder machen Sie die Aufgabe ganztägig.", + "taskListCreateFailed": "Aufgabenliste konnte nicht erstellt werden: {error}", + "taskListRenameFailed": "Aufgabenliste konnte nicht umbenannt werden: {error}", + "taskListDeleteFailed": "Aufgabenliste konnte nicht gelöscht werden: {error}", "unshare": "Freigabe aufheben", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "Diese Aufgabenliste ist schreibgeschützt und kann nicht umbenannt werden.", + "taskListCannotDelete": "Diese Aufgabenliste kann mit Ihren aktuellen Berechtigungen nicht gelöscht werden.", + "deleteTaskListConfirmation": "„{title}“ und alle Aufgaben löschen?", + "unshareTaskListConfirmation": "Freigabe von „{title}“ für dieses Konto aufheben?", + "taskStatus": "Aufgabenstatus", + "taskStatusNone": "Kein Status", "taskStatusNeedsAction": "Handlungsbedarf", "taskStatusInProcess": "In Bearbeitung", - "taskStatusCompleted": "Fertiggestellt", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Erledigt", + "taskStatusCancelled": "Abgebrochen", + "completionPercent": "{percent}% abgeschlossen", + "completionDate": "Abschlussdatum", "priority": "Priorität", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "Keine Priorität", + "priorityHighValue": "Priorität {priority} · Hoch", + "priorityMediumValue": "Priorität {priority} · Mittel", + "priorityLowValue": "Priorität {priority} · Niedrig", + "taskUrl": "Aufgaben-URL", + "invalidTaskUrl": "Geben Sie eine absolute URL einschließlich ihres Schemas ein.", + "classification": "Klassifizierung", + "classificationPublic": "Bei Freigabe die vollständige Aufgabe anzeigen", + "classificationConfidential": "Bei Freigabe nur den Belegt-Status anzeigen", + "classificationPrivate": "Diese Aufgabe bei Freigabe ausblenden", + "pinTask": "Aufgabe anheften", + "reminders": "Erinnerungen", "noReminders": "Keine Erinnerungen", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Bevor die Aufgabe startet", - "beforeTaskDue": "Bevor die Aufgabe fällig ist", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "editReminder": "Erinnerung bearbeiten", + "beforeTaskStarts": "Vor Beginn der Aufgabe", + "beforeTaskDue": "Vor Fälligkeit der Aufgabe", + "afterTaskStarts": "Nach Beginn der Aufgabe", + "afterTaskDue": "Nach Fälligkeit der Aufgabe", + "relativeToTaskStart": "Relativ zum Startdatum der Aufgabe", + "relativeToTaskDue": "Relativ zum Fälligkeitsdatum der Aufgabe", + "reminderTimeOfDay": "Tageszeit", + "absoluteReminder": "Zu einem Datum und einer Uhrzeit", + "reminderAmount": "Menge", + "reminderUnitSeconds": "Sekunden", + "reminderUnitMinutes": "Minuten", + "reminderUnitHours": "Stunden", + "reminderUnitDays": "Tage", + "reminderUnitWeeks": "Wochen", + "reminderAtTaskStart": "Zu Beginn der Aufgabe", + "reminderAtTaskDue": "Zum Fälligkeitszeitpunkt der Aufgabe", + "unsupportedReminder": "Dieser Erinnerungstyp bleibt erhalten, aber seine Zeit kann nicht bearbeitet werden.", + "relatedRemindersTitle": "Verknüpfte Erinnerungen behalten?", + "relatedRemindersDescription": "Für dieses Datum gibt es {count} verknüpfte Erinnerungen. Sollen sie ihr aktuelles Datum und ihre aktuelle Uhrzeit behalten?", "discardRelatedReminders": "Erinnerungen verwerfen", "keepRelatedReminders": "Erinnerungen behalten", - "repeatEvery": "Wiederhole jeden", - "repeatOn": "Repeat on", + "repeatEvery": "Intervall", + "repeatOn": "Wiederholen an", "repeatEnd": "Wiederholung beenden", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "repeatNever": "Nie", + "repeatUntil": "An einem Datum", + "repeatAfter": "Nach einer Anzahl von Wiederholungen", + "repeatCount": "Wiederholungen", + "repeatDayOfMonth": "Tage des Monats", + "repeatMonths": "Monate", + "repeatOrdinal": "Wochentagsposition", + "repeatSpecificDays": "Bestimmte Tage", + "repeatFirst": "Erste", + "repeatSecond": "Zweite", + "repeatThird": "Dritte", + "repeatFourth": "Vierte", + "repeatFifth": "Fünfte", + "repeatSecondToLast": "Vorletzte", + "repeatLast": "Letzte", + "repeatAnyDay": "Tag", + "repeatWeekday": "Wochentag", + "repeatWeekendDay": "Wochenendtag", + "repeatEveryDays": "Alle {count} Tage", + "repeatEveryWeeks": "Alle {count} Wochen", + "repeatEveryMonths": "Alle {count} Monate", + "repeatEveryYears": "Alle {count} Jahre", "repeatOnDaysSummary": "an {days}", - "repeatOnMonthDaysSummary": "am Tag {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} mal", + "repeatOnMonthDaysSummary": "am {days}. Tag", + "repeatOnOrdinalSummary": "am {ordinal} {days}", + "repeatInMonthsSummary": "in den Monaten {months}", + "repeatTimesSummary": "{count}-mal", "repeatUntilSummary": "bis {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "Diese Wiederholungsregel verwendet Optionen, die dieser Editor nicht ändert.", "recurrenceUnsupportedByProvider": "Diese Wiederholung kann nicht mit {provider} verwendet werden.", "duplicateTask": "Aufgabe duplizieren", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "Aufgabe dupliziert.", + "taskDuplicateFailed": "Aufgabe konnte nicht dupliziert werden: {error}", "hideSubtasks": "Teilaufgaben ausblenden", "hideClosedSubtasks": "Geschlossene Teilaufgaben ausblenden", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "Einheit", + "attendeeRequired": "Erforderlich", + "attendeeOptional": "Freiwillig", + "meetingSection": "Besprechung", + "addGoogleMeet": "Google Meet hinzufügen", + "addTeamsMeeting": "Microsoft-Teams-Besprechung hinzufügen", + "onlineMeetingAdded": "Onlinebesprechung hinzugefügt", + "requestResponses": "Antworten anfordern", + "requestResponsesDescription": "Bitten Sie Gäste, auf die Einladung zu antworten.", + "hideGuestList": "Gästeliste ausblenden", + "hideGuestListDescription": "Gäste können nicht sehen, wer sonst eingeladen wurde.", + "allowNewTimeProposals": "Neue Zeitvorschläge zulassen", + "allowNewTimeProposalsDescription": "Gäste können eine andere Besprechungszeit vorschlagen.", + "notifyGuestsTitle": "Gäste benachrichtigen?", + "notifyGuestsSaveMessage": "Diese Besprechung hat Gäste. Sollen beim Speichern Einladungen oder Terminaktualisierungen gesendet werden?", + "notifyGuestsDeleteMessage": "Diese Besprechung hat Gäste. Soll beim Löschen eine Absage gesendet werden?", + "sendUpdates": "Aktualisierungen senden", + "sendCancellation": "Absage senden", + "doNotSend": "Nicht senden", + "microsoftNotifyGuestsSaveTitle": "Besprechung speichern?", + "microsoftNotifyGuestsSaveMessage": "Microsoft sendet Einladungen oder Terminaktualisierungen an die Gäste.", + "microsoftNotifyGuestsDeleteTitle": "Besprechung löschen?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft sendet eine Absage an die Gäste.", + "organizer": "Organisator", + "yourResponse": "Ihre Antwort", + "guestResponses": "Gästeantworten", + "respond": "Antworten", + "acceptInvitation": "Annehmen", + "tentativeInvitation": "Mit Vorbehalt", + "declineInvitation": "Ablehnen", + "joinMeeting": "An Besprechung teilnehmen", + "responseAccepted": "Angenommen", + "responseTentative": "Mit Vorbehalt", + "responseDeclined": "Abgelehnt", + "responseNeedsAction": "Antwort ausstehend", + "responseNotResponded": "Nicht beantwortet", + "responseOrganizer": "Organisator", + "invitationResponseFailed": "Ihre Antwort konnte nicht gesendet werden: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Der Besprechungslink konnte nicht geöffnet werden.", "setCustomCalendarName": "Benutzerdefinierten Namen festlegen", "setAction": "Festlegen", "removeFromMyCalendars": "Aus meinen Kalendern entfernen", "removeAction": "Entfernen", "removeCalendarConfirmation": "„{title}“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.", - "calendarCannotRemove": "Dieser Kalender kann in diesem Konto weder gelöscht noch entfernt werden.", + "calendarCannotRemove": "Dieser Kalender kann nicht gelöscht oder aus diesem Konto entfernt werden.", "calendarPendingChangesPreventRemoval": "Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "Kalender importieren", - "calendarImportDescription": "Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie dann den beschreibbaren Zielkalender.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, - "networkOffline": "Offline", + "calendarSubscriptions": "Kalenderabonnements", + "calendarSubscriptionsDescription": "Fügen Sie schreibgeschützte Kalender hinzu, die über eine sichere WebCal-URL aktualisiert werden.", + "addCalendarSubscription": "Kalenderabonnement hinzufügen", + "subscriptionName": "Lokaler Name", + "subscriptionUrl": "Abonnement-URL", + "subscriptionUrlHelp": "Geben Sie eine HTTPS- oder webcal-URL ein. BusyMax speichert die vollständige URL sicher.", + "subscriptionUrlInvalid": "Geben Sie eine gültige HTTPS- oder webcal-URL ohne Benutzerinformationen oder Fragment ein.", + "subscriptionColor": "Lokale Farbe", + "subscriptionColorHelp": "Verwenden Sie eine sechsstellige Farbe wie #3584E4.", + "subscriptionColorInvalid": "Geben Sie eine sechsstellige Hexadezimalfarbe ein.", + "subscriptionRefreshMode": "Aktualisierungshäufigkeit", + "subscriptionAutomatic": "Automatisch", + "subscriptionHourly": "Stündlich", + "subscriptionSixHours": "Alle sechs Stunden", + "subscriptionDaily": "Täglich", + "subscriptionSafeOrigin": "Quelle: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Geben Sie eine gültige URL ein, um ihre sichere Quelle anzuzeigen.", + "subscriptionReadOnly": "Schreibgeschütztes Abonnement", + "subscriptionNeverRefreshed": "Noch nicht aktualisiert", + "subscriptionLastRefresh": "Letzte erfolgreiche Aktualisierung: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Nächste Aktualisierung: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Aktuell", + "subscriptionStatusIssue": "Aktualisierungsproblem: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Jetzt aktualisieren", + "unsubscribe": "Abbestellen", + "unsubscribeCalendarTitle": "„{name}“ abbestellen?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Dadurch werden das lokale Abonnement und die zwischengespeicherten Termine entfernt. Der veröffentlichte Kalender wird nicht geändert.", + "addSubscriptionAction": "Abonnement hinzufügen", + "subscriptionOperationFailed": "Kalenderabonnement fehlgeschlagen: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Abonnements", + "calendarImport": "Kalenderimport", + "calendarImportDescription": "Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie anschließend den beschreibbaren Kalender aus, der sie aufnehmen soll.", + "importIcsFile": "‎.ics-Datei importieren", + "importIcsPreview": "Kalendertermine importieren", + "importEventsFound": "Importierbare Terminserien: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Ungültige Termine: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Absichtlich ausgelassen: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Kein beschreibbarer Zielkalender verfügbar.", + "importDestinationCalendar": "Zielkalender", + "importIcsConfirm": "Termine importieren", + "importIcsComplete": "Import abgeschlossen", + "importQueued": "Importiert oder in die Warteschlange gestellt: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Übersprungene Duplikate: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Nicht unterstützte Wiederholungsserien: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Kalenderdatei konnte nicht importiert werden: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "networkOffline": "Ohne Verbindung", "networkOfflineDescription": "Änderungen werden synchronisiert, sobald die Verbindung wiederhergestellt ist.", "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9323e88f..db85e47a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2,6 +2,9 @@ "@@locale": "en", "appTitle": "BusyMax", "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "@connectGoogleAccount": { + "description": "Shown in account onboarding and settings; names the supported account providers." + }, "googlePermissionsConsentNotice": "On the Google permission screen, select both Calendar and Tasks permissions.", "googlePermissionsRequiredRetry": "Google Calendar and Google Tasks permissions are required. Please try again and select both checkboxes.", "finishSetup": "Finish setup", @@ -9,6 +12,9 @@ "onboardingSetupTitle": "Set Up BusyMax", "onboardingAccountsStepTitle": "Connect accounts", "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "@onboardingAccountsStepDescription": { + "description": "Onboarding explanation of the account data BusyMax synchronizes." + }, "onboardingPreferencesStepTitle": "Choose system settings", "onboardingPreferencesStepDescription": "Set desktop behavior, reminders, notification detail, and appearance before opening your schedule.", "signInWithGoogle": "Sign in with Google", @@ -345,7 +351,7 @@ "taskStatus": "Status", "taskStatusNone": "No status", "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", + "taskStatusInProcess": "In progress", "taskStatusCompleted": "Completed", "taskStatusCancelled": "Cancelled", "completionPercent": "{percent}% completed", @@ -383,6 +389,9 @@ "editReminder": "Edit reminder", "beforeTaskStarts": "Before the task starts", "beforeTaskDue": "Before the task is due", + "@beforeTaskDue": { + "description": "Reminder timing relative to the task's due time/deadline, not task completion." + }, "afterTaskStarts": "After the task starts", "afterTaskDue": "After the task is due", "relativeToTaskStart": "Relative to the task start date", @@ -415,8 +424,14 @@ "repeatWeekly": "Weekly", "repeatMonthly": "Monthly", "repeatYearly": "Yearly", - "repeatEvery": "Repeat every", + "repeatEvery": "Interval", + "@repeatEvery": { + "description": "Standalone label for the numeric interval used by daily, weekly, monthly, and yearly recurrence." + }, "repeatOn": "Repeat on", + "@repeatOn": { + "description": "Recurrence editor label introducing the selected weekdays, month days, or ordinal weekday." + }, "repeatEnd": "End repeat", "repeatNever": "Never", "repeatUntil": "On date", @@ -445,17 +460,35 @@ "repeatEveryYears": "Every {count} years", "@repeatEveryYears": {"placeholders": {"count": {"type": "int"}}}, "repeatOnDaysSummary": "on {days}", - "@repeatOnDaysSummary": {"placeholders": {"days": {"type": "String"}}}, + "@repeatOnDaysSummary": { + "description": "Sentence fragment appended to a recurrence summary; {days} is a localized list of weekdays.", + "placeholders": {"days": {"type": "String"}} + }, "repeatOnMonthDaysSummary": "on day {days}", - "@repeatOnMonthDaysSummary": {"placeholders": {"days": {"type": "String"}}}, + "@repeatOnMonthDaysSummary": { + "description": "Sentence fragment appended to a recurrence summary; {days} is a localized list of day-of-month numbers.", + "placeholders": {"days": {"type": "String"}} + }, "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "@repeatOnOrdinalSummary": {"placeholders": {"ordinal": {"type": "String"}, "days": {"type": "String"}}}, + "@repeatOnOrdinalSummary": { + "description": "Sentence fragment for ordinal recurrence, such as the first Monday; {ordinal} and {days} are localized.", + "placeholders": {"ordinal": {"type": "String"}, "days": {"type": "String"}} + }, "repeatInMonthsSummary": "in {months}", - "@repeatInMonthsSummary": {"placeholders": {"months": {"type": "String"}}}, + "@repeatInMonthsSummary": { + "description": "Sentence fragment for selected recurrence months; {months} is a localized list of month names.", + "placeholders": {"months": {"type": "String"}} + }, "repeatTimesSummary": "{count} times", - "@repeatTimesSummary": {"placeholders": {"count": {"type": "int"}}}, + "@repeatTimesSummary": { + "description": "Sentence fragment summarizing a finite recurrence count.", + "placeholders": {"count": {"type": "int"}} + }, "repeatUntilSummary": "until {date}", - "@repeatUntilSummary": {"placeholders": {"date": {"type": "String"}}}, + "@repeatUntilSummary": { + "description": "Sentence fragment for the recurrence end date.", + "placeholders": {"date": {"type": "String"}} + }, "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", "recurrenceUnsupportedByProvider": "This recurrence cannot be used with {provider}.", "@recurrenceUnsupportedByProvider": {"placeholders": {"provider": {"type": "String"}}}, @@ -492,6 +525,9 @@ "etag": "ETag", "updated": "Updated", "parent": "Parent", + "@parent": { + "description": "Metadata label for the immediate parent task of a subtask." + }, "position": "Position", "webLink": "Web link", "assignment": "Assignment", @@ -583,6 +619,9 @@ "notificationOpenAction": "Open", "notificationSnoozeAction": "Snooze 10 minutes", "notificationDismissAction": "Dismiss", + "@notificationDismissAction": { + "description": "Non-destructive action that closes or dismisses a desktop notification. Translate distinctly from reject, discard, or cancel." + }, "notificationDetailsHidden": "Details are hidden by privacy settings.", "previousMonth": "Previous month", "nextMonth": "Next month", @@ -673,7 +712,10 @@ "copyEventMoveWarning": "BusyMax cannot move this event directly from {source} to {destination}. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.", "@copyEventMoveWarning": {"placeholders": {"source": {"type": "String"}, "destination": {"type": "String"}}}, "copyAndDelete": "Copy and delete", - "chooseRecurringEventScope": "Choose which events this change applies to.", + "chooseRecurringEventScope": "Choose whether this change applies to the entire series, only this occurrence, or this and following events.", + "@chooseRecurringEventScope": { + "description": "Explains the three recurring-event scope choices: entire series, one occurrence, or this and following events." + }, "taskDueBeforeStart": "Due must not be before start.", "taskStartDueTimeModeMismatch": "Set times for both start and due, or make the task all day.", "deleteCalendarConfirmation": "Delete \"{title}\"?", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8792429a..1c5b5c72 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,14 +1,14 @@ { "@@locale": "es", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Conecta cuentas de Google, Microsoft, Calendario de Apple iCloud o Nextcloud.", "googlePermissionsConsentNotice": "En la pantalla de permisos de Google, selecciona los permisos de Calendario y Tareas.", "googlePermissionsRequiredRetry": "Los permisos de Google Calendar y Google Tasks son obligatorios. Inténtalo de nuevo y selecciona ambas casillas.", "finishSetup": "Finalizar configuración", "continueSetup": "Continuar", "onboardingSetupTitle": "Configurar BusyMax", "onboardingAccountsStepTitle": "Conectar cuentas", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Añade todas las cuentas que quieras usar. BusyMax sincroniza los calendarios, eventos, listas de tareas y tareas compatibles de cada cuenta.", "onboardingPreferencesStepTitle": "Elegir ajustes del sistema", "onboardingPreferencesStepDescription": "Configura el comportamiento de la aplicación en el escritorio, los recordatorios, el nivel de detalle de las notificaciones y la apariencia antes de abrir tu agenda.", "signInWithGoogle": "Iniciar sesión con Google", @@ -35,17 +35,41 @@ "calendars": "Calendarios", "newCalendar": "Nuevo calendario", "calendarColor": "Color del calendario", - "calendarColorOption": "Color {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "calendarColorOption": "Opción de color {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "Este proveedor no admite la gestión de calendarios en BusyMax.", "primaryCalendarCannotDelete": "El calendario principal no se puede eliminar.", "calendarCreateFailed": "No se pudo crear el calendario: {error}", "calendarCreatedRefreshPending": "El calendario se creó, pero BusyMax no pudo actualizar la cuenta. Aparecerá después de la próxima sincronización.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "No se pudo actualizar el calendario: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "No se pudo eliminar el calendario: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Nuevo evento", "refreshCalendar": "Actualizar calendario", "openInProvider": "Abrir en el proveedor", @@ -65,38 +89,62 @@ "scheduleNoSearchResultsDescription": "Prueba con otra búsqueda o borra los filtros actuales.", "refresh": "Actualizar", "trayOpenBusyMax": "Abrir BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Mostrar BusyMax", + "trayNewEvent": "Evento nuevo…", + "trayNewTask": "Tarea nueva…", + "trayToday": "Hoy", + "trayAllDay": "Todo el día", + "trayNow": "Ahora", + "trayCalendarEvent": "Evento del calendario", + "trayUntitledEvent": "Evento sin título", + "trayNothingElseToday": "Nada más hoy", + "trayTasksDueToday": "{count, plural, =1{1 tarea vence hoy} other{{count} tareas vencen hoy}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Abrir la agenda de hoy", + "traySyncNow": "Sincronizar ahora", + "traySyncing": "Sincronizando…", + "trayNotConnected": "No conectado", + "trayNotYetSynced": "Aún no sincronizado", + "trayLastSyncedJustNow": "Sincronizado ahora mismo", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizado hace 1 minuto} other{Sincronizado hace {count} minutos}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizado hace 1 hora} other{Sincronizado hace {count} horas}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizado hace 1 día} other{Sincronizado hace {count} días}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Configuración", + "trayQuitBusyMax": "Salir de BusyMax", "agendaLoadMoreOverdue": "Cargar más tareas vencidas", "agendaLoadMoreNoDate": "Cargar más tareas sin fecha", "viewDay": "Día", "viewWeek": "Semana", "viewMonth": "Mes", "viewYear": "Año", - "viewAgenda": "Agenda", + "viewAgenda": "Vista de agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualización de agenda", "scheduleDisplayHoursDescription": "Las vistas de día y semana muestran inicialmente este intervalo horario. Los elementos anteriores o posteriores lo amplían cuando es necesario.", @@ -116,7 +164,7 @@ "doesNotRepeat": "No se repite", "defaultReminder": "Recordatorio predeterminado", "guests": "Invitados", - "noGuests": "Sin invitados", + "noGuests": "No hay invitados", "description": "Descripción", "availabilityShowAs": "Disponibilidad / Mostrar como", "busy": "Ocupado", @@ -164,8 +212,8 @@ "visibilityPublic": "Pública", "visibilityPrivate": "Privada", "visibilityConfidential": "Confidencial", - "sensitivityNormal": "Normal", - "sensitivityPersonal": "Personal", + "sensitivityNormal": "Habitual", + "sensitivityPersonal": "Personalizada", "tasks": "Tareas", "allTasks": "Todas las tareas", "tasksInList": "Tareas en {title}", @@ -173,7 +221,7 @@ "navigation": "Navegación", "mainMenu": "Menú principal", "keyboardShortcuts": "Atajos de teclado", - "shortcutGroupGeneral": "General", + "shortcutGroupGeneral": "Atajos generales", "shortcutKeyboardShortcutsDescription": "Mostrar esta referencia de atajos", "shortcutGroupNavigation": "Navegación", "shortcutNextPeriod": "Periodo siguiente", @@ -238,7 +286,7 @@ "removeAccount": "Eliminar cuenta…", "removingAccount": "Eliminando cuenta…", "removeAccountDescription": "Detener la sincronización y eliminar de este dispositivo los datos de esta cuenta.", - "removeAccountTitle": "¿Eliminar {account} de BusyMax?", + "removeAccountTitle": "¿Quitar {account} de BusyMax?", "@removeAccountTitle": { "placeholders": { "account": { @@ -246,13 +294,13 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Esto elimina de este dispositivo las tareas, calendarios, eventos, recordatorios y cambios sin conexión pendientes almacenados en caché. Se perderán los cambios no sincronizados. No se eliminan las copias de calendarios, eventos, listas de tareas ni tareas del proveedor.", "revokeGoogleAccess": "Revocar también el acceso de BusyMax a esta cuenta de Google", "revokeGoogleAccessDescription": "Tendrás que volver a conceder acceso antes de reconectar la cuenta.", "removeAccountAction": "Eliminar cuenta", "removeAccountFailed": "No se pudo terminar de eliminar la cuenta. Inténtalo de nuevo.", "accountRemovedGoogleRevokeFailed": "La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.", - "newTaskList": "Nueva lista de tareas", + "newTaskList": "Lista de tareas nueva", "signInToViewTaskLists": "Inicia sesión para ver las listas de tareas.", "noTaskListsSynced": "Aún no hay listas de tareas sincronizadas.", "listActions": "Acciones de lista", @@ -262,7 +310,7 @@ "deleteList": "Eliminar lista", "builtInMicrosoftList": "Integrada", "builtInMicrosoftListCannotRenameDelete": "Las listas integradas de Microsoft To Do no se pueden cambiar de nombre ni eliminar.", - "deleteListConfirmation": "¿Eliminar \"{title}\" de Google Tasks?", + "deleteListConfirmation": "¿Eliminar «{title}» de Google Tasks?", "deleteEvent": "Eliminar evento", "title": "Título", "create": "Crear", @@ -313,7 +361,7 @@ "primarySection": "Principal", "statusSection": "Estado", "openStatus": "Abierta", - "doneStatus": "Completada", + "doneStatus": "Hecha", "notes": "Notas", "dueDate": "Fecha de vencimiento", "clearDueDate": "Borrar fecha de vencimiento", @@ -339,7 +387,7 @@ "repeatYearly": "Anual", "importance": "Importancia", "importanceLow": "Baja", - "importanceNormal": "Normal", + "importanceNormal": "Media", "importanceHigh": "Alta", "categories": "Categorías", "scheduleSection": "Programación", @@ -389,7 +437,7 @@ "taskReminders": "Recordatorios de tareas", "notificationDetailLevel": "Nivel de detalle de las notificaciones", "notificationDetailPrivate": "Privado", - "notificationDetailNormal": "Normal", + "notificationDetailNormal": "Estándar", "quietHours": "Horario de silencio", "quietHoursDescription": "Pausar las notificaciones durante este período.", "quietHoursStart": "Inicio del horario de silencio", @@ -433,7 +481,7 @@ "syncFailureNotificationTitle": "Falló la sincronización de BusyMax", "syncFailureNotificationBody": "Falló la sincronización en segundo plano. {message}", "conflictNotificationTitle": "Conflicto de sincronización de BusyMax", - "conflictNotificationBody": "Se bloqueó un cambio local pendiente. {summary}", + "conflictNotificationBody": "Se ha bloqueado un cambio local pendiente. {summary}", "dueTodayNotificationTitle": "Tareas que vencen hoy", "dueTodayNotificationBody": "{count, plural, =1{Una tarea vence hoy.} other{{count} tareas vencen hoy.}}", "eventReminderNotificationTitle": "Recordatorio de evento", @@ -442,7 +490,7 @@ "taskReminderNotificationBody": "La tarea vence pronto.", "notificationOpenAction": "Abrir", "notificationSnoozeAction": "Posponer 10 minutos", - "notificationDismissAction": "Descartar", + "notificationDismissAction": "Cerrar", "notificationDetailsHidden": "Los detalles están ocultos por la configuración de privacidad.", "previousMonth": "Mes anterior", "nextMonth": "Mes siguiente", @@ -470,45 +518,45 @@ "readOnlyCalendar": "Este calendario es de solo lectura.", "selectTimeZone": "Seleccionar zona horaria", "searchLocations": "Buscar ubicaciones", - "noLocationsFound": "No se encontraron ubicaciones", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "noLocationsFound": "No se han encontrado ubicaciones", + "requiredField": "Este campo es obligatorio.", + "providerConnectionDescription": "Conecta calendarios y tareas de uno de estos proveedores.", + "appleICloudProvider": "Calendario de Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Tareas de Nextcloud", + "addAppleICloudAccount": "Añadir cuenta del Calendario de Apple iCloud", + "addNextcloudAccount": "Añadir cuenta de Nextcloud", + "waitingForAppleICloud": "Conectando con Apple iCloud…", + "waitingForNextcloud": "Esperando la autorización de Nextcloud…", + "connectAppleICloudTitle": "Conectar el Calendario de Apple iCloud", + "appleAccountEmail": "Correo de la cuenta de Apple", + "appleAppSpecificPassword": "Contraseña específica de la app", + "appleAppSpecificPasswordHelp": "Crea una contraseña específica de la app después de activar la autenticación de dos factores en tu cuenta de Apple.", + "appleAppSpecificPasswordResetWarning": "Restablecer la contraseña de tu cuenta de Apple revoca las contraseñas específicas de las apps.", + "connectNextcloudTitle": "Conectar Nextcloud", + "nextcloudServerUrl": "Servidor de Nextcloud o dirección CalDAV", + "nextcloudServerUrlHelp": "Introduce la URL de tu servidor Nextcloud o pega la dirección CalDAV principal copiada de Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax abrirá el navegador. Aprueba allí el acceso y vuelve a BusyMax.", + "connectAccountAction": "Conectar", + "cancelAccountConnection": "Cancelar conexión", + "nextcloudAccountRemovedRevokeFailed": "La cuenta se quitó localmente, pero no se pudo revocar la contraseña de app de Nextcloud.", + "davCachedOfflineNotice": "Los datos del calendario y las tareas se almacenan localmente para usarlos sin conexión.", + "davReauthenticationRequired": "Vuelve a conectar esta cuenta para reanudar la sincronización.", + "davTemporarilyUnavailable": "Esta cuenta no está disponible temporalmente.", + "davPermissionChanged": "Han cambiado los permisos del servidor. Las ediciones pendientes están en pausa.", + "davUnsupportedServer": "Este servidor o perfil de proveedor no es compatible.", + "collectionSettings": "Calendarios y listas de tareas", + "calendarContent": "Eventos del calendario", + "taskContent": "Tareas", + "readOnlySharedCollection": "Solo lectura", + "pendingLocally": "Pendiente localmente", + "conflictBlocked": "Bloqueado por un conflicto", + "authenticationBlocked": "Bloqueado hasta volver a conectar", + "operationFailed": "La operación ha fallado", + "keepServerVersion": "Conservar la versión del servidor", + "reapplyLocalChange": "Revisar y volver a aplicar el cambio local", + "duplicateLocalItem": "Duplicar como elemento nuevo", "deleteCalendarConfirmation": "¿Eliminar \"{title}\"?", "@deleteCalendarConfirmation": { "placeholders": { @@ -517,11 +565,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Estado de conexión", + "davConnected": "Conectado", + "davConnecting": "Conectando…", + "davSignedOut": "Sesión cerrada", + "davLastSuccessfulSync": "Última sincronización correcta: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -529,9 +577,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Aún no se ha sincronizado", + "refreshCollections": "Actualizar calendarios y listas de tareas", + "nextcloudServerHost": "Servidor: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -539,12 +587,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Calendario de eventos", + "collectionSupportsTasks": "Lista de tareas", + "collectionSupportsEventsAndTasks": "Eventos y tareas", + "writableCollection": "Editable", + "sharedCollection": "Compartido", + "collectionLastSynced": "Última sincronización: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -552,7 +600,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Problema de sincronización: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -560,8 +608,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Conflictos de sincronización", + "remoteChangedAt": "Cambio en el servidor: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -569,7 +617,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Edición local: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -577,9 +625,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "No se pudo resolver el conflicto.", + "recurringEventScope": "Ámbito del evento recurrente", + "entireSeries": "Serie completa", "singleOccurrence": "Este evento", "thisAndFollowingEvents": "Este evento y los siguientes", "thisAndFutureUnavailable": "No compatible con este proveedor.", @@ -588,139 +636,145 @@ "copyEventAndDeleteOriginal": "¿Copiar el evento y eliminar el original?", "copyEventMoveWarning": "BusyMax no puede mover este evento directamente de {source} a {destination}. Primero creará la copia y eliminará el original solo cuando la copia se complete correctamente. Los identificadores del evento cambiarán; los estados de respuesta de los asistentes pueden restablecerse y pueden enviarse invitaciones o cancelaciones; y es posible que no se conserven los enlaces de conferencia, los archivos adjuntos, los recordatorios, los campos específicos del proveedor ni las excepciones de repetición.", "copyAndDelete": "Copiar y eliminar", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Elige si este cambio se aplica a toda la serie, solo a este evento o a este y los siguientes eventos.", "taskDueBeforeStart": "El vencimiento no puede ser anterior al inicio.", "taskStartDueTimeModeMismatch": "Define una hora tanto para el inicio como para el vencimiento, o configura la tarea para todo el día.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "No compartir", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "taskListCreateFailed": "No se pudo crear la lista de tareas: {error}", + "taskListRenameFailed": "No se pudo cambiar el nombre de la lista de tareas: {error}", + "taskListDeleteFailed": "No se pudo eliminar la lista de tareas: {error}", + "unshare": "Dejar de compartir", + "readOnlyTaskListCannotRename": "Esta lista de tareas es de solo lectura y no se puede cambiar su nombre.", + "taskListCannotDelete": "No se puede eliminar esta lista de tareas con tus permisos actuales.", + "deleteTaskListConfirmation": "¿Eliminar «{title}» y todas sus tareas?", + "unshareTaskListConfirmation": "¿Dejar de compartir «{title}» con esta cuenta?", + "taskStatus": "Estado", + "taskStatusNone": "Sin estado", "taskStatusNeedsAction": "Necesita una acción", - "taskStatusInProcess": "En proceso", + "taskStatusInProcess": "En curso", "taskStatusCompleted": "Completada", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCancelled": "Cancelada", + "completionPercent": "{percent}% completada", + "completionDate": "Fecha de finalización", "priority": "Prioridad", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "Sin recordatorio", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Antes de empezar la tarea", - "beforeTaskDue": "Antes de terminar la tarea", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "priorityNone": "Sin prioridad", + "priorityHighValue": "Prioridad {priority} · Alta", + "priorityMediumValue": "Prioridad {priority} · Media", + "priorityLowValue": "Prioridad {priority} · Baja", + "taskUrl": "URL de la tarea", + "invalidTaskUrl": "Introduce una URL absoluta, incluido su esquema.", + "classification": "Clasificación", + "classificationPublic": "Al compartirla, mostrar la tarea completa", + "classificationConfidential": "Al compartirla, mostrar solo la disponibilidad", + "classificationPrivate": "Al compartirla, ocultar esta tarea", + "pinTask": "Fijar tarea", + "reminders": "Recordatorios", + "noReminders": "Sin recordatorios", + "editReminder": "Editar recordatorio", + "beforeTaskStarts": "Antes de que empiece la tarea", + "beforeTaskDue": "Antes del vencimiento de la tarea", + "afterTaskStarts": "Después de que empiece la tarea", + "afterTaskDue": "Después del vencimiento de la tarea", + "relativeToTaskStart": "En relación con la fecha de inicio de la tarea", + "relativeToTaskDue": "En relación con la fecha de vencimiento de la tarea", + "reminderTimeOfDay": "Hora del día", + "absoluteReminder": "En una fecha y hora", + "reminderAmount": "Cantidad", + "reminderUnitSeconds": "Segundos", + "reminderUnitMinutes": "Minutos", + "reminderUnitHours": "Horas", + "reminderUnitDays": "Días", + "reminderUnitWeeks": "Semanas", + "reminderAtTaskStart": "Al comenzar la tarea", + "reminderAtTaskDue": "Al vencer la tarea", + "unsupportedReminder": "Este tipo de recordatorio se conserva, pero no se puede editar su hora.", + "relatedRemindersTitle": "¿Conservar los recordatorios relacionados?", + "relatedRemindersDescription": "Esta fecha tiene {count} recordatorios relacionados. ¿Quieres conservarlos en su fecha y hora actuales?", "discardRelatedReminders": "Descartar recordatorios", - "keepRelatedReminders": "Mantener recordatorios", - "repeatEvery": "Repetir cada", - "repeatOn": "Repeat on", + "keepRelatedReminders": "Conservar recordatorios", + "repeatEvery": "Intervalo", + "repeatOn": "Repetir en", "repeatEnd": "Finalizar repetición", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "dentro de {days}", - "repeatOnMonthDaysSummary": "en día {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatNever": "Nunca", + "repeatUntil": "En una fecha", + "repeatAfter": "Después de un número de repeticiones", + "repeatCount": "Repeticiones", + "repeatDayOfMonth": "Días del mes", + "repeatMonths": "Meses", + "repeatOrdinal": "Posición del día de la semana", + "repeatSpecificDays": "Días específicos", + "repeatFirst": "Primero", + "repeatSecond": "Segundo", + "repeatThird": "Tercero", + "repeatFourth": "Cuarto", + "repeatFifth": "Quinto", + "repeatSecondToLast": "Penúltimo", + "repeatLast": "Último", + "repeatAnyDay": "Día", + "repeatWeekday": "Día laborable", + "repeatWeekendDay": "Día de fin de semana", + "repeatEveryDays": "Cada {count} días", + "repeatEveryWeeks": "Cada {count} semanas", + "repeatEveryMonths": "Cada {count} meses", + "repeatEveryYears": "Cada {count} años", + "repeatOnDaysSummary": "los {days}", + "repeatOnMonthDaysSummary": "el día {days}", + "repeatOnOrdinalSummary": "el {ordinal} {days}", "repeatInMonthsSummary": "en {months}", "repeatTimesSummary": "{count} veces", - "repeatUntilSummary": "hasta el {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatUntilSummary": "hasta {date}", + "unsupportedRecurrencePreserved": "Esta regla de repetición usa opciones que este editor no modifica.", "recurrenceUnsupportedByProvider": "Esta repetición no se puede usar con {provider}.", "duplicateTask": "Duplicar tarea", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "Tarea duplicada.", + "taskDuplicateFailed": "No se pudo duplicar la tarea: {error}", "hideSubtasks": "Ocultar subtareas", "hideClosedSubtasks": "Ocultar subtareas cerradas", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "Unidad", + "attendeeRequired": "Obligatorio", + "attendeeOptional": "Opcional", + "meetingSection": "Reunión", + "addGoogleMeet": "Añadir Google Meet", + "addTeamsMeeting": "Añadir reunión de Microsoft Teams", + "onlineMeetingAdded": "Reunión en línea añadida", + "requestResponses": "Solicitar respuestas", + "requestResponsesDescription": "Pide a los invitados que respondan a la invitación.", + "hideGuestList": "Ocultar la lista de invitados", + "hideGuestListDescription": "Los invitados no pueden ver quién más ha sido invitado.", + "allowNewTimeProposals": "Permitir nuevas propuestas de horario", + "allowNewTimeProposalsDescription": "Los invitados pueden sugerir otra hora para la reunión.", + "notifyGuestsTitle": "¿Notificar a los invitados?", + "notifyGuestsSaveMessage": "Esta reunión tiene invitados. ¿Enviar invitaciones o actualizaciones del evento al guardarla?", + "notifyGuestsDeleteMessage": "Esta reunión tiene invitados. ¿Enviar una cancelación al eliminarla?", + "sendUpdates": "Enviar actualizaciones", + "sendCancellation": "Enviar cancelación", + "doNotSend": "No enviar", + "microsoftNotifyGuestsSaveTitle": "¿Guardar la reunión?", + "microsoftNotifyGuestsSaveMessage": "Microsoft enviará invitaciones o actualizaciones del evento a los invitados.", + "microsoftNotifyGuestsDeleteTitle": "¿Eliminar la reunión?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft enviará una cancelación a los invitados.", + "organizer": "Organizador", + "yourResponse": "Tu respuesta", + "guestResponses": "Respuestas de los invitados", + "respond": "Responder", + "acceptInvitation": "Aceptar", + "tentativeInvitation": "Provisional", + "declineInvitation": "Rechazar", + "joinMeeting": "Unirse a la reunión", + "responseAccepted": "Aceptada", + "responseTentative": "Provisional", + "responseDeclined": "Rechazada", + "responseNeedsAction": "Esperando respuesta", + "responseNotResponded": "Sin respuesta", + "responseOrganizer": "Organizador", + "invitationResponseFailed": "No se pudo enviar tu respuesta: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "No se pudo abrir el enlace de la reunión.", "setCustomCalendarName": "Establecer nombre personalizado", "setAction": "Establecer", "removeFromMyCalendars": "Quitar de mis calendarios", @@ -728,64 +782,142 @@ "removeCalendarConfirmation": "¿Quitar \"{title}\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.", "calendarCannotRemove": "Este calendario no se puede eliminar ni quitar de esta cuenta.", "calendarPendingChangesPreventRemoval": "Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "Importar calendario", - "calendarImportDescription": "Selecciona un archivo, revisa sus eventos y elige el calendario editable que los recibirá.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarSubscriptions": "Suscripciones de calendarios", + "calendarSubscriptionsDescription": "Añade calendarios de solo lectura que se actualicen desde una URL WebCal segura.", + "addCalendarSubscription": "Añadir suscripción de calendario", + "subscriptionName": "Nombre local", + "subscriptionUrl": "URL de la suscripción", + "subscriptionUrlHelp": "Introduce una URL HTTPS o webcal. BusyMax guarda la URL completa en un almacenamiento seguro.", + "subscriptionUrlInvalid": "Introduce una URL HTTPS o webcal válida sin información de usuario ni fragmento.", + "subscriptionColor": "Color local", + "subscriptionColorHelp": "Usa un color de seis dígitos, como #3584E4.", + "subscriptionColorInvalid": "Introduce un color hexadecimal de seis dígitos.", + "subscriptionRefreshMode": "Frecuencia de actualización", + "subscriptionAutomatic": "Automática", + "subscriptionHourly": "Cada hora", + "subscriptionSixHours": "Cada seis horas", + "subscriptionDaily": "Diaria", + "subscriptionSafeOrigin": "Origen: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Introduce una URL válida para previsualizar su origen seguro.", + "subscriptionReadOnly": "Suscripción de solo lectura", + "subscriptionNeverRefreshed": "Aún no se ha actualizado", + "subscriptionLastRefresh": "Última actualización correcta: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Próxima actualización: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Actualizada", + "subscriptionStatusIssue": "Problema de actualización: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Actualizar ahora", + "unsubscribe": "Cancelar suscripción", + "unsubscribeCalendarTitle": "¿Cancelar la suscripción a «{name}»?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Esto elimina la suscripción local y sus eventos almacenados en caché. El calendario publicado no cambia.", + "addSubscriptionAction": "Añadir suscripción", + "subscriptionOperationFailed": "La suscripción al calendario ha fallado: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Suscripciones", + "calendarImport": "Importación de calendario", + "calendarImportDescription": "Selecciona un archivo, revisa sus eventos y elige el calendario editable que debe recibirlos.", + "importIcsFile": "Importar archivo .ics", + "importIcsPreview": "Importar eventos del calendario", + "importEventsFound": "Conjuntos de eventos importables: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Eventos no válidos: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Omitidos intencionadamente: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "No hay ningún calendario de destino editable disponible.", + "importDestinationCalendar": "Calendario de destino", + "importIcsConfirm": "Importar eventos", + "importIcsComplete": "Importación completada", + "importQueued": "Importados o en cola: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Duplicados omitidos: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Conjuntos de repeticiones no compatibles: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "No se pudo importar el archivo de calendario: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Sin conexión", "networkOfflineDescription": "Los cambios se sincronizarán cuando se restablezca la conexión.", "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo." diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 3d92123a..00179934 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,14 +1,14 @@ { "@@locale": "et", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Ühenda Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.", "googlePermissionsConsentNotice": "Valige Google'i õiguste kuval nii kalendri kui ka ülesannete õigused.", "googlePermissionsRequiredRetry": "Google Calendari ja Google Tasksi õigused on nõutavad. Proovige uuesti ja märkige mõlemad ruudud.", "finishSetup": "Lõpeta seadistamine", "continueSetup": "Jätka", "onboardingSetupTitle": "BusyMaxi seadistamine", "onboardingAccountsStepTitle": "Kontode ühendamine", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Lisa kõik kontod, mida soovid kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.", "onboardingPreferencesStepTitle": "Süsteemiseadete valimine", "onboardingPreferencesStepDescription": "Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.", "signInWithGoogle": "Logi Google'iga sisse", @@ -36,16 +36,40 @@ "newCalendar": "Uus kalender", "calendarColor": "Kalendri värv", "calendarColorOption": "Värv {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "See teenusepakkuja ei toeta BusyMaxis kalendrite haldamist.", "primaryCalendarCannotDelete": "Põhikalendrit ei saa kustutada.", "calendarCreateFailed": "Kalendri loomine nurjus: {error}", "calendarCreatedRefreshPending": "Kalender loodi, kuid BusyMax ei saanud kontot värskendada. See ilmub pärast järgmist sünkroonimist.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Kalendri värskendamine nurjus: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Kalendri kustutamine nurjus: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Uus sündmus", "refreshCalendar": "Värskenda kalendrit", "openInProvider": "Ava teenuses", @@ -72,31 +96,55 @@ "scheduleNoSearchResultsDescription": "Proovige teistsugust otsingut või eemaldage praegused filtrid.", "refresh": "Värskenda", "trayOpenBusyMax": "Ava BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Näita BusyMaxi", + "trayNewEvent": "Uus sündmus…", + "trayNewTask": "Uus ülesanne…", + "trayToday": "Täna", + "trayAllDay": "Kogu päev", + "trayNow": "Praegu", + "trayCalendarEvent": "Kalendrisündmus", + "trayUntitledEvent": "Pealkirjata sündmus", + "trayNothingElseToday": "Täna pole rohkem midagi", + "trayTasksDueToday": "{count, plural, =1{Täna on tähtajaks 1 ülesanne} other{Täna on tähtajaks {count} ülesannet}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Ava tänane päevakava", + "traySyncNow": "Sünkrooni kohe", + "traySyncing": "Sünkroonimine…", + "trayNotConnected": "Pole ühendatud", + "trayNotYetSynced": "Veel sünkroonimata", + "trayLastSyncedJustNow": "Viimati sünkrooniti just nüüd", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Viimati sünkrooniti 1 minut tagasi} other{Viimati sünkrooniti {count} minutit tagasi}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Viimati sünkrooniti 1 tund tagasi} other{Viimati sünkrooniti {count} tundi tagasi}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Viimati sünkrooniti 1 päev tagasi} other{Viimati sünkrooniti {count} päeva tagasi}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Seaded", + "trayQuitBusyMax": "Välju BusyMaxist", "agendaLoadMoreOverdue": "Laadi veel tähtaja ületanud ülesandeid", "agendaLoadMoreNoDate": "Laadi veel kuupäevata ülesandeid", "viewDay": "Päev", @@ -267,7 +315,7 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "See kustutab sellest seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja ootel võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Teenusepakkuja kalendrite, sündmuste, ülesandeloendite ja ülesannete koopiaid ei kustutata.", "revokeGoogleAccess": "Tühista ka BusyMaxi juurdepääs sellele Google'i kontole", "revokeGoogleAccessDescription": "Enne uuesti ühendamist peate juurdepääsu uuesti andma.", "removeAccountAction": "Eemalda konto", @@ -365,7 +413,7 @@ "primarySection": "Põhiteave", "statusSection": "Olek", "openStatus": "Pooleli", - "doneStatus": "Valmis", + "doneStatus": "Tehtud", "notes": "Märkmed", "dueDate": "Tähtaeg", "clearDueDate": "Eemalda tähtaeg", @@ -557,7 +605,7 @@ "taskReminderNotificationBody": "Ülesande tähtaeg on varsti.", "notificationOpenAction": "Ava", "notificationSnoozeAction": "Lükka 10 minutit edasi", - "notificationDismissAction": "Loobu", + "notificationDismissAction": "Sulge", "notificationDetailsHidden": "Üksikasjad on privaatsusseadete tõttu peidetud.", "previousMonth": "Eelmine kuu", "nextMonth": "Järgmine kuu", @@ -586,44 +634,44 @@ "selectTimeZone": "Valige ajavöönd", "searchLocations": "Otsi asukohti", "noLocationsFound": "Asukohti ei leitud", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "See väli on kohustuslik.", + "providerConnectionDescription": "Ühenda kalendrid ja ülesanded ühega neist teenusepakkujatest.", + "appleICloudProvider": "Apple iCloudi kalender", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloudi ülesanded", + "addAppleICloudAccount": "Lisa Apple iCloud Calendari konto", + "addNextcloudAccount": "Lisa Nextcloudi konto", + "waitingForAppleICloud": "Apple iCloudiga ühendamine…", + "waitingForNextcloud": "Nextcloudi autoriseerimise ootel…", + "connectAppleICloudTitle": "Ühenda Apple iCloud Calendar", + "appleAccountEmail": "Apple’i konto e-post", + "appleAppSpecificPassword": "Rakendusepõhine parool", + "appleAppSpecificPasswordHelp": "Loo rakendusepõhine parool pärast Apple’i konto kaheastmelise autentimise lubamist.", + "appleAppSpecificPasswordResetWarning": "Apple’i konto parooli lähtestamine tühistab rakendusepõhised paroolid.", + "connectNextcloudTitle": "Ühenda Nextcloud", + "nextcloudServerUrl": "Nextcloudi server või CalDAV-aadress", + "nextcloudServerUrlHelp": "Sisesta Nextcloudi serveri URL või kleebi Nextcloudist kopeeritud peamine CalDAV-aadress.", + "nextcloudBrowserAuthorizationHelp": "BusyMax avab brauseri. Kinnita juurdepääs seal ja naase seejärel BusyMaxi.", + "connectAccountAction": "Ühenda", + "cancelAccountConnection": "Tühista ühendus", + "nextcloudAccountRemovedRevokeFailed": "Konto eemaldati kohalikult, kuid Nextcloudi rakenduse parooli ei saanud tühistada.", + "davCachedOfflineNotice": "Kalendri- ja ülesandeandmed salvestatakse võrguühenduseta kasutamiseks kohapeal.", + "davReauthenticationRequired": "Sünkroonimise jätkamiseks ühenda see konto uuesti.", + "davTemporarilyUnavailable": "See konto pole ajutiselt saadaval.", + "davPermissionChanged": "Serveriõigused muutusid. Ootel muudatused on peatatud.", + "davUnsupportedServer": "Seda serverit või teenusepakkuja profiili ei toetata.", + "collectionSettings": "Kalendrid ja ülesandeloendid", + "calendarContent": "Kalendrisündmused", + "taskContent": "Ülesanded", + "readOnlySharedCollection": "Kirjutuskaitstud", + "pendingLocally": "Kohapeal ootel", + "conflictBlocked": "Konflikti tõttu blokeeritud", + "authenticationBlocked": "Blokeeritud kuni taasühendamiseni", + "operationFailed": "Toiming nurjus", + "keepServerVersion": "Säilita serveri versioon", + "reapplyLocalChange": "Vaata kohalik muudatus üle ja rakenda uuesti", + "duplicateLocalItem": "Dubleeri uue üksusena", "deleteCalendarConfirmation": "Kas kustutada „{title}”?", "@deleteCalendarConfirmation": { "placeholders": { @@ -632,11 +680,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Ühenduse olek", + "davConnected": "Ühendatud", + "davConnecting": "Ühendamine…", + "davSignedOut": "Välja logitud", + "davLastSuccessfulSync": "Viimane õnnestunud sünkroonimine: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -644,9 +692,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Veel sünkroonimata", + "refreshCollections": "Värskenda kalendreid ja ülesandeloendeid", + "nextcloudServerHost": "Serveri aadress: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -654,12 +702,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Sündmuste kalender", + "collectionSupportsTasks": "Ülesandeloend", + "collectionSupportsEventsAndTasks": "Sündmused ja ülesanded", + "writableCollection": "Kirjutatav", + "sharedCollection": "Jagatud", + "collectionLastSynced": "Viimati sünkroonitud: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -667,7 +715,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Sünkroonimisprobleem: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -675,8 +723,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Sünkroonimiskonfliktid", + "remoteChangedAt": "Server muutis: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -684,7 +732,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Kohalik muudatus: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -692,9 +740,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "Konflikti ei saanud lahendada.", + "recurringEventScope": "Korduva sündmuse ulatus", + "entireSeries": "Kogu sari", "singleOccurrence": "See sündmus", "thisAndFollowingEvents": "See ja järgmised sündmused", "thisAndFutureUnavailable": "See teenusepakkuja ei toeta seda.", @@ -703,204 +751,288 @@ "copyEventAndDeleteOriginal": "Kas kopeerida sündmus ja kustutada algne?", "copyEventMoveWarning": "BusyMax ei saa seda sündmust otse kalendrist {source} kalendrisse {destination} teisaldada. Esmalt luuakse koopia ja algne sündmus kustutatakse alles pärast edukat kopeerimist. Sündmuse ID-d muutuvad; osalejate vastuseolekud võivad lähtestuda ning võidakse saata kutseid või tühistusi; konverentsilingid, manused, meeldetuletused, teenusepakkuja väljad ja korduse erandid ei pruugi üle kanduda.", "copyAndDelete": "Kopeeri ja kustuta", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Vali, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.", "taskDueBeforeStart": "Tähtaeg ei tohi olla enne algust.", "taskStartDueTimeModeMismatch": "Määra nii algus- kui ka tähtaja kellaaeg või tee ülesanne kogu päeva ülesandeks.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Unshare", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "Completed", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "Priority", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Repeat every", - "repeatOn": "Repeat on", - "repeatEnd": "End repeat", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} times", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "taskListCreateFailed": "Ülesandeloendi loomine ebaõnnestus: {error}", + "taskListRenameFailed": "Ülesandeloendi ümbernimetamine ebaõnnestus: {error}", + "taskListDeleteFailed": "Ülesandeloendi kustutamine ebaõnnestus: {error}", + "unshare": "Lõpeta jagamine", + "readOnlyTaskListCannotRename": "See ülesandeloend on kirjutuskaitstud ja seda ei saa ümber nimetada.", + "taskListCannotDelete": "Seda ülesandeloendit ei saa praeguste õigustega kustutada.", + "deleteTaskListConfirmation": "Kas kustutada „{title}” ja kõik selle ülesanded?", + "unshareTaskListConfirmation": "Kas lõpetada „{title}” jagamine selle kontoga?", + "taskStatus": "Olek", + "taskStatusNone": "Olek puudub", + "taskStatusNeedsAction": "Vajab tegevust", + "taskStatusInProcess": "Töös", + "taskStatusCompleted": "Lõpetatud", + "taskStatusCancelled": "Tühistatud", + "completionPercent": "Lõpetatud {percent}%", + "completionDate": "Lõpetamise kuupäev", + "priority": "Prioriteet", + "priorityNone": "Prioriteet puudub", + "priorityHighValue": "Prioriteet {priority} · kõrge", + "priorityMediumValue": "Prioriteet {priority} · keskmine", + "priorityLowValue": "Prioriteet {priority} · madal", + "taskUrl": "Ülesande URL", + "invalidTaskUrl": "Sisesta absoluutne URL koos skeemiga.", + "classification": "Klassifikatsioon", + "classificationPublic": "Jagamisel näita kogu ülesannet", + "classificationConfidential": "Jagamisel näita ainult hõivatust", + "classificationPrivate": "Jagamisel peida see ülesanne", + "pinTask": "Kinnita ülesanne", + "reminders": "Meeldetuletused", + "noReminders": "Meeldetuletusi pole", + "editReminder": "Muuda meeldetuletust", + "beforeTaskStarts": "Enne ülesande algust", + "beforeTaskDue": "Enne ülesande tähtaega", + "afterTaskStarts": "Pärast ülesande algust", + "afterTaskDue": "Pärast ülesande tähtaega", + "relativeToTaskStart": "Ülesande alguskuupäeva suhtes", + "relativeToTaskDue": "Ülesande tähtaja suhtes", + "reminderTimeOfDay": "Kellaaeg", + "absoluteReminder": "Kuupäeval ja kellaajal", + "reminderAmount": "Kogus", + "reminderUnitSeconds": "Sekundit", + "reminderUnitMinutes": "Minutit", + "reminderUnitHours": "Tundi", + "reminderUnitDays": "Päeva", + "reminderUnitWeeks": "Nädalat", + "reminderAtTaskStart": "Ülesande alguses", + "reminderAtTaskDue": "Ülesande tähtajal", + "unsupportedReminder": "See meeldetuletuse tüüp säilitatakse, kuid selle aega ei saa muuta.", + "relatedRemindersTitle": "Kas säilitada seotud meeldetuletused?", + "relatedRemindersDescription": "Sellel kuupäeval on {count} seotud meeldetuletust. Kas säilitada need praegusel kuupäeval ja kellaajal?", + "discardRelatedReminders": "Hülga meeldetuletused", + "keepRelatedReminders": "Säilita meeldetuletused", + "repeatEvery": "Kordusintervall", + "repeatOn": "Kordub", + "repeatEnd": "Lõpeta kordus", + "repeatNever": "Mitte kunagi", + "repeatUntil": "Kuupäeval", + "repeatAfter": "Pärast korduste arvu täitumist", + "repeatCount": "Korduste arv", + "repeatDayOfMonth": "Kuu päevad", + "repeatMonths": "Kuud", + "repeatOrdinal": "Nädalapäeva järjekord", + "repeatSpecificDays": "Kindlad päevad", + "repeatFirst": "Esimene", + "repeatSecond": "Teine", + "repeatThird": "Kolmas", + "repeatFourth": "Neljas", + "repeatFifth": "Viies", + "repeatSecondToLast": "Eelviimane", + "repeatLast": "Viimane", + "repeatAnyDay": "Päev", + "repeatWeekday": "Tööpäev", + "repeatWeekendDay": "Nädalavahetuse päev", + "repeatEveryDays": "Iga {count} päeva järel", + "repeatEveryWeeks": "Iga {count} nädala järel", + "repeatEveryMonths": "Iga {count} kuu järel", + "repeatEveryYears": "Iga {count} aasta järel", + "repeatOnDaysSummary": "päevadel {days}", + "repeatOnMonthDaysSummary": "kuu {days}. päeval", + "repeatOnOrdinalSummary": "{ordinal} {days}", + "repeatInMonthsSummary": "kuudel {months}", + "repeatTimesSummary": "{count} korda", + "repeatUntilSummary": "kuni kuupäevani {date}", + "unsupportedRecurrencePreserved": "See kordusreegel kasutab valikuid, mida see redaktor ei muuda.", "recurrenceUnsupportedByProvider": "Seda kordust ei saa teenusega {provider} kasutada.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "duplicateTask": "Loo ülesandest koopia", + "taskDuplicated": "Ülesanne dubleeriti.", + "taskDuplicateFailed": "Ülesande dubleerimine ebaõnnestus: {error}", + "hideSubtasks": "Peida alamülesanded", + "hideClosedSubtasks": "Peida lõpetatud alamülesanded", + "reminderUnit": "Ühik", + "attendeeRequired": "Nõutud", + "attendeeOptional": "Valikuline", + "meetingSection": "Koosolek", + "addGoogleMeet": "Lisa Google Meet", + "addTeamsMeeting": "Lisa Microsoft Teamsi koosolek", + "onlineMeetingAdded": "Veebikoosolek lisatud", + "requestResponses": "Küsi vastuseid", + "requestResponsesDescription": "Palu külalistel kutsele vastata.", + "hideGuestList": "Peida külaliste loend", + "hideGuestListDescription": "Külalised ei näe, keda veel kutsuti.", + "allowNewTimeProposals": "Luba uusi ajapakkumisi", + "allowNewTimeProposalsDescription": "Külalised saavad soovitada muud koosolekuaega.", + "notifyGuestsTitle": "Kas teavitada külalisi?", + "notifyGuestsSaveMessage": "Sellel koosolekul on külalised. Kas saata salvestamisel kutsed või sündmuse uuendused?", + "notifyGuestsDeleteMessage": "Sellel koosolekul on külalised. Kas saata kustutamisel tühistus?", + "sendUpdates": "Saada uuendused", + "sendCancellation": "Saada tühistus", + "doNotSend": "Ära saada", + "microsoftNotifyGuestsSaveTitle": "Kas salvestada koosolek?", + "microsoftNotifyGuestsSaveMessage": "Microsoft saadab külalistele kutsed või sündmuse uuendused.", + "microsoftNotifyGuestsDeleteTitle": "Kas kustutada koosolek?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft saadab külalistele tühistuse.", + "organizer": "Korraldaja", + "yourResponse": "Sinu vastus", + "guestResponses": "Külaliste vastused", + "respond": "Vasta", + "acceptInvitation": "Nõustu", + "tentativeInvitation": "Esialgne", + "declineInvitation": "Keeldu", + "joinMeeting": "Liitu koosolekuga", + "responseAccepted": "Nõustutud", + "responseTentative": "Esialgne", + "responseDeclined": "Keeldutud", + "responseNeedsAction": "Vastust oodatakse", + "responseNotResponded": "Vastus puudub", + "responseOrganizer": "Korraldaja", + "invitationResponseFailed": "Sinu vastust ei saanud saata: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Koosolekulinki ei saanud avada.", "setCustomCalendarName": "Määra kohandatud nimi", "setAction": "Määra", "removeFromMyCalendars": "Eemalda minu kalendritest", "removeAction": "Eemalda", "removeCalendarConfirmation": "Kas eemaldada „{title}” sinu Google'i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", - "calendarCannotRemove": "Seda kalendrit ei saa sellelt kontolt kustutada ega eemaldada.", + "calendarCannotRemove": "Seda kalendrit ei saa sellest kontost kustutada ega eemaldada.", "calendarPendingChangesPreventRemoval": "Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "Kalendri importimine", - "calendarImportDescription": "Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need imporditakse.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarSubscriptions": "Kalendritellimused", + "calendarSubscriptionsDescription": "Lisa kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.", + "addCalendarSubscription": "Lisa kalendritellimus", + "subscriptionName": "Kohalik nimi", + "subscriptionUrl": "Tellimuse URL", + "subscriptionUrlHelp": "Sisesta HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.", + "subscriptionUrlInvalid": "Sisesta kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.", + "subscriptionColor": "Kohalik värv", + "subscriptionColorHelp": "Kasuta kuuekohalist värvi, näiteks #3584E4.", + "subscriptionColorInvalid": "Sisesta kuuekohaline kuueteistkümnendsüsteemi värv.", + "subscriptionRefreshMode": "Värskendussagedus", + "subscriptionAutomatic": "Automaatne", + "subscriptionHourly": "Iga tunni järel", + "subscriptionSixHours": "Iga kuue tunni järel", + "subscriptionDaily": "Iga päev", + "subscriptionSafeOrigin": "Allikas: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Turvalise allika eelvaateks sisesta kehtiv URL.", + "subscriptionReadOnly": "Kirjutuskaitstud tellimus", + "subscriptionNeverRefreshed": "Veel värskendamata", + "subscriptionLastRefresh": "Viimane edukas värskendus: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Järgmine värskendus: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Ajaga kaasas", + "subscriptionStatusIssue": "Värskendusprobleem: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Värskenda kohe", + "unsubscribe": "Tühista tellimus", + "unsubscribeCalendarTitle": "Kas tühistada „{name}” tellimus?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "See eemaldab kohaliku tellimuse ja selle vahemällu salvestatud sündmused. Avaldatud kalendrit ei muudeta.", + "addSubscriptionAction": "Lisa tellimus", + "subscriptionOperationFailed": "Kalendritellimus nurjus: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Tellimused", + "calendarImport": "Kalendri import", + "calendarImportDescription": "Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need lisada.", + "importIcsFile": "Impordi .ics-fail", + "importIcsPreview": "Impordi kalendrisündmused", + "importEventsFound": "Imporditavad sündmusekomplektid: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Sobimatud sündmused: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Tahtlikult välja jäetud: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Sobivat kirjutatavat sihtkalendrit pole.", + "importDestinationCalendar": "Sihtkalender", + "importIcsConfirm": "Impordi sündmused", + "importIcsComplete": "Import lõpetatud", + "importQueued": "Imporditud või järjekorda lisatud: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Vahele jäetud duplikaadid: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Toetamata korduskomplektid: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Kalendrifaili ei saanud importida: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Võrguühenduseta", "networkOfflineDescription": "Muudatused sünkroonitakse ühenduse taastumisel.", "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti." diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 2f149d26..b999a5f3 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,14 +1,14 @@ { "@@locale": "fa", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "حساب‌های Google، Microsoft، Apple iCloud Calendar یا Nextcloud را متصل کنید.", "googlePermissionsConsentNotice": "در صفحهٔ مجوزهای Google، مجوزهای تقویم و کارها را هر دو انتخاب کنید.", "googlePermissionsRequiredRetry": "مجوزهای Google Calendar و Google Tasks لازم هستند. دوباره تلاش کنید و هر دو کادر را علامت بزنید.", "finishSetup": "پایان راه‌اندازی", "continueSetup": "ادامه", "onboardingSetupTitle": "راه‌اندازی BusyMax", "onboardingAccountsStepTitle": "اتصال حساب‌ها", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "همهٔ حساب‌هایی را که می‌خواهید استفاده کنید اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای پشتیبانی‌شده را از هر حساب همگام می‌کند.", "onboardingPreferencesStepTitle": "انتخاب تنظیمات سیستم", "onboardingPreferencesStepDescription": "پیش از باز کردن برنامه، رفتار برنامه روی میزکار، یادآورها، سطح جزئیات اعلان‌ها و ظاهر را تنظیم کنید.", "signInWithGoogle": "ورود با Google", @@ -36,16 +36,40 @@ "newCalendar": "تقویم جدید", "calendarColor": "رنگ تقویم", "calendarColorOption": "رنگ {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "این ارائه‌دهنده از مدیریت تقویم در BusyMax پشتیبانی نمی‌کند.", "primaryCalendarCannotDelete": "تقویم اصلی را نمی‌توان حذف کرد.", "calendarCreateFailed": "ایجاد تقویم ممکن نشد: {error}", "calendarCreatedRefreshPending": "تقویم ایجاد شد، اما BusyMax نتوانست حساب را تازه‌سازی کند. پس از همگام‌سازی بعدی نمایش داده می‌شود.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "به‌روزرسانی تقویم ممکن نشد: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "حذف تقویم ممکن نشد: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "رویداد جدید", "refreshCalendar": "تازه‌سازی تقویم", "openInProvider": "باز کردن در سرویس", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "جست‌وجوی دیگری را امتحان کنید یا پالایه‌های فعلی را پاک کنید.", "refresh": "تازه‌سازی", "trayOpenBusyMax": "باز کردن BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "نمایش BusyMax", + "trayNewEvent": "رویداد جدید…", + "trayNewTask": "کار جدید…", + "trayToday": "امروز", + "trayAllDay": "تمام‌روز", + "trayNow": "اکنون", + "trayCalendarEvent": "رویداد تقویم", + "trayUntitledEvent": "رویداد بدون عنوان", + "trayNothingElseToday": "امروز مورد دیگری نیست", + "trayTasksDueToday": "{count, plural, =1{۱ کار امروز سررسید دارد} other{{count} کار امروز سررسید دارند}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "باز کردن برنامهٔ امروز", + "traySyncNow": "همگام‌سازی اکنون", + "traySyncing": "در حال همگام‌سازی…", + "trayNotConnected": "متصل نیست", + "trayNotYetSynced": "هنوز همگام نشده", + "trayLastSyncedJustNow": "همین الان همگام شد", + "trayLastSyncedMinutesAgo": "{count, plural, =1{۱ دقیقه پیش همگام شد} other{{count} دقیقه پیش همگام شد}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{۱ ساعت پیش همگام شد} other{{count} ساعت پیش همگام شد}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{۱ روز پیش همگام شد} other{{count} روز پیش همگام شد}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "تنظیمات", + "trayQuitBusyMax": "خروج از BusyMax", "agendaLoadMoreOverdue": "بارگیری کارهای عقب‌افتادهٔ بیشتر", "agendaLoadMoreNoDate": "بارگیری کارهای بدون تاریخ بیشتر", "viewDay": "روز", @@ -116,7 +164,7 @@ "doesNotRepeat": "تکرار نمی‌شود", "defaultReminder": "یادآور پیش‌فرض", "guests": "مهمانان", - "noGuests": "بدون مهمان", + "noGuests": "مهمانی وجود ندارد", "description": "توضیحات", "availabilityShowAs": "وضعیت دسترسی / نمایش به‌عنوان", "busy": "مشغول", @@ -217,8 +265,8 @@ "removeAccount": "حذف حساب…", "removingAccount": "در حال حذف حساب…", "removeAccountDescription": "همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.", - "removeAccountTitle": "حذف ⁨{account}⁩ از BusyMax؟", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "{account} از BusyMax حذف شود؟", + "removeAccountConfirmation": "این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار را که در این دستگاه ذخیره شده‌اند حذف می‌کند. تغییرات همگام‌نشده از بین می‌روند. نسخه‌های تقویم‌ها، رویدادها، فهرست‌های کار و کارها نزد ارائه‌دهنده حذف نمی‌شوند.", "revokeGoogleAccess": "دسترسی BusyMax به این حساب Google نیز لغو شود", "revokeGoogleAccessDescription": "پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.", "removeAccountAction": "حذف حساب", @@ -234,7 +282,7 @@ "deleteList": "حذف فهرست", "builtInMicrosoftList": "داخلی", "builtInMicrosoftListCannotRenameDelete": "فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.", - "deleteListConfirmation": "«⁨{title}⁩» از Google Tasks حذف شود؟", + "deleteListConfirmation": "‏«{title}» از Google Tasks حذف شود؟", "deleteEvent": "حذف رویداد", "title": "عنوان", "create": "ایجاد", @@ -391,7 +439,7 @@ "syncFailureNotificationTitle": "همگام‌سازی BusyMax ناموفق بود", "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. ⁨{message}⁩", "conflictNotificationTitle": "تداخل همگام‌سازی BusyMax", - "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. ⁨{summary}⁩", + "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. {summary}", "dueTodayNotificationTitle": "کارهای دارای سررسید امروز", "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز ⁨{count}⁩ کار سررسید دارند.}}", "eventReminderNotificationTitle": "یادآور رویداد", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "سررسید کار نزدیک است.", "notificationOpenAction": "باز کردن", "notificationSnoozeAction": "تعویق ۱۰ دقیقه‌ای", - "notificationDismissAction": "رد کردن", + "notificationDismissAction": "بستن", "notificationDetailsHidden": "جزئیات به‌دلیل تنظیمات حریم خصوصی پنهان شده‌اند.", "previousMonth": "ماه قبل", "nextMonth": "ماه بعد", @@ -415,44 +463,44 @@ "selectTimeZone": "انتخاب منطقهٔ زمانی", "searchLocations": "جست‌وجوی مکان‌ها", "noLocationsFound": "مکانی پیدا نشد", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "این فیلد الزامی است.", + "providerConnectionDescription": "تقویم‌ها و کارها را از یکی از این ارائه‌دهندگان متصل کنید.", + "appleICloudProvider": "تقویم Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "کارهای Nextcloud", + "addAppleICloudAccount": "افزودن حساب تقویم Apple iCloud", + "addNextcloudAccount": "افزودن حساب Nextcloud", + "waitingForAppleICloud": "در حال اتصال به Apple iCloud…", + "waitingForNextcloud": "در انتظار تأیید Nextcloud…", + "connectAppleICloudTitle": "اتصال تقویم Apple iCloud", + "appleAccountEmail": "ایمیل حساب Apple", + "appleAppSpecificPassword": "گذرواژهٔ اختصاصی برنامه", + "appleAppSpecificPasswordHelp": "پس از فعال کردن احراز هویت دومرحله‌ای برای حساب Apple، یک گذرواژهٔ اختصاصی برنامه بسازید.", + "appleAppSpecificPasswordResetWarning": "بازنشانی گذرواژهٔ حساب Apple، گذرواژه‌های اختصاصی برنامه را باطل می‌کند.", + "connectNextcloudTitle": "اتصال Nextcloud", + "nextcloudServerUrl": "سرور Nextcloud یا نشانی CalDAV", + "nextcloudServerUrlHelp": "URL سرور Nextcloud خود را وارد کنید یا نشانی اصلی CalDAV را که از Nextcloud کپی کرده‌اید بچسبانید.", + "nextcloudBrowserAuthorizationHelp": "BusyMax مرورگر شما را باز می‌کند. آنجا دسترسی را تأیید کنید و سپس به BusyMax برگردید.", + "connectAccountAction": "اتصال", + "cancelAccountConnection": "لغو اتصال", + "nextcloudAccountRemovedRevokeFailed": "حساب به‌صورت محلی حذف شد، اما ابطال گذرواژهٔ برنامهٔ Nextcloud ممکن نیست.", + "davCachedOfflineNotice": "داده‌های تقویم و کار برای استفادهٔ آفلاین به‌صورت محلی ذخیره می‌شوند.", + "davReauthenticationRequired": "برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.", + "davTemporarilyUnavailable": "این حساب موقتاً در دسترس نیست.", + "davPermissionChanged": "مجوزهای سرور تغییر کرده‌اند. ویرایش‌های در انتظار متوقف شده‌اند.", + "davUnsupportedServer": "این سرور یا نمایهٔ ارائه‌دهنده پشتیبانی نمی‌شود.", + "collectionSettings": "تقویم‌ها و فهرست‌های کار", + "calendarContent": "رویدادهای تقویم", + "taskContent": "کارها", + "readOnlySharedCollection": "فقط‌خواندنی", + "pendingLocally": "در انتظار محلی", + "conflictBlocked": "مسدودشده به‌دلیل تعارض", + "authenticationBlocked": "تا اتصال دوباره مسدود است", + "operationFailed": "عملیات ناموفق بود", + "keepServerVersion": "نگه‌داشتن نسخهٔ سرور", + "reapplyLocalChange": "بررسی و اعمال دوبارهٔ تغییر محلی", + "duplicateLocalItem": "تکرار به‌عنوان مورد جدید", "deleteCalendarConfirmation": "«⁨{title}⁩» حذف شود؟", "@moreItems": { "placeholders": { @@ -518,11 +566,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "وضعیت اتصال", + "davConnected": "متصل", + "davConnecting": "در حال اتصال…", + "davSignedOut": "خارج‌شده", + "davLastSuccessfulSync": "آخرین همگام‌سازی موفق: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -530,9 +578,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "هنوز همگام نشده", + "refreshCollections": "تازه‌سازی تقویم‌ها و فهرست‌های کار", + "nextcloudServerHost": "سرور: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -540,12 +588,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "تقویم رویدادها", + "collectionSupportsTasks": "فهرست کار", + "collectionSupportsEventsAndTasks": "رویدادها و کارها", + "writableCollection": "قابل نوشتن", + "sharedCollection": "اشتراکی", + "collectionLastSynced": "آخرین همگام‌سازی: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -553,7 +601,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "مشکل همگام‌سازی: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -561,8 +609,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "تعارض‌های همگام‌سازی", + "remoteChangedAt": "تغییر سرور: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -570,7 +618,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "ویرایش محلی: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -578,9 +626,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "حل تعارض ممکن نیست.", + "recurringEventScope": "دامنهٔ رویداد تکرارشونده", + "entireSeries": "کل مجموعه", "singleOccurrence": "این رویداد", "thisAndFollowingEvents": "این رویداد و رویدادهای بعدی", "thisAndFutureUnavailable": "این ارائه‌دهنده از آن پشتیبانی نمی‌کند.", @@ -589,204 +637,288 @@ "copyEventAndDeleteOriginal": "رویداد کپی و نسخهٔ اصلی حذف شود؟", "copyEventMoveWarning": "BusyMax نمی‌تواند این رویداد را مستقیماً از {source} به {destination} منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.", "copyAndDelete": "کپی و حذف", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "انتخاب کنید این تغییر برای کل مجموعه، فقط این رویداد، یا این رویداد و رویدادهای بعدی اعمال شود.", "taskDueBeforeStart": "زمان سررسید نباید پیش از زمان شروع باشد.", "taskStartDueTimeModeMismatch": "برای شروع و سررسید هر دو زمان تعیین کنید، یا کار را تمام‌روز کنید.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "ایجاد فهرست کار ممکن نیست: {error}", + "taskListRenameFailed": "تغییر نام فهرست کار ممکن نیست: {error}", + "taskListDeleteFailed": "حذف فهرست کار ممکن نیست: {error}", "unshare": "لغو اشتراک‌گذاری", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "نیاز به اقدام", + "readOnlyTaskListCannotRename": "این فهرست کار فقط‌خواندنی است و نمی‌توان نام آن را تغییر داد.", + "taskListCannotDelete": "این فهرست کار با مجوزهای فعلی شما قابل حذف نیست.", + "deleteTaskListConfirmation": "‏«{title}» و همهٔ کارهای آن حذف شوند؟", + "unshareTaskListConfirmation": "اشتراک‌گذاری «{title}» از این حساب لغو شود؟", + "taskStatus": "وضعیت", + "taskStatusNone": "بدون وضعیت", + "taskStatusNeedsAction": "نیازمند اقدام", "taskStatusInProcess": "در حال انجام", "taskStatusCompleted": "انجام‌شده", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCancelled": "لغوشده", + "completionPercent": "{percent}٪ تکمیل‌شده", + "completionDate": "تاریخ تکمیل", "priority": "اولویت", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "بدون یادآوری", - "editReminder": "Edit reminder", - "beforeTaskStarts": "قبل از شروع کار", - "beforeTaskDue": "قبل از موعد انجام کار", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "حذف یادآوری‌ها", - "keepRelatedReminders": "نگه‌داشتن یادآوری‌ها", - "repeatEvery": "تکرار هر", - "repeatOn": "Repeat on", + "priorityNone": "بدون اولویت", + "priorityHighValue": "اولویت {priority} · زیاد", + "priorityMediumValue": "اولویت {priority} · متوسط", + "priorityLowValue": "اولویت {priority} · کم", + "taskUrl": "URL کار", + "invalidTaskUrl": "یک URL مطلق شامل طرح آن وارد کنید.", + "classification": "دسته‌بندی", + "classificationPublic": "هنگام اشتراک‌گذاری، کل کار نشان داده شود", + "classificationConfidential": "هنگام اشتراک‌گذاری، فقط مشغول بودن نشان داده شود", + "classificationPrivate": "هنگام اشتراک‌گذاری، این کار پنهان شود", + "pinTask": "سنجاق کردن کار", + "reminders": "یادآورها", + "noReminders": "بدون یادآور", + "editReminder": "ویرایش یادآور", + "beforeTaskStarts": "پیش از شروع کار", + "beforeTaskDue": "پیش از سررسید کار", + "afterTaskStarts": "پس از شروع کار", + "afterTaskDue": "پس از سررسید کار", + "relativeToTaskStart": "نسبت به تاریخ شروع کار", + "relativeToTaskDue": "نسبت به تاریخ سررسید کار", + "reminderTimeOfDay": "زمان روز", + "absoluteReminder": "در تاریخ و زمان مشخص", + "reminderAmount": "مقدار", + "reminderUnitSeconds": "ثانیه", + "reminderUnitMinutes": "دقیقه", + "reminderUnitHours": "ساعت", + "reminderUnitDays": "روز", + "reminderUnitWeeks": "هفته", + "reminderAtTaskStart": "هنگام شروع کار", + "reminderAtTaskDue": "هنگام سررسید کار", + "unsupportedReminder": "نوع این یادآور حفظ می‌شود، اما زمان آن قابل ویرایش نیست.", + "relatedRemindersTitle": "یادآورهای مرتبط نگه داشته شوند؟", + "relatedRemindersDescription": "این تاریخ {count} یادآور مرتبط دارد. آن‌ها در تاریخ و زمان فعلی نگه داشته شوند؟", + "discardRelatedReminders": "دور انداختن یادآورها", + "keepRelatedReminders": "نگه‌داشتن یادآورها", + "repeatEvery": "فاصلهٔ تکرار", + "repeatOn": "تکرار در", "repeatEnd": "پایان تکرار", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "repeatNever": "هرگز", + "repeatUntil": "در تاریخ", + "repeatAfter": "پس از تعداد مشخصی تکرار", + "repeatCount": "تعداد تکرار", + "repeatDayOfMonth": "روزهای ماه", + "repeatMonths": "ماه‌ها", + "repeatOrdinal": "جایگاه روز هفته", + "repeatSpecificDays": "روزهای مشخص", + "repeatFirst": "اول", + "repeatSecond": "دوم", + "repeatThird": "سوم", + "repeatFourth": "چهارم", + "repeatFifth": "پنجم", + "repeatSecondToLast": "یکی مانده به آخر", + "repeatLast": "آخر", + "repeatAnyDay": "روز", + "repeatWeekday": "روز هفته", + "repeatWeekendDay": "روز آخر هفته", + "repeatEveryDays": "هر {count} روز", + "repeatEveryWeeks": "هر {count} هفته", + "repeatEveryMonths": "هر {count} ماه", + "repeatEveryYears": "هر {count} سال", "repeatOnDaysSummary": "در {days}", - "repeatOnMonthDaysSummary": "در روز {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatOnMonthDaysSummary": "در روز {days} ماه", + "repeatOnOrdinalSummary": "در {ordinal} {days}", "repeatInMonthsSummary": "در {months}", "repeatTimesSummary": "{count} بار", "repeatUntilSummary": "تا {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "این قانون تکرار از گزینه‌هایی استفاده می‌کند که این ویرایشگر تغییر نمی‌دهد.", "recurrenceUnsupportedByProvider": "این تکرار را نمی‌توان با {provider} استفاده کرد.", - "duplicateTask": "تکراری‌سازی وظیفه", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "مخفی‌سازی زیروظیفه‌ها", - "hideClosedSubtasks": "مخفی‌سازی زیروظیفه‌های بسته‌شده", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "duplicateTask": "تکرار کار", + "taskDuplicated": "کار تکرار شد.", + "taskDuplicateFailed": "تکرار کار ممکن نیست: {error}", + "hideSubtasks": "پنهان کردن زیروظیفه‌ها", + "hideClosedSubtasks": "پنهان کردن زیروظیفه‌های بسته‌شده", + "reminderUnit": "واحد", + "attendeeRequired": "ضروری", + "attendeeOptional": "اختیاری", + "meetingSection": "جلسه", + "addGoogleMeet": "افزودن Google Meet", + "addTeamsMeeting": "افزودن جلسهٔ Microsoft Teams", + "onlineMeetingAdded": "جلسهٔ آنلاین افزوده شد", + "requestResponses": "درخواست پاسخ", + "requestResponsesDescription": "از مهمانان بخواهید به دعوت‌نامه پاسخ دهند.", + "hideGuestList": "پنهان کردن فهرست مهمانان", + "hideGuestListDescription": "مهمانان نمی‌توانند ببینند چه افراد دیگری دعوت شده‌اند.", + "allowNewTimeProposals": "اجازهٔ پیشنهاد زمان‌های جدید", + "allowNewTimeProposalsDescription": "مهمانان می‌توانند زمان دیگری برای جلسه پیشنهاد کنند.", + "notifyGuestsTitle": "مهمانان اطلاع داده شوند؟", + "notifyGuestsSaveMessage": "این جلسه مهمان دارد. هنگام ذخیره، دعوت‌نامه یا به‌روزرسانی رویداد برای آن‌ها ارسال شود؟", + "notifyGuestsDeleteMessage": "این جلسه مهمان دارد. هنگام حذف، لغو جلسه ارسال شود؟", + "sendUpdates": "ارسال به‌روزرسانی‌ها", + "sendCancellation": "ارسال لغو", + "doNotSend": "ارسال نکن", + "microsoftNotifyGuestsSaveTitle": "جلسه ذخیره شود؟", + "microsoftNotifyGuestsSaveMessage": "Microsoft دعوت‌نامه یا به‌روزرسانی رویداد را برای مهمانان ارسال می‌کند.", + "microsoftNotifyGuestsDeleteTitle": "جلسه حذف شود؟", + "microsoftNotifyGuestsDeleteMessage": "Microsoft لغو جلسه را برای مهمانان ارسال می‌کند.", + "organizer": "برگزارکننده", + "yourResponse": "پاسخ شما", + "guestResponses": "پاسخ مهمانان", + "respond": "پاسخ دادن", + "acceptInvitation": "پذیرفتن", + "tentativeInvitation": "موقت", + "declineInvitation": "رد کردن", + "joinMeeting": "پیوستن به جلسه", + "responseAccepted": "پذیرفته‌شده", + "responseTentative": "موقت", + "responseDeclined": "ردشده", + "responseNeedsAction": "در انتظار پاسخ", + "responseNotResponded": "بی‌پاسخ", + "responseOrganizer": "برگزارکننده", + "invitationResponseFailed": "ارسال پاسخ شما ممکن نیست: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "باز کردن پیوند جلسه ممکن نیست.", "setCustomCalendarName": "تنظیم نام سفارشی", "setAction": "تنظیم", "removeFromMyCalendars": "حذف از تقویم‌های من", "removeAction": "حذف", "removeCalendarConfirmation": "«{title}» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", - "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف کرد.", - "calendarPendingChangesPreventRemoval": "پیش از حذف یا برداشتن این تقویم، صبر کنید تا تغییرات در انتظار آن همگام‌سازی شوند.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "وارد کردن تقویم", - "calendarImportDescription": "یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل‌نوشتن مقصد را برگزینید.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف یا جدا کرد.", + "calendarPendingChangesPreventRemoval": "پیش از حذف یا جدا کردن این تقویم، صبر کنید تغییرات در انتظار آن همگام شوند.", + "calendarSubscriptions": "اشتراک‌های تقویم", + "calendarSubscriptionsDescription": "تقویم‌های فقط‌خواندنی را اضافه کنید که از یک URL امن WebCal تازه می‌شوند.", + "addCalendarSubscription": "افزودن اشتراک تقویم", + "subscriptionName": "نام محلی", + "subscriptionUrl": "URL اشتراک", + "subscriptionUrlHelp": "یک URL از نوع HTTPS یا webcal وارد کنید. BusyMax URL کامل را در فضای ذخیره‌سازی امن نگه می‌دارد.", + "subscriptionUrlInvalid": "یک URL معتبر HTTPS یا webcal بدون اطلاعات کاربر یا قطعه وارد کنید.", + "subscriptionColor": "رنگ محلی", + "subscriptionColorHelp": "یک رنگ شش‌رقمی مانند #3584E4 وارد کنید.", + "subscriptionColorInvalid": "یک رنگ هگزادسیمال شش‌رقمی وارد کنید.", + "subscriptionRefreshMode": "دفعات تازه‌سازی", + "subscriptionAutomatic": "خودکار", + "subscriptionHourly": "ساعتی", + "subscriptionSixHours": "هر شش ساعت", + "subscriptionDaily": "روزانه", + "subscriptionSafeOrigin": "مبدأ: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "برای پیش‌نمایش مبدأ امن، یک URL معتبر وارد کنید.", + "subscriptionReadOnly": "اشتراک فقط‌خواندنی", + "subscriptionNeverRefreshed": "هنوز تازه نشده", + "subscriptionLastRefresh": "آخرین تازه‌سازی موفق: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "تازه‌سازی بعدی: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "به‌روز", + "subscriptionStatusIssue": "مشکل تازه‌سازی: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "تازه‌سازی اکنون", + "unsubscribe": "لغو اشتراک", + "unsubscribeCalendarTitle": "اشتراک «{name}» لغو شود؟", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "این کار اشتراک محلی و رویدادهای ذخیره‌شدهٔ آن را حذف می‌کند. تقویم منتشرشده تغییر نمی‌کند.", + "addSubscriptionAction": "افزودن اشتراک", + "subscriptionOperationFailed": "اشتراک تقویم ناموفق بود: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "اشتراک‌ها", + "calendarImport": "درون‌ریزی تقویم", + "calendarImportDescription": "یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل نوشتنی را که باید آن‌ها را دریافت کند انتخاب کنید.", + "importIcsFile": "درون‌ریزی فایل .ics", + "importIcsPreview": "درون‌ریزی رویدادهای تقویم", + "importEventsFound": "مجموعه رویدادهای قابل درون‌ریزی: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "رویدادهای نامعتبر: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "عمداً حذف‌شده: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "تقویم مقصد قابل نوشتنی موجود نیست.", + "importDestinationCalendar": "تقویم مقصد", + "importIcsConfirm": "درون‌ریزی رویدادها", + "importIcsComplete": "درون‌ریزی کامل شد", + "importQueued": "درون‌ریزی‌شده یا در صف: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "تکراری‌ها رد شدند: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "مجموعه‌های تکرار پشتیبانی‌نشده: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "درون‌ریزی فایل تقویم ممکن نیست: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "آفلاین", "networkOfflineDescription": "تغییرات پس از برقراری دوبارهٔ اتصال همگام‌سازی می‌شوند.", "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید." diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 9c32b7a5..2845629e 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,14 +1,14 @@ { "@@locale": "fi", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Yhdistä Google-, Microsoft-, Apple iCloud Calendar- tai Nextcloud-tilit.", "googlePermissionsConsentNotice": "Valitse Googlen käyttöoikeusnäkymässä sekä Kalenteri- että Tehtävät-käyttöoikeudet.", "googlePermissionsRequiredRetry": "Google Kalenterin ja Google Tasksin käyttöoikeudet vaaditaan. Yritä uudelleen ja valitse molemmat valintaruudut.", "finishSetup": "Viimeistele määritys", "continueSetup": "Jatka", "onboardingSetupTitle": "Määritä BusyMax", "onboardingAccountsStepTitle": "Yhdistä tilit", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Lisää kaikki tilit, joita haluat käyttää. BusyMax synkronoi tuetut kalenterit, tapahtumat, tehtäväluettelot ja tehtävät jokaiselta tililtä.", "onboardingPreferencesStepTitle": "Valitse järjestelmäasetukset", "onboardingPreferencesStepDescription": "Määritä sovelluksen toiminta työpöydällä, muistutukset, ilmoitusten yksityiskohtaisuus ja ulkoasu ennen aikataulun avaamista.", "signInWithGoogle": "Kirjaudu Google-tilillä", @@ -36,16 +36,40 @@ "newCalendar": "Uusi kalenteri", "calendarColor": "Kalenterin väri", "calendarColorOption": "Väri {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "Tämä palveluntarjoaja ei tue kalenterien hallintaa BusyMaxissa.", "primaryCalendarCannotDelete": "Ensisijaista kalenteria ei voi poistaa.", "calendarCreateFailed": "Kalenteria ei voitu luoda: {error}", "calendarCreatedRefreshPending": "Kalenteri luotiin, mutta BusyMax ei voinut päivittää tiliä. Se tulee näkyviin seuraavan synkronoinnin jälkeen.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Kalenteria ei voitu päivittää: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Kalenteria ei voitu poistaa: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Uusi tapahtuma", "refreshCalendar": "Päivitä kalenteri", "openInProvider": "Avaa palvelussa", @@ -65,38 +89,62 @@ "scheduleNoSearchResultsDescription": "Kokeile toista hakua tai tyhjennä nykyiset suodattimet.", "refresh": "Päivitä", "trayOpenBusyMax": "Avaa BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Näytä BusyMax", + "trayNewEvent": "Uusi tapahtuma…", + "trayNewTask": "Uusi tehtävä…", + "trayToday": "Tänään", + "trayAllDay": "Koko päivä", + "trayNow": "Nyt", + "trayCalendarEvent": "Kalenteritapahtuma", + "trayUntitledEvent": "Nimetön tapahtuma", + "trayNothingElseToday": "Ei muuta tänään", + "trayTasksDueToday": "{count, plural, =1{1 tehtävä erääntyy tänään} other{{count} tehtävää erääntyy tänään}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Avaa tämän päivän agenda", + "traySyncNow": "Synkronoi nyt", + "traySyncing": "Synkronoidaan…", + "trayNotConnected": "Ei yhteyttä", + "trayNotYetSynced": "Ei vielä synkronoitu", + "trayLastSyncedJustNow": "Synkronoitu juuri nyt", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Synkronoitu minuutti sitten} other{Synkronoitu {count} minuuttia sitten}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Synkronoitu tunti sitten} other{Synkronoitu {count} tuntia sitten}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Synkronoitu päivä sitten} other{Synkronoitu {count} päivää sitten}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Asetukset", + "trayQuitBusyMax": "Lopeta BusyMax", "agendaLoadMoreOverdue": "Lataa lisää myöhässä olevia tehtäviä", "agendaLoadMoreNoDate": "Lataa lisää päiväämättömiä tehtäviä", "viewDay": "Päivä", "viewWeek": "Viikko", "viewMonth": "Kuukausi", "viewYear": "Vuosi", - "viewAgenda": "Agenda", + "viewAgenda": "Agendanäkymä", "scheduleSettings": "Aikataulu", "scheduleDisplaySettings": "Aikataulun näyttö", "scheduleDisplayHoursDescription": "Päivä- ja viikkonäkymät näyttävät aluksi tämän aikavälin. Aikaisemmat ja myöhemmät kohteet laajentavat sitä tarvittaessa.", @@ -218,7 +266,7 @@ "removingAccount": "Poistetaan tiliä…", "removeAccountDescription": "Lopeta synkronointi ja poista tämän tilin tiedot tältä laitteelta.", "removeAccountTitle": "Poistetaanko {account} BusyMaxista?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Tämä poistaa laitteelta välimuistiin tallennetut tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset. Synkronoimattomat muutokset menetetään. Palveluntarjoajan kalenteri-, tapahtuma-, tehtäväluettelo- ja tehtäväkopioita ei poisteta.", "revokeGoogleAccess": "Peruuta myös BusyMaxin käyttöoikeus tähän Google-tiliin", "revokeGoogleAccessDescription": "Käyttöoikeus on myönnettävä uudelleen ennen tilin yhdistämistä.", "removeAccountAction": "Poista tili", @@ -234,7 +282,7 @@ "deleteList": "Poista luettelo", "builtInMicrosoftList": "Sisäänrakennettu", "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do -sovelluksen sisäänrakennettuja luetteloita ei voi nimetä uudelleen tai poistaa.", - "deleteListConfirmation": "Poistetaanko \"{title}\" Google Tasksista?", + "deleteListConfirmation": "Poistetaanko ”{title}” Google Tasksista?", "deleteEvent": "Poista tapahtuma", "title": "Nimi", "create": "Luo", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "Tehtävän määräaika lähestyy.", "notificationOpenAction": "Avaa", "notificationSnoozeAction": "Torkuta 10 minuuttia", - "notificationDismissAction": "Hylkää", + "notificationDismissAction": "Sulje", "notificationDetailsHidden": "Yksityiskohdat on piilotettu tietosuoja-asetusten vuoksi.", "previousMonth": "Edellinen kuukausi", "nextMonth": "Seuraava kuukausi", @@ -415,50 +463,50 @@ "selectTimeZone": "Valitse aikavyöhyke", "searchLocations": "Hae sijainteja", "noLocationsFound": "Sijainteja ei löytynyt", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Tämä kenttä on pakollinen.", + "providerConnectionDescription": "Yhdistä kalenterit ja tehtävät johonkin näistä palveluntarjoajista.", + "appleICloudProvider": "Apple iCloud -kalenteri", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud-tehtävät", + "addAppleICloudAccount": "Lisää Apple iCloud -kalenteritili", + "addNextcloudAccount": "Lisää Nextcloud-tili", + "waitingForAppleICloud": "Yhdistetään Apple iCloudiin…", + "waitingForNextcloud": "Odotetaan Nextcloud-valtuutusta…", + "connectAppleICloudTitle": "Yhdistä Apple iCloud -kalenteri", + "appleAccountEmail": "Apple-tilin sähköposti", + "appleAppSpecificPassword": "Sovelluskohtainen salasana", + "appleAppSpecificPasswordHelp": "Luo sovelluskohtainen salasana, kun olet ottanut kaksivaiheisen todennuksen käyttöön Apple-tililläsi.", + "appleAppSpecificPasswordResetWarning": "Apple-tilin salasanan vaihtaminen mitätöi sovelluskohtaiset salasanat.", + "connectNextcloudTitle": "Yhdistä Nextcloud", + "nextcloudServerUrl": "Nextcloud-palvelin tai CalDAV-osoite", + "nextcloudServerUrlHelp": "Anna Nextcloud-palvelimesi URL-osoite tai liitä Nextcloudista kopioitu ensisijainen CalDAV-osoite.", + "nextcloudBrowserAuthorizationHelp": "BusyMax avaa selaimesi. Hyväksy käyttö siellä ja palaa sitten BusyMaxiin.", + "connectAccountAction": "Yhdistä", + "cancelAccountConnection": "Peruuta yhteys", + "nextcloudAccountRemovedRevokeFailed": "Tili poistettiin paikallisesti, mutta Nextcloudin sovellussalasanaa ei voitu kumota.", + "davCachedOfflineNotice": "Kalenteri- ja tehtävätiedot tallennetaan paikallisesti offline-käyttöä varten.", + "davReauthenticationRequired": "Yhdistä tämä tili uudelleen jatkaaksesi synkronointia.", + "davTemporarilyUnavailable": "Tämä tili ei ole tilapäisesti käytettävissä.", + "davPermissionChanged": "Palvelimen käyttöoikeudet muuttuivat. Odottavat muokkaukset on keskeytetty.", + "davUnsupportedServer": "Tätä palvelinta tai palveluntarjoajan profiilia ei tueta.", + "collectionSettings": "Kalenterit ja tehtäväluettelot", + "calendarContent": "Kalenteritapahtumat", + "taskContent": "Tehtävät", + "readOnlySharedCollection": "Vain luku", + "pendingLocally": "Paikallisesti odottava", + "conflictBlocked": "Ristiriidan estämä", + "authenticationBlocked": "Estetty, kunnes yhteys muodostetaan uudelleen", + "operationFailed": "Toiminto epäonnistui", + "keepServerVersion": "Säilytä palvelinversio", + "reapplyLocalChange": "Tarkista ja ota paikallinen muutos uudelleen käyttöön", + "duplicateLocalItem": "Monista uudeksi kohteeksi", "deleteCalendarConfirmation": "Poistetaanko \"{title}\"?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Yhteyden tila", + "davConnected": "Yhdistetty", + "davConnecting": "Yhdistetään…", + "davSignedOut": "Kirjautunut ulos", + "davLastSuccessfulSync": "Viimeisin onnistunut synkronointi: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Ei vielä synkronoitu", + "refreshCollections": "Päivitä kalenterit ja tehtäväluettelot", + "nextcloudServerHost": "Palvelin: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Tapahtumakalenteri", + "collectionSupportsTasks": "Tehtäväluettelo", + "collectionSupportsEventsAndTasks": "Tapahtumat ja tehtävät", + "writableCollection": "Muokattava", + "sharedCollection": "Jaettu", + "collectionLastSynced": "Viimeksi synkronoitu: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Synkronointiongelma: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Synkronointiristiriidat", + "remoteChangedAt": "Palvelin muuttui: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Paikallinen muokkaus: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "Ristiriitaa ei voitu ratkaista.", + "recurringEventScope": "Toistuvan tapahtuman laajuus", + "entireSeries": "Koko sarja", "singleOccurrence": "Tämä tapahtuma", "thisAndFollowingEvents": "Tämä ja seuraavat tapahtumat", "thisAndFutureUnavailable": "Tämä palveluntarjoaja ei tue tätä.", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "Kopioidaanko tapahtuma ja poistetaanko alkuperäinen?", "copyEventMoveWarning": "BusyMax ei voi siirtää tätä tapahtumaa suoraan kalenterista {source} kalenteriin {destination}. Kopio luodaan ensin, ja alkuperäinen poistetaan vasta onnistuneen kopioinnin jälkeen. Tapahtuman tunnisteet muuttuvat; osallistujien vastaustilat voivat nollautua ja kutsuja tai peruutuksia voidaan lähettää; kokouslinkit, liitteet, muistutukset, palveluntarjoajakohtaiset kentät ja toistumisen poikkeukset eivät välttämättä siirry.", "copyAndDelete": "Kopioi ja poista", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Valitse, koskeeko tämä muutos koko sarjaa, vain tätä tapahtumaa vai tätä ja seuraavia tapahtumia.", "taskDueBeforeStart": "Määräaika ei saa olla ennen alkamisaikaa.", "taskStartDueTimeModeMismatch": "Aseta kellonaika sekä alkamiselle että määräajalle tai tee tehtävästä koko päivän tehtävä.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "Tehtäväluettelon luominen epäonnistui: {error}", + "taskListRenameFailed": "Tehtäväluettelon uudelleennimeäminen epäonnistui: {error}", + "taskListDeleteFailed": "Tehtäväluettelon poistaminen epäonnistui: {error}", "unshare": "Lopeta jakaminen", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "Tämä tehtäväluettelo on vain luku -tilassa, eikä sitä voi nimetä uudelleen.", + "taskListCannotDelete": "Tätä tehtäväluetteloa ei voi poistaa nykyisillä käyttöoikeuksillasi.", + "deleteTaskListConfirmation": "Poistetaanko ”{title}” ja kaikki sen tehtävät?", + "unshareTaskListConfirmation": "Lopetetaanko kohteen ”{title}” jakaminen tämän tilin kanssa?", + "taskStatus": "Tila", + "taskStatusNone": "Ei tilaa", "taskStatusNeedsAction": "Vaatii toimenpiteitä", "taskStatusInProcess": "Käsittelyssä", - "taskStatusCompleted": "Valmiina", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Valmis", + "taskStatusCancelled": "Peruutettu", + "completionPercent": "Valmis {percent} %", + "completionDate": "Valmistumispäivä", "priority": "Tärkeys", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Toista joka", - "repeatOn": "Repeat on", + "priorityNone": "Ei tärkeyttä", + "priorityHighValue": "Tärkeys {priority} · korkea", + "priorityMediumValue": "Tärkeys {priority} · keskitaso", + "priorityLowValue": "Tärkeys {priority} · matala", + "taskUrl": "Tehtävän URL", + "invalidTaskUrl": "Anna absoluuttinen URL-osoite skeemoineen.", + "classification": "Luokittelu", + "classificationPublic": "Näytä koko tehtävä jaettaessa", + "classificationConfidential": "Näytä jaettaessa vain varattu-aika", + "classificationPrivate": "Piilota tämä tehtävä jaettaessa", + "pinTask": "Kiinnitä tehtävä", + "reminders": "Muistutukset", + "noReminders": "Ei muistutuksia", + "editReminder": "Muokkaa muistutusta", + "beforeTaskStarts": "Ennen tehtävän alkamista", + "beforeTaskDue": "Ennen tehtävän määräaikaa", + "afterTaskStarts": "Tehtävän alkamisen jälkeen", + "afterTaskDue": "Tehtävän määräajan jälkeen", + "relativeToTaskStart": "Suhteessa tehtävän aloituspäivään", + "relativeToTaskDue": "Suhteessa tehtävän määräpäivään", + "reminderTimeOfDay": "Kellonaika", + "absoluteReminder": "Tiettynä päivänä ja kellonaikana", + "reminderAmount": "Määrä", + "reminderUnitSeconds": "Sekunnit", + "reminderUnitMinutes": "Minuutit", + "reminderUnitHours": "Tunnit", + "reminderUnitDays": "Päivät", + "reminderUnitWeeks": "Viikot", + "reminderAtTaskStart": "Tehtävän alkaessa", + "reminderAtTaskDue": "Tehtävän määräaikana", + "unsupportedReminder": "Tämä muistutustyyppi säilytetään, mutta sen aikaa ei voi muokata.", + "relatedRemindersTitle": "Säilytetäänkö liittyvät muistutukset?", + "relatedRemindersDescription": "Tällä päivämäärällä on {count} liittyvää muistutusta. Säilytetäänkö ne nykyisessä päivämäärässä ja kellonajassa?", + "discardRelatedReminders": "Hylkää muistutukset", + "keepRelatedReminders": "Säilytä muistutukset", + "repeatEvery": "Toistoväli", + "repeatOn": "Toistopäivät", "repeatEnd": "Lopeta toisto", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "päivänä {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", + "repeatNever": "Ei koskaan", + "repeatUntil": "Päivämääränä", + "repeatAfter": "Toistokertojen jälkeen", + "repeatCount": "Toistokerrat", + "repeatDayOfMonth": "Kuukauden päivät", + "repeatMonths": "Kuukaudet", + "repeatOrdinal": "Viikonpäivän järjestys", + "repeatSpecificDays": "Tietyt päivät", + "repeatFirst": "Ensimmäinen", + "repeatSecond": "Toinen", + "repeatThird": "Kolmas", + "repeatFourth": "Neljäs", + "repeatFifth": "Viides", + "repeatSecondToLast": "Toiseksi viimeinen", + "repeatLast": "Viimeinen", + "repeatAnyDay": "Päivä", + "repeatWeekday": "Arkipäivä", + "repeatWeekendDay": "Viikonlopun päivä", + "repeatEveryDays": "Joka {count}. päivä", + "repeatEveryWeeks": "Joka {count}. viikko", + "repeatEveryMonths": "Joka {count}. kuukausi", + "repeatEveryYears": "Joka {count}. vuosi", + "repeatOnDaysSummary": "päivinä {days}", + "repeatOnMonthDaysSummary": "kuukauden päivinä {days}", + "repeatOnOrdinalSummary": "{ordinal} {days}", + "repeatInMonthsSummary": "kuukausina {months}", "repeatTimesSummary": "{count} kertaa", "repeatUntilSummary": "{date} asti", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "Tämä toistosääntö käyttää asetuksia, joita tämä muokkain ei muuta.", "recurrenceUnsupportedByProvider": "Tätä toistoa ei voi käyttää palvelussa {provider}.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "duplicateTask": "Monista tehtävä", + "taskDuplicated": "Tehtävä monistettiin.", + "taskDuplicateFailed": "Tehtävän monistaminen epäonnistui: {error}", "hideSubtasks": "Piilota alitehtävät", "hideClosedSubtasks": "Piilota suljetut alitehtävät", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "Yksikkö", + "attendeeRequired": "Pakollinen", + "attendeeOptional": "Valinnainen", + "meetingSection": "Kokous", + "addGoogleMeet": "Lisää Google Meet", + "addTeamsMeeting": "Lisää Microsoft Teams -kokous", + "onlineMeetingAdded": "Verkkokokous lisätty", + "requestResponses": "Pyydä vastauksia", + "requestResponsesDescription": "Pyydä vieraita vastaamaan kutsuun.", + "hideGuestList": "Piilota vierasluettelo", + "hideGuestListDescription": "Vieraat eivät näe, keitä muita on kutsuttu.", + "allowNewTimeProposals": "Salli uudet aikaehdotukset", + "allowNewTimeProposalsDescription": "Vieraat voivat ehdottaa toista kokousaikaa.", + "notifyGuestsTitle": "Ilmoitetaanko vieraille?", + "notifyGuestsSaveMessage": "Tässä kokouksessa on vieraita. Lähetetäänkö kutsut tai tapahtumapäivitykset tallennettaessa?", + "notifyGuestsDeleteMessage": "Tässä kokouksessa on vieraita. Lähetetäänkö peruutus, kun kokous poistetaan?", + "sendUpdates": "Lähetä päivitykset", + "sendCancellation": "Lähetä peruutus", + "doNotSend": "Älä lähetä", + "microsoftNotifyGuestsSaveTitle": "Tallennetaanko kokous?", + "microsoftNotifyGuestsSaveMessage": "Microsoft lähettää vieraille kutsut tai tapahtumapäivitykset.", + "microsoftNotifyGuestsDeleteTitle": "Poistetaanko kokous?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft lähettää vieraille peruutuksen.", + "organizer": "Järjestäjä", + "yourResponse": "Vastauksesi", + "guestResponses": "Vieraiden vastaukset", + "respond": "Vastaa", + "acceptInvitation": "Hyväksy", + "tentativeInvitation": "Alustava", + "declineInvitation": "Hylkää", + "joinMeeting": "Liity kokoukseen", + "responseAccepted": "Hyväksytty", + "responseTentative": "Alustava", + "responseDeclined": "Hylätty", + "responseNeedsAction": "Vastausta odotetaan", + "responseNotResponded": "Ei vastausta", + "responseOrganizer": "Järjestäjä", + "invitationResponseFailed": "Vastauksesi lähetys epäonnistui: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Kokouslinkkiä ei voitu avata.", "setCustomCalendarName": "Aseta mukautettu nimi", "setAction": "Aseta", "removeFromMyCalendars": "Poista omista kalentereistani", "removeAction": "Poista", "removeCalendarConfirmation": "Poistetaanko ”{title}” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.", - "calendarCannotRemove": "Tätä kalenteria ei voi poistaa tältä tililtä.", - "calendarPendingChangesPreventRemoval": "Odota, että tämän kalenterin odottavat muutokset on synkronoitu, ennen kuin poistat kalenterin.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", + "calendarCannotRemove": "Tätä kalenteria ei voi poistaa tai irrottaa tästä tilistä.", + "calendarPendingChangesPreventRemoval": "Odota tämän kalenterin odottavien muutosten synkronointia ennen poistamista tai irrottamista.", + "calendarSubscriptions": "Kalenteritilaukset", + "calendarSubscriptionsDescription": "Lisää vain luku -kalentereita, jotka päivittyvät suojatusta WebCal-URL-osoitteesta.", + "addCalendarSubscription": "Lisää kalenteritilaus", + "subscriptionName": "Paikallinen nimi", + "subscriptionUrl": "Tilauksen URL", + "subscriptionUrlHelp": "Anna HTTPS- tai webcal-URL. BusyMax säilyttää täydellisen URL-osoitteen suojatussa tallennustilassa.", + "subscriptionUrlInvalid": "Anna kelvollinen HTTPS- tai webcal-URL ilman käyttäjätietoja tai fragmenttia.", + "subscriptionColor": "Paikallinen väri", + "subscriptionColorHelp": "Käytä kuusinumeroista väriä, kuten #3584E4.", + "subscriptionColorInvalid": "Anna kuusinumeroinen heksadesimaaliväri.", + "subscriptionRefreshMode": "Päivitystiheys", + "subscriptionAutomatic": "Automaattinen", + "subscriptionHourly": "Tunneittain", + "subscriptionSixHours": "Kuuden tunnin välein", + "subscriptionDaily": "Päivittäin", + "subscriptionSafeOrigin": "Lähde: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Anna kelvollinen URL esikatsellaksesi sen suojattua alkuperää.", + "subscriptionReadOnly": "Vain luku -tilaus", + "subscriptionNeverRefreshed": "Ei vielä päivitetty", + "subscriptionLastRefresh": "Viimeisin onnistunut päivitys: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Seuraava päivitys: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Ajan tasalla", + "subscriptionStatusIssue": "Päivitysongelma: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Päivitä nyt", + "unsubscribe": "Peruuta tilaus", + "unsubscribeCalendarTitle": "Perutaanko tilaus kalenterista ”{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Tämä poistaa paikallisen tilauksen ja sen välimuistissa olevat tapahtumat. Julkaistua kalenteria ei muuteta.", + "addSubscriptionAction": "Lisää tilaus", + "subscriptionOperationFailed": "Kalenteritilaus epäonnistui: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Tilaukset", "calendarImport": "Kalenterin tuonti", - "calendarImportDescription": "Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten kirjoitettava kohdekalenteri.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarImportDescription": "Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten muokattava kalenteri, joka vastaanottaa ne.", + "importIcsFile": "Tuo .ics-tiedosto", + "importIcsPreview": "Tuo kalenteritapahtumat", + "importEventsFound": "Tuotavat tapahtumajoukot: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Virheelliset tapahtumat: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Jätetty tarkoituksella pois: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Muokattavaa kohdekalenteria ei ole saatavilla.", + "importDestinationCalendar": "Kohdekalenteri", + "importIcsConfirm": "Tuo tapahtumat", + "importIcsComplete": "Tuonti valmis", + "importQueued": "Tuotu tai jonossa: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Ohitetut kaksoiskappaleet: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Tukemattomat toistojoukot: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Kalenteritiedostoa ei voitu tuoda: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Ei verkkoyhteyttä", "networkOfflineDescription": "Muutokset synkronoidaan, kun yhteys palautuu.", "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2b4a4e12..bfec6001 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,14 +1,14 @@ { "@@locale": "fr", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Connectez les comptes Google, Microsoft, Calendrier Apple iCloud ou Nextcloud.", "googlePermissionsConsentNotice": "Sur l’écran d’autorisations Google, sélectionnez les autorisations Calendrier et Tâches.", "googlePermissionsRequiredRetry": "Les autorisations Google Calendar et Google Tasks sont requises. Réessayez et sélectionnez les deux cases.", "finishSetup": "Terminer la configuration", "continueSetup": "Continuer", "onboardingSetupTitle": "Configurer BusyMax", "onboardingAccountsStepTitle": "Connecter des comptes", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Ajoutez tous les comptes que vous souhaitez utiliser. BusyMax synchronise les calendriers, événements, listes de tâches et tâches pris en charge de chaque compte.", "onboardingPreferencesStepTitle": "Choisir les paramètres système", "onboardingPreferencesStepDescription": "Réglez le comportement de l’application sur le bureau, les rappels, le niveau de détail des notifications et l’apparence avant d’ouvrir votre planning.", "signInWithGoogle": "Se connecter avec Google", @@ -22,7 +22,7 @@ "cancel": "Annuler", "close": "Fermer", "exit": "Quitter", - "options": "Options", + "options": "Choix", "hide": "Masquer", "show": "Afficher", "export": "Exporter", @@ -36,16 +36,40 @@ "newCalendar": "Nouveau calendrier", "calendarColor": "Couleur du calendrier", "calendarColorOption": "Couleur {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "Ce fournisseur ne prend pas en charge la gestion des calendriers dans BusyMax.", "primaryCalendarCannotDelete": "Le calendrier principal ne peut pas être supprimé.", "calendarCreateFailed": "Impossible de créer le calendrier : {error}", "calendarCreatedRefreshPending": "Le calendrier a été créé, mais BusyMax n’a pas pu actualiser le compte. Il apparaîtra après la prochaine synchronisation.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Impossible de mettre à jour le calendrier : {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Impossible de supprimer le calendrier : {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Nouvel événement", "refreshCalendar": "Actualiser le calendrier", "openInProvider": "Ouvrir dans le service", @@ -65,38 +89,62 @@ "scheduleNoSearchResultsDescription": "Essayez une autre recherche ou effacez les filtres actuels.", "refresh": "Actualiser", "trayOpenBusyMax": "Ouvrir BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Afficher BusyMax", + "trayNewEvent": "Nouvel événement…", + "trayNewTask": "Nouvelle tâche…", + "trayToday": "Aujourd’hui", + "trayAllDay": "Toute la journée", + "trayNow": "Maintenant", + "trayCalendarEvent": "Événement du calendrier", + "trayUntitledEvent": "Événement sans titre", + "trayNothingElseToday": "Rien d’autre aujourd’hui", + "trayTasksDueToday": "{count, plural, =1{1 tâche arrive à échéance aujourd’hui} other{{count} tâches arrivent à échéance aujourd’hui}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Ouvrir l’agenda du jour", + "traySyncNow": "Synchroniser maintenant", + "traySyncing": "Synchronisation…", + "trayNotConnected": "Non connecté", + "trayNotYetSynced": "Pas encore synchronisé", + "trayLastSyncedJustNow": "Synchronisé à l’instant", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Synchronisé il y a 1 minute} other{Synchronisé il y a {count} minutes}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Synchronisé il y a 1 heure} other{Synchronisé il y a {count} heures}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Synchronisé il y a 1 jour} other{Synchronisé il y a {count} jours}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Paramètres", + "trayQuitBusyMax": "Quitter BusyMax", "agendaLoadMoreOverdue": "Charger plus de tâches en retard", "agendaLoadMoreNoDate": "Charger plus de tâches sans date", "viewDay": "Jour", "viewWeek": "Semaine", "viewMonth": "Mois", "viewYear": "Année", - "viewAgenda": "Agenda", + "viewAgenda": "Vue agenda", "scheduleSettings": "Planning", "scheduleDisplaySettings": "Affichage du planning", "scheduleDisplayHoursDescription": "Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments situés avant ou après cette plage l’étendent si nécessaire.", @@ -117,7 +165,7 @@ "defaultReminder": "Rappel par défaut", "guests": "Invités", "noGuests": "Aucun invité", - "description": "Description", + "description": "Détails", "availabilityShowAs": "Disponibilité / Afficher comme", "busy": "Occupé", "visibility": "Visibilité", @@ -170,12 +218,12 @@ "allTasks": "Toutes les tâches", "tasksInList": "Tâches dans {title}", "taskLists": "Listes de tâches", - "navigation": "Navigation", + "navigation": "Déplacement", "mainMenu": "Menu principal", "keyboardShortcuts": "Raccourcis clavier", "shortcutGroupGeneral": "Général", "shortcutKeyboardShortcutsDescription": "Afficher cette liste de raccourcis", - "shortcutGroupNavigation": "Navigation", + "shortcutGroupNavigation": "Déplacement", "shortcutNextPeriod": "Période suivante", "shortcutNextPeriodDescription": "Semaine suivante en vue semaine, mois suivant en vue mois, etc.", "shortcutPreviousPeriod": "Période précédente", @@ -246,7 +294,7 @@ } } }, - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Les copies des calendriers, événements, listes de tâches et tâches conservées chez le fournisseur ne sont pas supprimées.", "revokeGoogleAccess": "Révoquer également l’accès de BusyMax à ce compte Google", "revokeGoogleAccessDescription": "Vous devrez accorder à nouveau l’accès avant de reconnecter le compte.", "removeAccountAction": "Supprimer le compte", @@ -262,7 +310,7 @@ "deleteList": "Supprimer la liste", "builtInMicrosoftList": "Intégrée", "builtInMicrosoftListCannotRenameDelete": "Les listes Microsoft To Do intégrées ne peuvent pas être renommées ni supprimées.", - "deleteListConfirmation": "Supprimer « {title} » de Google Tasks ?", + "deleteListConfirmation": "Supprimer « {title} » de Google Tasks ?", "deleteEvent": "Supprimer l’événement", "title": "Titre", "create": "Créer", @@ -314,7 +362,7 @@ "statusSection": "État", "openStatus": "Ouverte", "doneStatus": "Terminée", - "notes": "Notes", + "notes": "Remarques", "dueDate": "Date d’échéance", "clearDueDate": "Effacer la date d’échéance", "dueTime": "Heure d’échéance", @@ -337,7 +385,7 @@ "repeatWeekly": "Hebdomadaire", "repeatMonthly": "Mensuel", "repeatYearly": "Annuel", - "importance": "Importance", + "importance": "Niveau d’importance", "importanceLow": "Faible", "importanceNormal": "Normale", "importanceHigh": "Élevée", @@ -347,7 +395,7 @@ "startGroup": "Début", "reminderGroup": "Rappel", "organizationSection": "Organisation", - "actionsSection": "Actions", + "actionsSection": "Opérations", "advancedSection": "Avancé", "addCategory": "Ajouter une catégorie", "list": "Liste", @@ -363,7 +411,7 @@ "etag": "ETag", "updated": "Mis à jour", "parent": "Tâche parente", - "position": "Position", + "position": "Ordre", "webLink": "Lien web", "assignment": "Attribution", "localState": "État local", @@ -389,12 +437,12 @@ "taskReminders": "Rappels de tâches", "notificationDetailLevel": "Niveau de détail des notifications", "notificationDetailPrivate": "Privé", - "notificationDetailNormal": "Normal", + "notificationDetailNormal": "Standard", "quietHours": "Période de silence", "quietHoursDescription": "Mettre les notifications en pause pendant cette période.", "quietHoursStart": "Début de la période de silence", "quietHoursEnd": "Fin de la période de silence", - "notifications": "Notifications", + "notifications": "Alertes", "appearance": "Apparence", "theme": "Thème", "themeSystem": "Système", @@ -407,7 +455,7 @@ "privacy": "Confidentialité", "redactTaskContentInDiagnostics": "Masquer le contenu des tâches dans les diagnostics", "developerDiagnostics": "Diagnostics développeur", - "diagnostics": "Diagnostics", + "diagnostics": "Informations de diagnostic", "apiInspectorDisabled": "Afficher l’inspecteur API", "googleTasksApi": "API Google Tasks", "discoveryRevision": "Révision Discovery : {revision}", @@ -442,7 +490,7 @@ "taskReminderNotificationBody": "La tâche arrive bientôt à échéance.", "notificationOpenAction": "Ouvrir", "notificationSnoozeAction": "Rappeler dans 10 minutes", - "notificationDismissAction": "Ignorer", + "notificationDismissAction": "Fermer", "notificationDetailsHidden": "Les détails sont masqués par les paramètres de confidentialité.", "previousMonth": "Mois précédent", "nextMonth": "Mois suivant", @@ -471,44 +519,44 @@ "selectTimeZone": "Sélectionner le fuseau horaire", "searchLocations": "Rechercher des lieux", "noLocationsFound": "Aucun lieu trouvé", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Ce champ est obligatoire.", + "providerConnectionDescription": "Connectez les calendriers et les tâches à l’un de ces fournisseurs.", + "appleICloudProvider": "Calendrier Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Tâches Nextcloud", + "addAppleICloudAccount": "Ajouter un compte Calendrier Apple iCloud", + "addNextcloudAccount": "Ajouter un compte Nextcloud", + "waitingForAppleICloud": "Connexion à Apple iCloud…", + "waitingForNextcloud": "En attente de l’autorisation Nextcloud…", + "connectAppleICloudTitle": "Connecter le Calendrier Apple iCloud", + "appleAccountEmail": "E-mail du compte Apple", + "appleAppSpecificPassword": "Mot de passe spécifique à l’app", + "appleAppSpecificPasswordHelp": "Créez un mot de passe spécifique à l’app après avoir activé l’authentification à deux facteurs de votre compte Apple.", + "appleAppSpecificPasswordResetWarning": "La réinitialisation du mot de passe de votre compte Apple révoque les mots de passe spécifiques aux apps.", + "connectNextcloudTitle": "Connecter Nextcloud", + "nextcloudServerUrl": "Serveur Nextcloud ou adresse CalDAV", + "nextcloudServerUrlHelp": "Saisissez l’URL de votre serveur Nextcloud ou collez l’adresse CalDAV principale copiée depuis Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax va ouvrir votre navigateur. Autorisez-y l’accès, puis revenez dans BusyMax.", + "connectAccountAction": "Connecter", + "cancelAccountConnection": "Annuler la connexion", + "nextcloudAccountRemovedRevokeFailed": "Le compte a été supprimé localement, mais le mot de passe d’app Nextcloud n’a pas pu être révoqué.", + "davCachedOfflineNotice": "Les données des calendriers et des tâches sont mises en cache localement pour une utilisation hors connexion.", + "davReauthenticationRequired": "Reconnectez ce compte pour reprendre la synchronisation.", + "davTemporarilyUnavailable": "Ce compte est temporairement indisponible.", + "davPermissionChanged": "Les autorisations du serveur ont changé. Les modifications en attente sont suspendues.", + "davUnsupportedServer": "Ce serveur ou profil de fournisseur n’est pas pris en charge.", + "collectionSettings": "Calendriers et listes de tâches", + "calendarContent": "Événements du calendrier", + "taskContent": "Tâches", + "readOnlySharedCollection": "Lecture seule", + "pendingLocally": "En attente localement", + "conflictBlocked": "Bloqué par un conflit", + "authenticationBlocked": "Bloqué jusqu’à la reconnexion", + "operationFailed": "Échec de l’opération", + "keepServerVersion": "Conserver la version du serveur", + "reapplyLocalChange": "Examiner et réappliquer la modification locale", + "duplicateLocalItem": "Dupliquer comme nouvel élément", "deleteCalendarConfirmation": "Supprimer « {title} » ?", "@deleteCalendarConfirmation": { "placeholders": { @@ -517,11 +565,11 @@ } } }, - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "État de la connexion", + "davConnected": "Connecté", + "davConnecting": "Connexion…", + "davSignedOut": "Déconnecté", + "davLastSuccessfulSync": "Dernière synchronisation réussie : {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -529,9 +577,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Pas encore synchronisé", + "refreshCollections": "Actualiser les calendriers et les listes de tâches", + "nextcloudServerHost": "Serveur : {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -539,12 +587,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Calendrier d’événements", + "collectionSupportsTasks": "Liste de tâches", + "collectionSupportsEventsAndTasks": "Événements et tâches", + "writableCollection": "Modifiable", + "sharedCollection": "Partagé", + "collectionLastSynced": "Dernière synchronisation : {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -552,7 +600,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Problème de synchronisation : {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -560,8 +608,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Conflits de synchronisation", + "remoteChangedAt": "Modification du serveur : {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -569,7 +617,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Modification locale : {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -577,9 +625,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "Impossible de résoudre le conflit.", + "recurringEventScope": "Portée de l’événement récurrent", + "entireSeries": "Série entière", "singleOccurrence": "Cet événement", "thisAndFollowingEvents": "Cet événement et les suivants", "thisAndFutureUnavailable": "Non pris en charge par ce fournisseur.", @@ -588,204 +636,288 @@ "copyEventAndDeleteOriginal": "Copier l’événement et supprimer l’original ?", "copyEventMoveWarning": "BusyMax ne peut pas déplacer directement cet événement de {source} vers {destination}. La copie sera créée en premier et l’original ne sera supprimé qu’après la réussite de la copie. Les identifiants changeront ; les statuts de réponse des participants pourront être réinitialisés et des invitations ou annulations envoyées ; les liens de conférence, pièces jointes, rappels, champs propres au fournisseur et exceptions de récurrence pourront ne pas être conservés.", "copyAndDelete": "Copier et supprimer", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Choisissez si cette modification s’applique à toute la série, à cet événement uniquement ou à cet événement et aux suivants.", "taskDueBeforeStart": "L’échéance ne peut pas précéder le début.", "taskStartDueTimeModeMismatch": "Définissez une heure pour le début et l’échéance, ou passez la tâche en journée entière.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "Impossible de créer la liste de tâches : {error}", + "taskListRenameFailed": "Impossible de renommer la liste de tâches : {error}", + "taskListDeleteFailed": "Impossible de supprimer la liste de tâches : {error}", "unshare": "Ne plus partager", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Nécessite une action", + "readOnlyTaskListCannotRename": "Cette liste de tâches est en lecture seule et ne peut pas être renommée.", + "taskListCannotDelete": "Cette liste de tâches ne peut pas être supprimée avec vos autorisations actuelles.", + "deleteTaskListConfirmation": "Supprimer « {title} » et toutes ses tâches ?", + "unshareTaskListConfirmation": "Ne plus partager « {title} » avec ce compte ?", + "taskStatus": "État", + "taskStatusNone": "Aucun état", + "taskStatusNeedsAction": "Action requise", "taskStatusInProcess": "En cours", - "taskStatusCompleted": "Terminé", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Terminée", + "taskStatusCancelled": "Annulée", + "completionPercent": "{percent} % terminée", + "completionDate": "Date d’achèvement", "priority": "Priorité", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "Aucune priorité", + "priorityHighValue": "Priorité {priority} · élevée", + "priorityMediumValue": "Priorité {priority} · moyenne", + "priorityLowValue": "Priorité {priority} · faible", + "taskUrl": "URL de la tâche", + "invalidTaskUrl": "Saisissez une URL absolue, avec son schéma.", + "classification": "Catégorisation", + "classificationPublic": "Lorsqu’elle est partagée, afficher la tâche complète", + "classificationConfidential": "Lorsqu’elle est partagée, afficher uniquement l’état occupé", + "classificationPrivate": "Lorsqu’elle est partagée, masquer cette tâche", + "pinTask": "Épingler la tâche", + "reminders": "Rappels", "noReminders": "Aucun rappel", - "editReminder": "Edit reminder", + "editReminder": "Modifier le rappel", "beforeTaskStarts": "Avant le début de la tâche", - "beforeTaskDue": "Avant l'échéance de la tâche", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Répéter chaque", - "repeatOn": "Repeat on", - "repeatEnd": "Arrêter la répétition", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "le {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", + "beforeTaskDue": "Avant l’échéance de la tâche", + "afterTaskStarts": "Après le début de la tâche", + "afterTaskDue": "Après l’échéance de la tâche", + "relativeToTaskStart": "Par rapport à la date de début de la tâche", + "relativeToTaskDue": "Par rapport à la date d’échéance de la tâche", + "reminderTimeOfDay": "Heure de la journée", + "absoluteReminder": "À une date et une heure", + "reminderAmount": "Quantité", + "reminderUnitSeconds": "Secondes", + "reminderUnitMinutes": "min", + "reminderUnitHours": "Heures", + "reminderUnitDays": "Jours", + "reminderUnitWeeks": "Semaines", + "reminderAtTaskStart": "Au début de la tâche", + "reminderAtTaskDue": "À l’échéance de la tâche", + "unsupportedReminder": "Ce type de rappel est conservé, mais son heure ne peut pas être modifiée.", + "relatedRemindersTitle": "Conserver les rappels associés ?", + "relatedRemindersDescription": "Cette date comporte {count} rappels associés. Les conserver à leur date et heure actuelles ?", + "discardRelatedReminders": "Supprimer les rappels", + "keepRelatedReminders": "Conserver les rappels", + "repeatEvery": "Intervalle", + "repeatOn": "Répéter le", + "repeatEnd": "Terminer la répétition", + "repeatNever": "Jamais", + "repeatUntil": "À une date", + "repeatAfter": "Après un certain nombre de répétitions", + "repeatCount": "Nombre d’occurrences", + "repeatDayOfMonth": "Jours du mois", + "repeatMonths": "Mois", + "repeatOrdinal": "Position du jour de la semaine", + "repeatSpecificDays": "Jours précis", + "repeatFirst": "Premier", + "repeatSecond": "Deuxième", + "repeatThird": "Troisième", + "repeatFourth": "Quatrième", + "repeatFifth": "Cinquième", + "repeatSecondToLast": "Avant-dernier", + "repeatLast": "Dernier", + "repeatAnyDay": "Jour", + "repeatWeekday": "Jour de semaine", + "repeatWeekendDay": "Jour du week-end", + "repeatEveryDays": "Tous les {count} jours", + "repeatEveryWeeks": "Toutes les {count} semaines", + "repeatEveryMonths": "Tous les {count} mois", + "repeatEveryYears": "Toutes les {count} années", + "repeatOnDaysSummary": "les {days}", + "repeatOnMonthDaysSummary": "le {days} du mois", + "repeatOnOrdinalSummary": "le {ordinal} {days}", + "repeatInMonthsSummary": "en {months}", "repeatTimesSummary": "{count} fois", - "repeatUntilSummary": "jusqu'au {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatUntilSummary": "jusqu’au {date}", + "unsupportedRecurrencePreserved": "Cette règle de récurrence utilise des options que cet éditeur ne modifie pas.", "recurrenceUnsupportedByProvider": "Cette récurrence ne peut pas être utilisée avec {provider}.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "duplicateTask": "Dupliquer la tâche", + "taskDuplicated": "Tâche dupliquée.", + "taskDuplicateFailed": "Impossible de dupliquer la tâche : {error}", "hideSubtasks": "Masquer les sous-tâches", - "hideClosedSubtasks": "Masquer les sous-tâches fermées", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "Masquer les sous-tâches terminées", + "reminderUnit": "Unité", + "attendeeRequired": "Obligatoire", + "attendeeOptional": "Facultatif", + "meetingSection": "Réunion", + "addGoogleMeet": "Ajouter Google Meet", + "addTeamsMeeting": "Ajouter une réunion Microsoft Teams", + "onlineMeetingAdded": "Réunion en ligne ajoutée", + "requestResponses": "Demander des réponses", + "requestResponsesDescription": "Demandez aux invités de répondre à l’invitation.", + "hideGuestList": "Masquer la liste des invités", + "hideGuestListDescription": "Les invités ne peuvent pas voir qui d’autre a été invité.", + "allowNewTimeProposals": "Autoriser les nouvelles propositions d’horaire", + "allowNewTimeProposalsDescription": "Les invités peuvent proposer une autre heure de réunion.", + "notifyGuestsTitle": "Notifier les invités ?", + "notifyGuestsSaveMessage": "Cette réunion comporte des invités. Envoyer les invitations ou les mises à jour de l’événement lors de l’enregistrement ?", + "notifyGuestsDeleteMessage": "Cette réunion comporte des invités. Envoyer une annulation lors de sa suppression ?", + "sendUpdates": "Envoyer les mises à jour", + "sendCancellation": "Envoyer l’annulation", + "doNotSend": "Ne pas envoyer", + "microsoftNotifyGuestsSaveTitle": "Enregistrer la réunion ?", + "microsoftNotifyGuestsSaveMessage": "Microsoft enverra les invitations ou les mises à jour de l’événement aux invités.", + "microsoftNotifyGuestsDeleteTitle": "Supprimer la réunion ?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft enverra une annulation aux invités.", + "organizer": "Organisateur", + "yourResponse": "Votre réponse", + "guestResponses": "Réponses des invités", + "respond": "Répondre", + "acceptInvitation": "Accepter", + "tentativeInvitation": "Provisoire", + "declineInvitation": "Refuser", + "joinMeeting": "Rejoindre la réunion", + "responseAccepted": "Acceptée", + "responseTentative": "Provisoire", + "responseDeclined": "Refusée", + "responseNeedsAction": "Réponse attendue", + "responseNotResponded": "Sans réponse", + "responseOrganizer": "Organisateur", + "invitationResponseFailed": "Impossible d’envoyer votre réponse : {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Impossible d’ouvrir le lien de la réunion.", "setCustomCalendarName": "Définir un nom personnalisé", "setAction": "Définir", "removeFromMyCalendars": "Retirer de mes agendas", "removeAction": "Retirer", "removeCalendarConfirmation": "Retirer « {title} » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.", - "calendarCannotRemove": "Cet agenda ne peut pas être supprimé ni retiré de ce compte.", - "calendarPendingChangesPreventRemoval": "Attendez la fin de la synchronisation des modifications en attente de cet agenda avant de le supprimer ou de le retirer.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "Importer un calendrier", - "calendarImportDescription": "Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui les recevra.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarCannotRemove": "Ce calendrier ne peut pas être supprimé ou retiré de ce compte.", + "calendarPendingChangesPreventRemoval": "Attendez que les modifications en attente de ce calendrier aient fini de se synchroniser avant de le supprimer ou de le retirer.", + "calendarSubscriptions": "Abonnements aux calendriers", + "calendarSubscriptionsDescription": "Ajoutez des calendriers en lecture seule qui se mettent à jour depuis une URL WebCal sécurisée.", + "addCalendarSubscription": "Ajouter un abonnement de calendrier", + "subscriptionName": "Nom local", + "subscriptionUrl": "URL de l’abonnement", + "subscriptionUrlHelp": "Saisissez une URL HTTPS ou webcal. BusyMax conserve l’URL complète dans un stockage sécurisé.", + "subscriptionUrlInvalid": "Saisissez une URL HTTPS ou webcal valide, sans informations utilisateur ni fragment.", + "subscriptionColor": "Couleur locale", + "subscriptionColorHelp": "Utilisez une couleur à six chiffres, comme #3584E4.", + "subscriptionColorInvalid": "Saisissez une couleur hexadécimale à six chiffres.", + "subscriptionRefreshMode": "Fréquence d’actualisation", + "subscriptionAutomatic": "Automatique", + "subscriptionHourly": "Toutes les heures", + "subscriptionSixHours": "Toutes les six heures", + "subscriptionDaily": "Tous les jours", + "subscriptionSafeOrigin": "Source : {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Saisissez une URL valide pour prévisualiser son origine sécurisée.", + "subscriptionReadOnly": "Abonnement en lecture seule", + "subscriptionNeverRefreshed": "Pas encore actualisé", + "subscriptionLastRefresh": "Dernière actualisation réussie : {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Prochaine actualisation : {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "À jour", + "subscriptionStatusIssue": "Problème d’actualisation : {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Actualiser maintenant", + "unsubscribe": "Se désabonner", + "unsubscribeCalendarTitle": "Se désabonner de « {name} » ?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Cette action supprime l’abonnement local et ses événements mis en cache. Le calendrier publié n’est pas modifié.", + "addSubscriptionAction": "Ajouter un abonnement", + "subscriptionOperationFailed": "Échec de l’abonnement au calendrier : {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Abonnements", + "calendarImport": "Importation de calendrier", + "calendarImportDescription": "Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui doit les recevoir.", + "importIcsFile": "Importer un fichier .ics", + "importIcsPreview": "Importer des événements du calendrier", + "importEventsFound": "Ensembles d’événements importables : {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Événements non valides : {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Exclus intentionnellement : {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Aucun calendrier de destination modifiable n’est disponible.", + "importDestinationCalendar": "Calendrier de destination", + "importIcsConfirm": "Importer les événements", + "importIcsComplete": "Importation terminée", + "importQueued": "Importés ou mis en file d’attente : {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Doublons ignorés : {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Ensembles de récurrence non pris en charge : {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Impossible d’importer le fichier de calendrier : {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Hors ligne", "networkOfflineDescription": "Les modifications seront synchronisées au rétablissement de la connexion.", "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 5af01e64..d2f07db1 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,14 +1,14 @@ { "@@locale": "hi", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Google, Microsoft, Apple iCloud Calendar या Nextcloud खाते कनेक्ट करें।", "googlePermissionsConsentNotice": "Google अनुमति स्क्रीन पर, कैलेंडर और कार्य दोनों अनुमतियाँ चुनें।", "googlePermissionsRequiredRetry": "Google Calendar और Google Tasks की अनुमतियाँ आवश्यक हैं। फिर से कोशिश करें और दोनों चेकबॉक्स चुनें।", "finishSetup": "सेटअप पूरा करें", "continueSetup": "जारी रखें", "onboardingSetupTitle": "BusyMax सेट अप करें", "onboardingAccountsStepTitle": "खाते कनेक्ट करें", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "वे सभी खाते जोड़ें जिनका आप उपयोग करना चाहते हैं। BusyMax हर खाते से समर्थित कैलेंडर, इवेंट, कार्य सूचियाँ और कार्य सिंक करता है।", "onboardingPreferencesStepTitle": "सिस्टम सेटिंग्स चुनें", "onboardingPreferencesStepDescription": "अपना शेड्यूल खोलने से पहले डेस्कटॉप व्यवहार, रिमाइंडर, सूचनाओं के विवरण का स्तर और दिखावट सेट करें।", "signInWithGoogle": "Google से साइन इन करें", @@ -36,16 +36,40 @@ "newCalendar": "नया कैलेंडर", "calendarColor": "कैलेंडर का रंग", "calendarColorOption": "रंग {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "यह प्रदाता BusyMax में कैलेंडर प्रबंधन का समर्थन नहीं करता है।", "primaryCalendarCannotDelete": "प्राथमिक कैलेंडर को हटाया नहीं जा सकता।", "calendarCreateFailed": "कैलेंडर नहीं बनाया जा सका: {error}", "calendarCreatedRefreshPending": "कैलेंडर बन गया, लेकिन BusyMax खाते को रीफ़्रेश नहीं कर सका। यह अगली सिंक के बाद दिखाई देगा।", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "कैलेंडर अपडेट नहीं किया जा सका: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "कैलेंडर हटाया नहीं जा सका: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "नया ईवेंट", "refreshCalendar": "कैलेंडर रीफ़्रेश करें", "openInProvider": "सेवा में खोलें", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।", "refresh": "रीफ़्रेश करें", "trayOpenBusyMax": "BusyMax खोलें", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "BusyMax दिखाएँ", + "trayNewEvent": "नया इवेंट…", + "trayNewTask": "नया कार्य…", + "trayToday": "आज", + "trayAllDay": "पूरा दिन", + "trayNow": "अभी", + "trayCalendarEvent": "कैलेंडर इवेंट", + "trayUntitledEvent": "बिना शीर्षक का इवेंट", + "trayNothingElseToday": "आज और कुछ नहीं", + "trayTasksDueToday": "{count, plural, =1{आज 1 कार्य की नियत तारीख है} other{आज {count} कार्यों की नियत तारीख है}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "आज का एजेंडा खोलें", + "traySyncNow": "अभी सिंक करें", + "traySyncing": "सिंक हो रहा है…", + "trayNotConnected": "कनेक्ट नहीं है", + "trayNotYetSynced": "अभी तक सिंक नहीं हुआ", + "trayLastSyncedJustNow": "अभी-अभी सिंक हुआ", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 मिनट पहले सिंक हुआ} other{{count} मिनट पहले सिंक हुआ}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 घंटे पहले सिंक हुआ} other{{count} घंटे पहले सिंक हुआ}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 दिन पहले सिंक हुआ} other{{count} दिन पहले सिंक हुआ}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "सेटिंग्स", + "trayQuitBusyMax": "BusyMax बंद करें", "agendaLoadMoreOverdue": "समय-सीमा पार कर चुके अतिरिक्त कार्य लोड करें", "agendaLoadMoreNoDate": "बिना तारीख वाले और कार्य लोड करें", "viewDay": "दिन", @@ -217,8 +265,8 @@ "removeAccount": "खाता हटाएँ…", "removingAccount": "खाता हटाया जा रहा है…", "removeAccountDescription": "सिंक करना बंद करें और इस डिवाइस से इस खाते का डेटा हटाएँ।", - "removeAccountTitle": "BusyMax से {account} हटाएँ?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "{account} को BusyMax से हटाएँ?", + "removeAccountConfirmation": "इससे इस डिवाइस से कैश किए गए कार्य, कैलेंडर, इवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव हट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। प्रदाता की कैलेंडर, इवेंट, कार्य सूची और कार्य प्रतियाँ हटाई नहीं जाएँगी।", "revokeGoogleAccess": "इस Google खाते से BusyMax की पहुँच भी रद्द करें", "revokeGoogleAccessDescription": "दोबारा कनेक्ट करने से पहले आपको फिर से पहुँच देनी होगी।", "removeAccountAction": "खाता हटाएँ", @@ -234,7 +282,7 @@ "deleteList": "सूची मिटाएँ", "builtInMicrosoftList": "अंतर्निहित", "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do की अंतर्निहित सूचियों का नाम बदला या उन्हें मिटाया नहीं जा सकता।", - "deleteListConfirmation": "Google Tasks से “{title}” मिटाएँ?", + "deleteListConfirmation": "Google Tasks से “{title}” हटाएँ?", "deleteEvent": "ईवेंट मिटाएँ", "title": "शीर्षक", "create": "बनाएँ", @@ -391,7 +439,7 @@ "syncFailureNotificationTitle": "BusyMax सिंक विफल", "syncFailureNotificationBody": "बैकग्राउंड सिंक विफल हुआ। {message}", "conflictNotificationTitle": "BusyMax सिंक में टकराव", - "conflictNotificationBody": "एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। {summary}", + "conflictNotificationBody": "लंबित स्थानीय बदलाव अवरुद्ध किया गया। {summary}", "dueTodayNotificationTitle": "आज देय कार्य", "dueTodayNotificationBody": "{count, plural, =1{आज एक कार्य देय है।} other{आज {count} कार्य देय हैं।}}", "eventReminderNotificationTitle": "ईवेंट रिमाइंडर", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "कार्य जल्द देय है।", "notificationOpenAction": "खोलें", "notificationSnoozeAction": "10 मिनट के लिए स्नूज़ करें", - "notificationDismissAction": "खारिज करें", + "notificationDismissAction": "बंद करें", "notificationDetailsHidden": "गोपनीयता सेटिंग्स के कारण विवरण छिपे हुए हैं।", "previousMonth": "पिछला महीना", "nextMonth": "अगला महीना", @@ -415,50 +463,50 @@ "selectTimeZone": "समय क्षेत्र चुनें", "searchLocations": "स्थान खोजें", "noLocationsFound": "कोई स्थान नहीं मिला", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "यह फ़ील्ड आवश्यक है।", + "providerConnectionDescription": "इनमें से किसी प्रदाता से कैलेंडर और कार्य कनेक्ट करें।", + "appleICloudProvider": "Apple iCloud कैलेंडर", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud कार्य", + "addAppleICloudAccount": "Apple iCloud Calendar खाता जोड़ें", + "addNextcloudAccount": "Nextcloud खाता जोड़ें", + "waitingForAppleICloud": "Apple iCloud से कनेक्ट हो रहा है…", + "waitingForNextcloud": "Nextcloud प्राधिकरण की प्रतीक्षा है…", + "connectAppleICloudTitle": "Apple iCloud Calendar कनेक्ट करें", + "appleAccountEmail": "Apple खाते का ईमेल", + "appleAppSpecificPassword": "ऐप-विशिष्ट पासवर्ड", + "appleAppSpecificPasswordHelp": "Apple खाते के लिए दो-चरणीय प्रमाणीकरण चालू करने के बाद ऐप-विशिष्ट पासवर्ड बनाएँ।", + "appleAppSpecificPasswordResetWarning": "Apple खाते का पासवर्ड रीसेट करने से ऐप-विशिष्ट पासवर्ड रद्द हो जाते हैं।", + "connectNextcloudTitle": "Nextcloud कनेक्ट करें", + "nextcloudServerUrl": "Nextcloud सर्वर या CalDAV पता", + "nextcloudServerUrlHelp": "Nextcloud सर्वर URL दर्ज करें या Nextcloud से कॉपी किया गया प्राथमिक CalDAV पता चिपकाएँ।", + "nextcloudBrowserAuthorizationHelp": "BusyMax आपका ब्राउज़र खोलेगा। वहाँ पहुँच की अनुमति दें और फिर BusyMax पर लौटें।", + "connectAccountAction": "कनेक्ट करें", + "cancelAccountConnection": "कनेक्शन रद्द करें", + "nextcloudAccountRemovedRevokeFailed": "खाता स्थानीय रूप से हटाया गया, लेकिन Nextcloud ऐप पासवर्ड रद्द नहीं किया जा सका।", + "davCachedOfflineNotice": "ऑफ़लाइन उपयोग के लिए कैलेंडर और कार्य डेटा स्थानीय रूप से कैश किया जाता है।", + "davReauthenticationRequired": "सिंक फिर से शुरू करने के लिए इस खाते को दोबारा कनेक्ट करें।", + "davTemporarilyUnavailable": "यह खाता अस्थायी रूप से उपलब्ध नहीं है।", + "davPermissionChanged": "सर्वर अनुमतियाँ बदल गई हैं। लंबित संपादन रोक दिए गए हैं।", + "davUnsupportedServer": "यह सर्वर या प्रदाता प्रोफ़ाइल समर्थित नहीं है।", + "collectionSettings": "कैलेंडर और कार्य सूचियाँ", + "calendarContent": "कैलेंडर इवेंट", + "taskContent": "कार्य", + "readOnlySharedCollection": "केवल-पठन", + "pendingLocally": "स्थानीय रूप से लंबित", + "conflictBlocked": "विरोध के कारण अवरुद्ध", + "authenticationBlocked": "दोबारा कनेक्ट होने तक अवरुद्ध", + "operationFailed": "कार्रवाई विफल", + "keepServerVersion": "सर्वर संस्करण रखें", + "reapplyLocalChange": "स्थानीय बदलाव की समीक्षा कर फिर लागू करें", + "duplicateLocalItem": "नए आइटम के रूप में कॉपी बनाएँ", "deleteCalendarConfirmation": "“{title}” मिटाएँ?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "कनेक्शन की स्थिति", + "davConnected": "कनेक्टेड", + "davConnecting": "कनेक्ट हो रहा है…", + "davSignedOut": "साइन आउट किया गया", + "davLastSuccessfulSync": "अंतिम सफल सिंक: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "अभी तक सिंक नहीं हुआ", + "refreshCollections": "कैलेंडर और कार्य सूचियाँ रीफ़्रेश करें", + "nextcloudServerHost": "सर्वर: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "इवेंट कैलेंडर", + "collectionSupportsTasks": "कार्य सूची", + "collectionSupportsEventsAndTasks": "इवेंट और कार्य", + "writableCollection": "लिखने योग्य", + "sharedCollection": "साझा", + "collectionLastSynced": "अंतिम सिंक: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "सिंक समस्या: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "सिंक विरोध", + "remoteChangedAt": "सर्वर में बदलाव: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "स्थानीय संपादन: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "विरोध का समाधान नहीं हो सका।", + "recurringEventScope": "दोहराए जाने वाले इवेंट का दायरा", + "entireSeries": "पूरी शृंखला", "singleOccurrence": "यह इवेंट", "thisAndFollowingEvents": "यह और इसके बाद के इवेंट", "thisAndFutureUnavailable": "यह प्रदाता इसका समर्थन नहीं करता।", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "इवेंट की कॉपी बनाकर मूल इवेंट मिटाएँ?", "copyEventMoveWarning": "BusyMax इस इवेंट को {source} से {destination} में सीधे नहीं ले जा सकता। यह पहले कॉपी बनाएगा और कॉपी सफल होने के बाद ही मूल इवेंट मिटाएगा। इवेंट ID बदलेंगे; सहभागियों की प्रतिक्रिया स्थितियां रीसेट हो सकती हैं और आमंत्रण या रद्दीकरण भेजे जा सकते हैं; और कॉन्फ़्रेंस लिंक, अटैचमेंट, रिमाइंडर, प्रदाता-विशिष्ट फ़ील्ड तथा दोहराव अपवाद शायद स्थानांतरित न हों।", "copyAndDelete": "कॉपी बनाएँ और मिटाएँ", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "चुनें कि यह बदलाव पूरी शृंखला, केवल इस इवेंट या इस और इसके बाद के इवेंट पर लागू हो।", "taskDueBeforeStart": "नियत समय प्रारंभ समय से पहले नहीं हो सकता।", "taskStartDueTimeModeMismatch": "प्रारंभ और नियत समय दोनों सेट करें, या कार्य को पूरे दिन का बनाएँ।", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Unshare", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "Completed", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "Priority", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Repeat every", - "repeatOn": "Repeat on", - "repeatEnd": "End repeat", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count} times", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "taskListCreateFailed": "कार्य सूची बनाई नहीं जा सकी: {error}", + "taskListRenameFailed": "कार्य सूची का नाम बदला नहीं जा सका: {error}", + "taskListDeleteFailed": "कार्य सूची हटाई नहीं जा सकी: {error}", + "unshare": "साझा करना बंद करें", + "readOnlyTaskListCannotRename": "यह कार्य सूची केवल-पठन है और इसका नाम नहीं बदला जा सकता।", + "taskListCannotDelete": "आपकी मौजूदा अनुमतियों से यह कार्य सूची हटाई नहीं जा सकती।", + "deleteTaskListConfirmation": "“{title}” और इसके सभी कार्य हटाएँ?", + "unshareTaskListConfirmation": "इस खाते से “{title}” साझा करना बंद करें?", + "taskStatus": "स्थिति", + "taskStatusNone": "कोई स्थिति नहीं", + "taskStatusNeedsAction": "कार्रवाई आवश्यक", + "taskStatusInProcess": "प्रगति में", + "taskStatusCompleted": "पूर्ण", + "taskStatusCancelled": "रद्द", + "completionPercent": "{percent}% पूर्ण", + "completionDate": "पूर्ण होने की तारीख", + "priority": "प्राथमिकता", + "priorityNone": "कोई प्राथमिकता नहीं", + "priorityHighValue": "प्राथमिकता {priority} · उच्च", + "priorityMediumValue": "प्राथमिकता {priority} · मध्यम", + "priorityLowValue": "प्राथमिकता {priority} · निम्न", + "taskUrl": "कार्य URL", + "invalidTaskUrl": "स्कीम सहित पूर्ण URL दर्ज करें।", + "classification": "वर्गीकरण", + "classificationPublic": "साझा होने पर पूरा कार्य दिखाएँ", + "classificationConfidential": "साझा होने पर केवल व्यस्तता दिखाएँ", + "classificationPrivate": "साझा होने पर यह कार्य छिपाएँ", + "pinTask": "कार्य पिन करें", + "reminders": "रिमाइंडर", + "noReminders": "कोई रिमाइंडर नहीं", + "editReminder": "रिमाइंडर संपादित करें", + "beforeTaskStarts": "कार्य शुरू होने से पहले", + "beforeTaskDue": "कार्य की नियत तारीख से पहले", + "afterTaskStarts": "कार्य शुरू होने के बाद", + "afterTaskDue": "कार्य की नियत तारीख के बाद", + "relativeToTaskStart": "कार्य शुरू होने की तारीख के सापेक्ष", + "relativeToTaskDue": "कार्य की नियत तारीख के सापेक्ष", + "reminderTimeOfDay": "दिन का समय", + "absoluteReminder": "तारीख और समय पर", + "reminderAmount": "मात्रा", + "reminderUnitSeconds": "सेकंड", + "reminderUnitMinutes": "मिनट", + "reminderUnitHours": "घंटे", + "reminderUnitDays": "दिन", + "reminderUnitWeeks": "सप्ताह", + "reminderAtTaskStart": "कार्य शुरू होने पर", + "reminderAtTaskDue": "कार्य की नियत तारीख पर", + "unsupportedReminder": "यह रिमाइंडर प्रकार सुरक्षित है, लेकिन इसका समय संपादित नहीं किया जा सकता।", + "relatedRemindersTitle": "संबंधित रिमाइंडर रखें?", + "relatedRemindersDescription": "इस तारीख पर {count} संबंधित रिमाइंडर हैं। इन्हें उनकी मौजूदा तारीख और समय पर रखें?", + "discardRelatedReminders": "रिमाइंडर हटाएँ", + "keepRelatedReminders": "रिमाइंडर रखें", + "repeatEvery": "दोहराव अंतराल", + "repeatOn": "दोहराएँ", + "repeatEnd": "दोहराव समाप्त करें", + "repeatNever": "कभी नहीं", + "repeatUntil": "तारीख तक", + "repeatAfter": "कई बार दोहराने के बाद", + "repeatCount": "दोहराव की संख्या", + "repeatDayOfMonth": "महीने के दिन", + "repeatMonths": "महीने", + "repeatOrdinal": "सप्ताह के दिन की स्थिति", + "repeatSpecificDays": "विशिष्ट दिन", + "repeatFirst": "पहला", + "repeatSecond": "दूसरा", + "repeatThird": "तीसरा", + "repeatFourth": "चौथा", + "repeatFifth": "पाँचवाँ", + "repeatSecondToLast": "अंतिम से दूसरा", + "repeatLast": "अंतिम", + "repeatAnyDay": "दिन", + "repeatWeekday": "सप्ताह का दिन", + "repeatWeekendDay": "सप्ताहांत का दिन", + "repeatEveryDays": "हर {count} दिन", + "repeatEveryWeeks": "हर {count} सप्ताह", + "repeatEveryMonths": "हर {count} महीने", + "repeatEveryYears": "हर {count} साल", + "repeatOnDaysSummary": "{days} को", + "repeatOnMonthDaysSummary": "महीने के {days}वें दिन", + "repeatOnOrdinalSummary": "{days} के {ordinal} दिन", + "repeatInMonthsSummary": "{months} में", + "repeatTimesSummary": "{count} बार", + "repeatUntilSummary": "{date} तक", + "unsupportedRecurrencePreserved": "यह दोहराव नियम ऐसे विकल्पों का उपयोग करता है जिन्हें यह संपादक नहीं बदलता।", "recurrenceUnsupportedByProvider": "इस पुनरावृत्ति का उपयोग {provider} के साथ नहीं किया जा सकता।", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "duplicateTask": "कार्य की प्रतिलिपि बनाएँ", + "taskDuplicated": "कार्य की प्रतिलिपि बन गई।", + "taskDuplicateFailed": "कार्य की प्रतिलिपि नहीं बन सकी: {error}", + "hideSubtasks": "उपकार्य छिपाएँ", + "hideClosedSubtasks": "बंद उपकार्य छिपाएँ", + "reminderUnit": "इकाई", + "attendeeRequired": "ज़रूरी", + "attendeeOptional": "वैकल्पिक", + "meetingSection": "मीटिंग", + "addGoogleMeet": "Google Meet जोड़ें", + "addTeamsMeeting": "Microsoft Teams मीटिंग जोड़ें", + "onlineMeetingAdded": "ऑनलाइन मीटिंग जोड़ी गई", + "requestResponses": "जवाब माँगें", + "requestResponsesDescription": "अतिथियों से आमंत्रण का जवाब देने को कहें।", + "hideGuestList": "अतिथि सूची छिपाएँ", + "hideGuestListDescription": "अतिथि यह नहीं देख सकते कि और किसे आमंत्रित किया गया है।", + "allowNewTimeProposals": "नए समय प्रस्तावों की अनुमति दें", + "allowNewTimeProposalsDescription": "अतिथि मीटिंग का अलग समय सुझा सकते हैं।", + "notifyGuestsTitle": "अतिथियों को सूचित करें?", + "notifyGuestsSaveMessage": "इस मीटिंग में अतिथि हैं। सहेजते समय आमंत्रण या इवेंट अपडेट भेजें?", + "notifyGuestsDeleteMessage": "इस मीटिंग में अतिथि हैं। हटाते समय रद्दीकरण भेजें?", + "sendUpdates": "अपडेट भेजें", + "sendCancellation": "रद्दीकरण भेजें", + "doNotSend": "न भेजें", + "microsoftNotifyGuestsSaveTitle": "मीटिंग सहेजें?", + "microsoftNotifyGuestsSaveMessage": "Microsoft अतिथियों को आमंत्रण या इवेंट अपडेट भेजेगा।", + "microsoftNotifyGuestsDeleteTitle": "मीटिंग हटाएँ?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft अतिथियों को रद्दीकरण भेजेगा।", + "organizer": "आयोजक", + "yourResponse": "आपका जवाब", + "guestResponses": "अतिथियों के जवाब", + "respond": "जवाब दें", + "acceptInvitation": "स्वीकार करें", + "tentativeInvitation": "अनिश्चित", + "declineInvitation": "अस्वीकार करें", + "joinMeeting": "मीटिंग में शामिल हों", + "responseAccepted": "स्वीकृत", + "responseTentative": "अनिश्चित", + "responseDeclined": "अस्वीकृत", + "responseNeedsAction": "जवाब की प्रतीक्षा है", + "responseNotResponded": "जवाब नहीं दिया", + "responseOrganizer": "आयोजक", + "invitationResponseFailed": "आपका जवाब भेजा नहीं जा सका: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "मीटिंग लिंक खोला नहीं जा सका।", "setCustomCalendarName": "कस्टम नाम सेट करें", "setAction": "सेट करें", "removeFromMyCalendars": "मेरे कैलेंडर से हटाएँ", "removeAction": "हटाएँ", "removeCalendarConfirmation": "क्या \"{title}\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।", - "calendarCannotRemove": "इस कैलेंडर को इस खाते से मिटाया या हटाया नहीं जा सकता।", - "calendarPendingChangesPreventRemoval": "इस कैलेंडर को मिटाने या हटाने से पहले इसके लंबित बदलावों का सिंक पूरा होने दें।", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", + "calendarCannotRemove": "इस कैलेंडर को इस खाते से हटाया या मिटाया नहीं जा सकता।", + "calendarPendingChangesPreventRemoval": "कैलेंडर हटाने से पहले उसके लंबित बदलावों का सिंक पूरा होने की प्रतीक्षा करें।", + "calendarSubscriptions": "कैलेंडर सदस्यताएँ", + "calendarSubscriptionsDescription": "सुरक्षित WebCal URL से रीफ़्रेश होने वाले केवल-पठन कैलेंडर जोड़ें।", + "addCalendarSubscription": "कैलेंडर सदस्यता जोड़ें", + "subscriptionName": "स्थानीय नाम", + "subscriptionUrl": "सदस्यता URL", + "subscriptionUrlHelp": "HTTPS या webcal URL दर्ज करें। BusyMax पूरा URL सुरक्षित संग्रहण में रखता है।", + "subscriptionUrlInvalid": "उपयोगकर्ता जानकारी या फ़्रैगमेंट के बिना मान्य HTTPS या webcal URL दर्ज करें।", + "subscriptionColor": "स्थानीय रंग", + "subscriptionColorHelp": "#3584E4 जैसे छह अंकों वाले रंग का उपयोग करें।", + "subscriptionColorInvalid": "छह अंकों वाला हेक्साडेसिमल रंग दर्ज करें।", + "subscriptionRefreshMode": "रीफ़्रेश आवृत्ति", + "subscriptionAutomatic": "स्वचालित", + "subscriptionHourly": "हर घंटे", + "subscriptionSixHours": "हर छह घंटे", + "subscriptionDaily": "हर दिन", + "subscriptionSafeOrigin": "स्रोत: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "सुरक्षित स्रोत का पूर्वावलोकन करने के लिए मान्य URL दर्ज करें।", + "subscriptionReadOnly": "केवल-पठन सदस्यता", + "subscriptionNeverRefreshed": "अभी तक रीफ़्रेश नहीं हुआ", + "subscriptionLastRefresh": "अंतिम सफल रीफ़्रेश: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "अगला रीफ़्रेश: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "अप टू डेट", + "subscriptionStatusIssue": "रीफ़्रेश समस्या: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "अभी रीफ़्रेश करें", + "unsubscribe": "सदस्यता रद्द करें", + "unsubscribeCalendarTitle": "“{name}” की सदस्यता रद्द करें?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "इससे स्थानीय सदस्यता और कैश किए गए इवेंट हट जाएँगे। प्रकाशित कैलेंडर नहीं बदलेगा।", + "addSubscriptionAction": "सदस्यता जोड़ें", + "subscriptionOperationFailed": "कैलेंडर सदस्यता विफल: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "सदस्यताएँ", "calendarImport": "कैलेंडर आयात", - "calendarImportDescription": "फ़ाइल चुनें, उसके इवेंट की समीक्षा करें, फिर वह लिखने योग्य कैलेंडर चुनें जिसमें उन्हें आयात करना है।", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarImportDescription": "फ़ाइल चुनें, उसके इवेंट की समीक्षा करें और फिर वह लिखने योग्य कैलेंडर चुनें जिसे उन्हें प्राप्त करना चाहिए।", + "importIcsFile": ".ics फ़ाइल आयात करें", + "importIcsPreview": "कैलेंडर इवेंट आयात करें", + "importEventsFound": "आयात योग्य इवेंट सेट: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "अमान्य इवेंट: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "जानबूझकर छोड़े गए: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "कोई लिखने योग्य गंतव्य कैलेंडर उपलब्ध नहीं है।", + "importDestinationCalendar": "गंतव्य कैलेंडर", + "importIcsConfirm": "इवेंट आयात करें", + "importIcsComplete": "आयात पूरा हुआ", + "importQueued": "आयातित या कतार में: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "डुप्लिकेट छोड़े गए: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "असमर्थित दोहराव सेट: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "कैलेंडर फ़ाइल आयात नहीं की जा सकी: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "ऑफ़लाइन", "networkOfflineDescription": "कनेक्शन वापस आने पर बदलाव सिंक हो जाएंगे।", "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।" diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 151106df..46702456 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,14 +1,14 @@ { "@@locale": "it", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Collega gli account Google, Microsoft, Calendario Apple iCloud o Nextcloud.", "googlePermissionsConsentNotice": "Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.", "googlePermissionsRequiredRetry": "Sono necessarie le autorizzazioni per Google Calendar e Google Tasks. Riprova e seleziona entrambe le caselle.", "finishSetup": "Completa la configurazione", "continueSetup": "Continua", "onboardingSetupTitle": "Configura BusyMax", "onboardingAccountsStepTitle": "Collega gli account", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Aggiungi tutti gli account che vuoi usare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività supportati da ogni account.", "onboardingPreferencesStepTitle": "Scegli le impostazioni di sistema", "onboardingPreferencesStepDescription": "Prima di aprire l’agenda, configura il comportamento sul desktop, i promemoria, il livello di dettaglio delle notifiche e l’aspetto.", "signInWithGoogle": "Accedi con Google", @@ -36,16 +36,40 @@ "newCalendar": "Nuovo calendario", "calendarColor": "Colore del calendario", "calendarColorOption": "Colore {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "Questo provider non supporta la gestione dei calendari in BusyMax.", "primaryCalendarCannotDelete": "Il calendario principale non può essere eliminato.", "calendarCreateFailed": "Impossibile creare il calendario: {error}", "calendarCreatedRefreshPending": "Il calendario è stato creato, ma BusyMax non ha potuto aggiornare l’account. Apparirà dopo la prossima sincronizzazione.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Impossibile aggiornare il calendario: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Impossibile eliminare il calendario: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Nuovo evento", "refreshCalendar": "Aggiorna calendario", "openInProvider": "Apri nel servizio", @@ -65,38 +89,62 @@ "scheduleNoSearchResultsDescription": "Prova una ricerca diversa o cancella i filtri attuali.", "refresh": "Aggiorna", "trayOpenBusyMax": "Apri BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Mostra BusyMax", + "trayNewEvent": "Nuovo evento…", + "trayNewTask": "Nuova attività…", + "trayToday": "Oggi", + "trayAllDay": "Tutto il giorno", + "trayNow": "Adesso", + "trayCalendarEvent": "Evento del calendario", + "trayUntitledEvent": "Evento senza titolo", + "trayNothingElseToday": "Nient’altro oggi", + "trayTasksDueToday": "{count, plural, =1{1 attività scade oggi} other{{count} attività scadono oggi}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Apri l’agenda di oggi", + "traySyncNow": "Sincronizza ora", + "traySyncing": "Sincronizzazione…", + "trayNotConnected": "Non connesso", + "trayNotYetSynced": "Non ancora sincronizzato", + "trayLastSyncedJustNow": "Sincronizzato proprio ora", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizzato 1 minuto fa} other{Sincronizzato {count} minuti fa}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizzato 1 ora fa} other{Sincronizzato {count} ore fa}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizzato 1 giorno fa} other{Sincronizzato {count} giorni fa}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Impostazioni", + "trayQuitBusyMax": "Esci da BusyMax", "agendaLoadMoreOverdue": "Carica altre attività scadute", "agendaLoadMoreNoDate": "Carica altre attività senza data", "viewDay": "Giorno", "viewWeek": "Settimana", "viewMonth": "Mese", "viewYear": "Anno", - "viewAgenda": "Agenda", + "viewAgenda": "Vista agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Visualizzazione agenda", "scheduleDisplayHoursDescription": "Le viste Giorno e Settimana mostrano inizialmente questo intervallo orario. Gli elementi precedenti o successivi lo estendono quando necessario.", @@ -218,7 +266,7 @@ "removingAccount": "Rimozione account…", "removeAccountDescription": "Interrompi la sincronizzazione e rimuovi i dati di questo account dal dispositivo.", "removeAccountTitle": "Rimuovere {account} da BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Questa operazione elimina da questo dispositivo attività, calendari, eventi, promemoria e modifiche offline in sospeso memorizzati nella cache. Le modifiche non sincronizzate andranno perse. Le copie del provider non verranno eliminate.", "revokeGoogleAccess": "Revoca anche l’accesso di BusyMax a questo account Google", "revokeGoogleAccessDescription": "Dovrai concedere nuovamente l’accesso prima di riconnetterti.", "removeAccountAction": "Rimuovi account", @@ -234,7 +282,7 @@ "deleteList": "Elimina elenco", "builtInMicrosoftList": "Integrato", "builtInMicrosoftListCannotRenameDelete": "Gli elenchi integrati di Microsoft To Do non possono essere rinominati o eliminati.", - "deleteListConfirmation": "Eliminare «{title}» da Google Tasks?", + "deleteListConfirmation": "Eliminare “{title}” da Google Tasks?", "deleteEvent": "Elimina evento", "title": "Titolo", "create": "Crea", @@ -327,7 +375,7 @@ "localState": "Stato locale", "pendingSync": "Sincronizzazione in sospeso", "synced": "Sincronizzato", - "account": "Account", + "account": "Profilo", "sync": "Sincronizzazione", "manualFullSync": "Sincronizzazione completa manuale", "runInBackgroundWhenClosed": "Continua a funzionare quando la finestra è chiusa", @@ -362,7 +410,7 @@ "themeFamilyYaru": "Tema nativo di Ubuntu (Yaru)", "localization": "Lingua e area geografica", "currentLocale": "Impostazioni locali correnti", - "privacy": "Privacy", + "privacy": "Riservatezza", "redactTaskContentInDiagnostics": "Nascondi il contenuto delle attività nelle informazioni diagnostiche", "developerDiagnostics": "Diagnostica per sviluppatori", "diagnostics": "Diagnostica", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "L’attività scadrà a breve.", "notificationOpenAction": "Apri", "notificationSnoozeAction": "Posticipa di 10 minuti", - "notificationDismissAction": "Ignora", + "notificationDismissAction": "Chiudi", "notificationDetailsHidden": "I dettagli sono nascosti dalle impostazioni sulla privacy.", "previousMonth": "Mese precedente", "nextMonth": "Mese successivo", @@ -413,52 +461,52 @@ "scheduleItemCount": "{count, plural, =1{1 elemento} other{{count} elementi}}", "readOnlyCalendar": "Questo calendario è di sola lettura.", "selectTimeZone": "Seleziona fuso orario", - "searchLocations": "Cerca luoghi", - "noLocationsFound": "Nessun luogo trovato", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "searchLocations": "Cerca località", + "noLocationsFound": "Nessuna località trovata", + "requiredField": "Questo campo è obbligatorio.", + "providerConnectionDescription": "Collega calendari e attività a uno di questi provider.", + "appleICloudProvider": "Calendario Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Attività Nextcloud", + "addAppleICloudAccount": "Aggiungi account Calendario Apple iCloud", + "addNextcloudAccount": "Aggiungi account Nextcloud", + "waitingForAppleICloud": "Connessione ad Apple iCloud…", + "waitingForNextcloud": "In attesa dell’autorizzazione Nextcloud…", + "connectAppleICloudTitle": "Collega Calendario Apple iCloud", + "appleAccountEmail": "E-mail dell’account Apple", + "appleAppSpecificPassword": "Password specifica per l’app", + "appleAppSpecificPasswordHelp": "Crea una password specifica per l’app dopo aver attivato l’autenticazione a due fattori per il tuo account Apple.", + "appleAppSpecificPasswordResetWarning": "La reimpostazione della password dell’account Apple revoca le password specifiche per le app.", + "connectNextcloudTitle": "Collega Nextcloud", + "nextcloudServerUrl": "Server Nextcloud o indirizzo CalDAV", + "nextcloudServerUrlHelp": "Inserisci l’URL del server Nextcloud o incolla l’indirizzo CalDAV principale copiato da Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax aprirà il browser. Approva l’accesso e torna a BusyMax.", + "connectAccountAction": "Collega", + "cancelAccountConnection": "Annulla connessione", + "nextcloudAccountRemovedRevokeFailed": "L’account è stato rimosso localmente, ma non è stato possibile revocare la password dell’app Nextcloud.", + "davCachedOfflineNotice": "I dati di calendari e attività vengono memorizzati localmente per l’uso offline.", + "davReauthenticationRequired": "Ricollega questo account per riprendere la sincronizzazione.", + "davTemporarilyUnavailable": "Questo account è temporaneamente non disponibile.", + "davPermissionChanged": "Le autorizzazioni del server sono cambiate. Le modifiche in sospeso sono in pausa.", + "davUnsupportedServer": "Questo server o profilo provider non è supportato.", + "collectionSettings": "Calendari ed elenchi di attività", + "calendarContent": "Eventi del calendario", + "taskContent": "Attività", + "readOnlySharedCollection": "Sola lettura", + "pendingLocally": "In sospeso localmente", + "conflictBlocked": "Bloccato da un conflitto", + "authenticationBlocked": "Bloccato fino alla riconnessione", + "operationFailed": "Operazione non riuscita", + "keepServerVersion": "Mantieni la versione del server", + "reapplyLocalChange": "Esamina e riapplica la modifica locale", + "duplicateLocalItem": "Duplica come nuovo elemento", "deleteCalendarConfirmation": "Eliminare «{title}»?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Stato della connessione", + "davConnected": "Connesso", + "davConnecting": "Connessione…", + "davSignedOut": "Disconnesso", + "davLastSuccessfulSync": "Ultima sincronizzazione riuscita: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Non ancora sincronizzato", + "refreshCollections": "Aggiorna calendari ed elenchi di attività", + "nextcloudServerHost": "Indirizzo server: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Calendario eventi", + "collectionSupportsTasks": "Elenco di attività", + "collectionSupportsEventsAndTasks": "Eventi e attività", + "writableCollection": "Scrivibile", + "sharedCollection": "Condiviso", + "collectionLastSynced": "Ultima sincronizzazione: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Problema di sincronizzazione: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Conflitti di sincronizzazione", + "remoteChangedAt": "Modifica del server: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Modifica locale: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "Impossibile risolvere il conflitto.", + "recurringEventScope": "Ambito dell’evento ricorrente", + "entireSeries": "Intera serie", "singleOccurrence": "Questo evento", "thisAndFollowingEvents": "Questo evento e quelli successivi", "thisAndFutureUnavailable": "Non supportato da questo provider.", @@ -525,205 +573,289 @@ "copyEventAndDeleteOriginal": "Copiare l’evento ed eliminare l’originale?", "copyEventMoveWarning": "BusyMax non può spostare direttamente questo evento da {source} a {destination}. Creerà prima la copia ed eliminerà l’originale solo dopo che la copia sarà riuscita. Gli ID dell’evento cambieranno; gli stati di risposta dei partecipanti potrebbero essere reimpostati e potrebbero essere inviati inviti o annullamenti; i link alle riunioni, gli allegati, i promemoria, i campi specifici del provider e le eccezioni di ricorrenza potrebbero non essere trasferiti.", "copyAndDelete": "Copia ed elimina", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Scegli se questa modifica si applica all’intera serie, solo a questo evento oppure a questo e agli eventi successivi.", "taskDueBeforeStart": "La scadenza non può precedere l'inizio.", - "taskStartDueTimeModeMismatch": "Imposta un orario sia per l'inizio sia per la scadenza, oppure rendi l'attività valida per l'intera giornata.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskStartDueTimeModeMismatch": "Imposta un orario sia per l’inizio sia per la scadenza, oppure rendi l’attività valida per l’intera giornata.", + "taskListCreateFailed": "Impossibile creare l’elenco di attività: {error}", + "taskListRenameFailed": "Impossibile rinominare l’elenco di attività: {error}", + "taskListDeleteFailed": "Impossibile eliminare l’elenco di attività: {error}", "unshare": "Rimuovi condivisione", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "Questo elenco di attività è di sola lettura e non può essere rinominato.", + "taskListCannotDelete": "Questo elenco di attività non può essere eliminato con le autorizzazioni attuali.", + "deleteTaskListConfirmation": "Eliminare “{title}” e tutte le sue attività?", + "unshareTaskListConfirmation": "Rimuovere la condivisione di “{title}” da questo account?", + "taskStatus": "Stato", + "taskStatusNone": "Nessuno stato", "taskStatusNeedsAction": "Richiede azione", "taskStatusInProcess": "In corso", - "taskStatusCompleted": "Completato", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCompleted": "Completata", + "taskStatusCancelled": "Annullata", + "completionPercent": "{percent}% completata", + "completionDate": "Data di completamento", "priority": "Priorità", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "Nessuna priorità", + "priorityHighValue": "Priorità {priority} · alta", + "priorityMediumValue": "Priorità {priority} · media", + "priorityLowValue": "Priorità {priority} · bassa", + "taskUrl": "URL dell’attività", + "invalidTaskUrl": "Inserisci un URL assoluto, incluso lo schema.", + "classification": "Classificazione", + "classificationPublic": "Quando condivisa, mostra l’attività completa", + "classificationConfidential": "Quando condivisa, mostra solo lo stato occupato", + "classificationPrivate": "Quando condivisa, nascondi questa attività", + "pinTask": "Fissa attività", + "reminders": "Promemoria", "noReminders": "Nessun promemoria", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Prima dell'inizio dell'attività", - "beforeTaskDue": "Prima della scadenza del compito", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Scarta i promemoria", - "keepRelatedReminders": "Mantieni i promemoria", - "repeatEvery": "Ripeti ogni", - "repeatOn": "Repeat on", + "editReminder": "Modifica promemoria", + "beforeTaskStarts": "Prima dell’inizio dell’attività", + "beforeTaskDue": "Prima della scadenza dell’attività", + "afterTaskStarts": "Dopo l’inizio dell’attività", + "afterTaskDue": "Dopo la scadenza dell’attività", + "relativeToTaskStart": "Rispetto alla data di inizio dell’attività", + "relativeToTaskDue": "Rispetto alla data di scadenza dell’attività", + "reminderTimeOfDay": "Ora del giorno", + "absoluteReminder": "A una data e un’ora", + "reminderAmount": "Quantità", + "reminderUnitSeconds": "Secondi", + "reminderUnitMinutes": "Minuti", + "reminderUnitHours": "Ore", + "reminderUnitDays": "Giorni", + "reminderUnitWeeks": "Settimane", + "reminderAtTaskStart": "All’inizio dell’attività", + "reminderAtTaskDue": "Alla scadenza dell’attività", + "unsupportedReminder": "Questo tipo di promemoria viene conservato, ma non è possibile modificarne l’ora.", + "relatedRemindersTitle": "Conservare i promemoria correlati?", + "relatedRemindersDescription": "Questa data ha {count} promemoria correlati. Conservarli alla data e all’ora attuali?", + "discardRelatedReminders": "Scarta promemoria", + "keepRelatedReminders": "Conserva promemoria", + "repeatEvery": "Intervallo", + "repeatOn": "Ripeti il", "repeatEnd": "Termina ripetizione", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", + "repeatNever": "Mai", + "repeatUntil": "In data", + "repeatAfter": "Dopo un numero di occorrenze", + "repeatCount": "Occorrenze", + "repeatDayOfMonth": "Giorni del mese", + "repeatMonths": "Mesi", + "repeatOrdinal": "Posizione del giorno della settimana", + "repeatSpecificDays": "Giorni specifici", + "repeatFirst": "Primo", + "repeatSecond": "Secondo", + "repeatThird": "Terzo", + "repeatFourth": "Quarto", + "repeatFifth": "Quinto", + "repeatSecondToLast": "Penultimo", + "repeatLast": "Ultimo", + "repeatAnyDay": "Giorno", + "repeatWeekday": "Giorno feriale", + "repeatWeekendDay": "Giorno del fine settimana", + "repeatEveryDays": "Ogni {count} giorni", + "repeatEveryWeeks": "Ogni {count} settimane", + "repeatEveryMonths": "Ogni {count} mesi", + "repeatEveryYears": "Ogni {count} anni", + "repeatOnDaysSummary": "il {days}", "repeatOnMonthDaysSummary": "il giorno {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", + "repeatOnOrdinalSummary": "il {ordinal} {days}", + "repeatInMonthsSummary": "nei mesi {months}", "repeatTimesSummary": "{count} volte", "repeatUntilSummary": "fino al {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "Questa regola di ricorrenza usa opzioni che questo editor non modifica.", "recurrenceUnsupportedByProvider": "Questa ricorrenza non può essere usata con {provider}.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "duplicateTask": "Duplica attività", + "taskDuplicated": "Attività duplicata.", + "taskDuplicateFailed": "Impossibile duplicare l’attività: {error}", "hideSubtasks": "Nascondi sottoattività", - "hideClosedSubtasks": "Nascondi sotto-attività chiuse", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "Nascondi sottoattività chiuse", + "reminderUnit": "Unità", + "attendeeRequired": "Obbligatorio", + "attendeeOptional": "Facoltativo", + "meetingSection": "Riunione", + "addGoogleMeet": "Aggiungi Google Meet", + "addTeamsMeeting": "Aggiungi riunione Microsoft Teams", + "onlineMeetingAdded": "Riunione online aggiunta", + "requestResponses": "Richiedi risposte", + "requestResponsesDescription": "Chiedi agli invitati di rispondere all’invito.", + "hideGuestList": "Nascondi elenco invitati", + "hideGuestListDescription": "Gli invitati non possono vedere chi altro è stato invitato.", + "allowNewTimeProposals": "Consenti nuove proposte di orario", + "allowNewTimeProposalsDescription": "Gli invitati possono suggerire un altro orario per la riunione.", + "notifyGuestsTitle": "Notificare gli invitati?", + "notifyGuestsSaveMessage": "Questa riunione ha degli invitati. Inviare inviti o aggiornamenti dell’evento al salvataggio?", + "notifyGuestsDeleteMessage": "Questa riunione ha degli invitati. Inviare una cancellazione quando viene eliminata?", + "sendUpdates": "Invia aggiornamenti", + "sendCancellation": "Invia cancellazione", + "doNotSend": "Non inviare", + "microsoftNotifyGuestsSaveTitle": "Salvare la riunione?", + "microsoftNotifyGuestsSaveMessage": "Microsoft invierà inviti o aggiornamenti dell’evento agli invitati.", + "microsoftNotifyGuestsDeleteTitle": "Eliminare la riunione?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft invierà una cancellazione agli invitati.", + "organizer": "Organizzatore", + "yourResponse": "La tua risposta", + "guestResponses": "Risposte degli invitati", + "respond": "Rispondi", + "acceptInvitation": "Accetta", + "tentativeInvitation": "Provvisorio", + "declineInvitation": "Rifiuta", + "joinMeeting": "Partecipa alla riunione", + "responseAccepted": "Accettata", + "responseTentative": "Provvisoria", + "responseDeclined": "Rifiutata", + "responseNeedsAction": "In attesa di risposta", + "responseNotResponded": "Nessuna risposta", + "responseOrganizer": "Organizzatore", + "invitationResponseFailed": "Impossibile inviare la risposta: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Impossibile aprire il link della riunione.", "setCustomCalendarName": "Imposta nome personalizzato", "setAction": "Imposta", "removeFromMyCalendars": "Rimuovi dai miei calendari", "removeAction": "Rimuovi", "removeCalendarConfirmation": "Rimuovere \"{title}\" dall'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.", - "calendarCannotRemove": "Questo calendario non può essere eliminato o rimosso da questo account.", - "calendarPendingChangesPreventRemoval": "Attendi che le modifiche in sospeso di questo calendario siano sincronizzate prima di eliminarlo o rimuoverlo.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "Importa calendario", - "calendarImportDescription": "Seleziona un file, controlla gli eventi e scegli il calendario scrivibile in cui importarli.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, - "networkOffline": "Offline", + "calendarCannotRemove": "Non è possibile eliminare o rimuovere questo calendario da questo account.", + "calendarPendingChangesPreventRemoval": "Attendi il completamento della sincronizzazione delle modifiche in sospeso del calendario prima di eliminarlo o rimuoverlo.", + "calendarSubscriptions": "Abbonamenti ai calendari", + "calendarSubscriptionsDescription": "Aggiungi calendari di sola lettura che si aggiornano da un URL WebCal sicuro.", + "addCalendarSubscription": "Aggiungi abbonamento al calendario", + "subscriptionName": "Nome locale", + "subscriptionUrl": "URL abbonamento", + "subscriptionUrlHelp": "Inserisci un URL HTTPS o webcal. BusyMax conserva l’URL completo in un archivio sicuro.", + "subscriptionUrlInvalid": "Inserisci un URL HTTPS o webcal valido senza informazioni utente o frammento.", + "subscriptionColor": "Colore locale", + "subscriptionColorHelp": "Usa un colore a sei cifre come #3584E4.", + "subscriptionColorInvalid": "Inserisci un colore esadecimale di sei cifre.", + "subscriptionRefreshMode": "Frequenza di aggiornamento", + "subscriptionAutomatic": "Automatica", + "subscriptionHourly": "Ogni ora", + "subscriptionSixHours": "Ogni sei ore", + "subscriptionDaily": "Ogni giorno", + "subscriptionSafeOrigin": "Origine: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Inserisci un URL valido per visualizzarne l’origine sicura.", + "subscriptionReadOnly": "Abbonamento di sola lettura", + "subscriptionNeverRefreshed": "Non ancora aggiornato", + "subscriptionLastRefresh": "Ultimo aggiornamento riuscito: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Prossimo aggiornamento: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Aggiornato", + "subscriptionStatusIssue": "Problema di aggiornamento: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Aggiorna ora", + "unsubscribe": "Annulla abbonamento", + "unsubscribeCalendarTitle": "Annullare l’abbonamento a “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Rimuove l’abbonamento locale e gli eventi memorizzati nella cache. Il calendario pubblicato non cambia.", + "addSubscriptionAction": "Aggiungi abbonamento", + "subscriptionOperationFailed": "Abbonamento al calendario non riuscito: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Abbonamenti", + "calendarImport": "Importazione calendario", + "calendarImportDescription": "Seleziona un file, rivedi gli eventi e scegli il calendario scrivibile che deve riceverli.", + "importIcsFile": "Importa file .ics", + "importIcsPreview": "Importa eventi del calendario", + "importEventsFound": "Insiemi di eventi importabili: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Eventi non validi: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Ommessi intenzionalmente: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Nessun calendario di destinazione scrivibile disponibile.", + "importDestinationCalendar": "Calendario di destinazione", + "importIcsConfirm": "Importa eventi", + "importIcsComplete": "Importazione completata", + "importQueued": "Importati o accodati: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Duplicati ignorati: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Insiemi di ricorrenze non supportati: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Impossibile importare il file del calendario: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "networkOffline": "Senza connessione", "networkOfflineDescription": "Le modifiche verranno sincronizzate al ripristino della connessione.", "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index c27e6ab0..fb7156e9 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,14 +1,14 @@ { "@@locale": "ja", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Google、Microsoft、Apple iCloud Calendar、または Nextcloud のアカウントを接続します。", "googlePermissionsConsentNotice": "Google の権限画面で、カレンダーとタスクの両方の権限を選択してください。", "googlePermissionsRequiredRetry": "Google カレンダーと Google Tasks の権限が必要です。もう一度試して、両方のチェックボックスを選択してください。", "finishSetup": "セットアップを完了", "continueSetup": "続行", "onboardingSetupTitle": "BusyMax をセットアップ", "onboardingAccountsStepTitle": "アカウントを接続", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "使用するすべてのアカウントを追加します。BusyMax は各アカウントの対応するカレンダー、予定、タスクリスト、タスクを同期します。", "onboardingPreferencesStepTitle": "システム設定を選択", "onboardingPreferencesStepDescription": "スケジュールを開く前に、デスクトップでの動作、リマインダー、通知の詳細度、外観を設定します。", "signInWithGoogle": "Google でサインイン", @@ -36,16 +36,40 @@ "newCalendar": "新しいカレンダー", "calendarColor": "カレンダーの色", "calendarColorOption": "色 {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "このプロバイダーのカレンダー管理は BusyMax ではサポートされていません。", "primaryCalendarCannotDelete": "メインカレンダーは削除できません。", "calendarCreateFailed": "カレンダーを作成できませんでした: {error}", "calendarCreatedRefreshPending": "カレンダーは作成されましたが、BusyMax はアカウントを更新できませんでした。次回の同期後に表示されます。", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "カレンダーを更新できませんでした: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "カレンダーを削除できませんでした: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新しい予定", "refreshCalendar": "カレンダーを更新", "openInProvider": "サービスで開く", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "別の条件で検索するか、現在のフィルターを解除してください。", "refresh": "更新", "trayOpenBusyMax": "BusyMax を開く", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "BusyMax を表示", + "trayNewEvent": "新しい予定…", + "trayNewTask": "新しいタスク…", + "trayToday": "今日", + "trayAllDay": "終日", + "trayNow": "今", + "trayCalendarEvent": "カレンダーの予定", + "trayUntitledEvent": "無題の予定", + "trayNothingElseToday": "今日はこれ以上ありません", + "trayTasksDueToday": "{count, plural, =1{今日が期限のタスクは 1 件} other{今日が期限のタスクは {count} 件}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "今日の予定表を開く", + "traySyncNow": "今すぐ同期", + "traySyncing": "同期中…", + "trayNotConnected": "未接続", + "trayNotYetSynced": "まだ同期されていません", + "trayLastSyncedJustNow": "たった今同期", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分前に同期} other{{count} 分前に同期}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 時間前に同期} other{{count} 時間前に同期}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 日前に同期} other{{count} 日前に同期}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "設定", + "trayQuitBusyMax": "BusyMax を終了", "agendaLoadMoreOverdue": "期限切れのタスクをさらに読み込む", "agendaLoadMoreNoDate": "日付のないタスクをさらに読み込む", "viewDay": "日", @@ -217,8 +265,8 @@ "removeAccount": "アカウントを削除…", "removingAccount": "アカウントを削除しています…", "removeAccountDescription": "同期を停止し、このアカウントのデータをこのデバイスから削除します。", - "removeAccountTitle": "BusyMax から {account} を削除しますか?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "{account} を BusyMax から削除しますか?", + "removeAccountConfirmation": "このデバイスから、キャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更を削除します。同期されていない変更は失われます。プロバイダー側のカレンダー、予定、タスクリスト、タスクのコピーは削除されません。", "revokeGoogleAccess": "この Google アカウントへの BusyMax のアクセス権も取り消す", "revokeGoogleAccessDescription": "再接続する前に、もう一度アクセスを許可する必要があります。", "removeAccountAction": "アカウントを削除", @@ -415,50 +463,50 @@ "selectTimeZone": "タイムゾーンを選択", "searchLocations": "場所を検索", "noLocationsFound": "場所が見つかりません", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "このフィールドは必須です。", + "providerConnectionDescription": "次のいずれかのプロバイダーからカレンダーとタスクを接続します。", + "appleICloudProvider": "Apple iCloud カレンダー", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud タスク", + "addAppleICloudAccount": "Apple iCloud カレンダーアカウントを追加", + "addNextcloudAccount": "Nextcloud アカウントを追加", + "waitingForAppleICloud": "Apple iCloud に接続中…", + "waitingForNextcloud": "Nextcloud の認証を待機中…", + "connectAppleICloudTitle": "Apple iCloud カレンダーを接続", + "appleAccountEmail": "Apple アカウントのメールアドレス", + "appleAppSpecificPassword": "App 用パスワード", + "appleAppSpecificPasswordHelp": "Apple アカウントで 2 要素認証を有効にした後、App 用パスワードを作成してください。", + "appleAppSpecificPasswordResetWarning": "Apple アカウントのパスワードをリセットすると、App 用パスワードが無効になります。", + "connectNextcloudTitle": "Nextcloud を接続", + "nextcloudServerUrl": "Nextcloud サーバーまたは CalDAV アドレス", + "nextcloudServerUrlHelp": "Nextcloud サーバーの URL を入力するか、Nextcloud からコピーしたプライマリ CalDAV アドレスを貼り付けてください。", + "nextcloudBrowserAuthorizationHelp": "BusyMax がブラウザーを開きます。そこでアクセスを許可してから BusyMax に戻ってください。", + "connectAccountAction": "接続", + "cancelAccountConnection": "接続をキャンセル", + "nextcloudAccountRemovedRevokeFailed": "アカウントはローカルから削除されましたが、Nextcloud の App パスワードを無効にできませんでした。", + "davCachedOfflineNotice": "オフラインで使用できるよう、カレンダーとタスクのデータはローカルにキャッシュされます。", + "davReauthenticationRequired": "同期を再開するには、このアカウントを再接続してください。", + "davTemporarilyUnavailable": "このアカウントは一時的に利用できません。", + "davPermissionChanged": "サーバーの権限が変更されました。保留中の編集は一時停止されています。", + "davUnsupportedServer": "このサーバーまたはプロバイダー プロファイルはサポートされていません。", + "collectionSettings": "カレンダーとタスクリスト", + "calendarContent": "カレンダーの予定", + "taskContent": "タスク", + "readOnlySharedCollection": "読み取り専用", + "pendingLocally": "ローカルで保留中", + "conflictBlocked": "競合によりブロック", + "authenticationBlocked": "再接続するまでブロック", + "operationFailed": "操作に失敗", + "keepServerVersion": "サーバーのバージョンを保持", + "reapplyLocalChange": "ローカル変更を確認して再適用", + "duplicateLocalItem": "新しいアイテムとして複製", "deleteCalendarConfirmation": "「{title}」を削除しますか?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "接続状態", + "davConnected": "接続済み", + "davConnecting": "接続中…", + "davSignedOut": "サインアウト済み", + "davLastSuccessfulSync": "最後に成功した同期: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "まだ同期されていません", + "refreshCollections": "カレンダーとタスクリストを更新", + "nextcloudServerHost": "サーバー: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "予定カレンダー", + "collectionSupportsTasks": "タスクリスト", + "collectionSupportsEventsAndTasks": "予定とタスク", + "writableCollection": "書き込み可能", + "sharedCollection": "共有", + "collectionLastSynced": "最後の同期: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同期の問題: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同期の競合", + "remoteChangedAt": "サーバーでの変更: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "ローカル編集: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "競合を解決できませんでした。", + "recurringEventScope": "繰り返し予定の範囲", + "entireSeries": "予定全体", "singleOccurrence": "この予定", "thisAndFollowingEvents": "この予定とこれ以降の予定", "thisAndFutureUnavailable": "このプロバイダーではサポートされていません。", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "予定をコピーして元の予定を削除しますか?", "copyEventMoveWarning": "BusyMax はこの予定を {source} から {destination} へ直接移動できません。先にコピーを作成し、コピーが成功した場合にのみ元の予定を削除します。予定 ID は変更されます。出席者の回答状況がリセットされ、招待やキャンセルが送信される場合があります。また、会議リンク、添付ファイル、リマインダー、プロバイダー固有のフィールド、繰り返しの例外は引き継がれない場合があります。", "copyAndDelete": "コピーして削除", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "この変更を予定全体、この予定のみ、この予定とこれ以降の予定のどれに適用するか選択してください。", "taskDueBeforeStart": "期限を開始より前に設定することはできません。", "taskStartDueTimeModeMismatch": "開始と期限の両方に時刻を設定するか、タスクを終日にしてください。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "タスクリストを作成できませんでした: {error}", + "taskListRenameFailed": "タスクリストの名前を変更できませんでした: {error}", + "taskListDeleteFailed": "タスクリストを削除できませんでした: {error}", "unshare": "共有を解除", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "アクションが必要", + "readOnlyTaskListCannotRename": "このタスクリストは読み取り専用のため、名前を変更できません。", + "taskListCannotDelete": "現在の権限ではこのタスクリストを削除できません。", + "deleteTaskListConfirmation": "「{title}」とそのすべてのタスクを削除しますか?", + "unshareTaskListConfirmation": "このアカウントとの「{title}」の共有を解除しますか?", + "taskStatus": "ステータス", + "taskStatusNone": "ステータスなし", + "taskStatusNeedsAction": "要対応", "taskStatusInProcess": "進行中", "taskStatusCompleted": "完了", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCancelled": "キャンセル済み", + "completionPercent": "{percent}% 完了", + "completionDate": "完了日", "priority": "優先度", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "毎日繰り返す", - "repeatOn": "Repeat on", - "repeatEnd": "繰り返し終了", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", - "repeatTimesSummary": "{count}回", - "repeatUntilSummary": "{date}まで", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "priorityNone": "優先度なし", + "priorityHighValue": "優先度 {priority} · 高", + "priorityMediumValue": "優先度 {priority} · 中", + "priorityLowValue": "優先度 {priority} · 低", + "taskUrl": "タスク URL", + "invalidTaskUrl": "スキームを含む絶対 URL を入力してください。", + "classification": "分類", + "classificationPublic": "共有時にタスク全体を表示", + "classificationConfidential": "共有時に空き状況のみ表示", + "classificationPrivate": "共有時にこのタスクを非表示", + "pinTask": "タスクをピン留め", + "reminders": "リマインダー", + "noReminders": "リマインダーなし", + "editReminder": "リマインダーを編集", + "beforeTaskStarts": "タスクの開始前", + "beforeTaskDue": "タスクの期限前", + "afterTaskStarts": "タスクの開始後", + "afterTaskDue": "タスクの期限後", + "relativeToTaskStart": "タスクの開始日に対して", + "relativeToTaskDue": "タスクの期限日に対して", + "reminderTimeOfDay": "時刻", + "absoluteReminder": "日時を指定", + "reminderAmount": "数値", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分", + "reminderUnitHours": "時間", + "reminderUnitDays": "日", + "reminderUnitWeeks": "週", + "reminderAtTaskStart": "タスクの開始時", + "reminderAtTaskDue": "タスクの期限時刻", + "unsupportedReminder": "このリマインダーの種類は保持されますが、時刻は編集できません。", + "relatedRemindersTitle": "関連するリマインダーを保持しますか?", + "relatedRemindersDescription": "この日付には関連するリマインダーが {count} 件あります。現在の日付と時刻を保持しますか?", + "discardRelatedReminders": "リマインダーを破棄", + "keepRelatedReminders": "リマインダーを保持", + "repeatEvery": "繰り返し間隔", + "repeatOn": "繰り返す曜日", + "repeatEnd": "繰り返しを終了", + "repeatNever": "なし", + "repeatUntil": "指定日まで", + "repeatAfter": "指定回数後", + "repeatCount": "繰り返し回数", + "repeatDayOfMonth": "月の日", + "repeatMonths": "月", + "repeatOrdinal": "曜日の順番", + "repeatSpecificDays": "特定の曜日", + "repeatFirst": "第 1", + "repeatSecond": "第 2", + "repeatThird": "第 3", + "repeatFourth": "第 4", + "repeatFifth": "第 5", + "repeatSecondToLast": "最後から 2 番目", + "repeatLast": "最後", + "repeatAnyDay": "日", + "repeatWeekday": "平日", + "repeatWeekendDay": "週末", + "repeatEveryDays": "{count} 日ごと", + "repeatEveryWeeks": "{count} 週間ごと", + "repeatEveryMonths": "{count} か月ごと", + "repeatEveryYears": "{count} 年ごと", + "repeatOnDaysSummary": "{days} 曜日", + "repeatOnMonthDaysSummary": "毎月 {days} 日", + "repeatOnOrdinalSummary": "{days} の第 {ordinal}", + "repeatInMonthsSummary": "{months} に", + "repeatTimesSummary": "{count} 回", + "repeatUntilSummary": "{date} まで", + "unsupportedRecurrencePreserved": "この繰り返しルールには、このエディターで変更されないオプションが含まれています。", "recurrenceUnsupportedByProvider": "この繰り返しは {provider} では使用できません。", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "duplicateTask": "タスクを複製", + "taskDuplicated": "タスクを複製しました。", + "taskDuplicateFailed": "タスクを複製できませんでした: {error}", "hideSubtasks": "サブタスクを非表示", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "完了したサブタスクを非表示", + "reminderUnit": "単位", + "attendeeRequired": "必須", + "attendeeOptional": "任意", + "meetingSection": "会議", + "addGoogleMeet": "Google Meet を追加", + "addTeamsMeeting": "Microsoft Teams 会議を追加", + "onlineMeetingAdded": "オンライン会議を追加しました", + "requestResponses": "返信をリクエスト", + "requestResponsesDescription": "ゲストに招待への返信を依頼します。", + "hideGuestList": "ゲストリストを非表示", + "hideGuestListDescription": "ゲストには他の招待者が表示されません。", + "allowNewTimeProposals": "新しい日時の提案を許可", + "allowNewTimeProposalsDescription": "ゲストは別の会議時間を提案できます。", + "notifyGuestsTitle": "ゲストに通知しますか?", + "notifyGuestsSaveMessage": "この会議にはゲストがいます。保存時に招待または予定の更新を送信しますか?", + "notifyGuestsDeleteMessage": "この会議にはゲストがいます。削除時にキャンセルを送信しますか?", + "sendUpdates": "更新を送信", + "sendCancellation": "キャンセルを送信", + "doNotSend": "送信しない", + "microsoftNotifyGuestsSaveTitle": "会議を保存しますか?", + "microsoftNotifyGuestsSaveMessage": "Microsoft からゲストに招待または予定の更新が送信されます。", + "microsoftNotifyGuestsDeleteTitle": "会議を削除しますか?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft からゲストにキャンセルが送信されます。", + "organizer": "主催者", + "yourResponse": "自分の返信", + "guestResponses": "ゲストの返信", + "respond": "返信", + "acceptInvitation": "承諾", + "tentativeInvitation": "仮承諾", + "declineInvitation": "辞退", + "joinMeeting": "会議に参加", + "responseAccepted": "承諾済み", + "responseTentative": "仮承諾", + "responseDeclined": "辞退済み", + "responseNeedsAction": "返信待ち", + "responseNotResponded": "未返信", + "responseOrganizer": "主催者", + "invitationResponseFailed": "返信を送信できませんでした: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "会議リンクを開けませんでした。", "setCustomCalendarName": "カスタム名を設定", "setAction": "設定", "removeFromMyCalendars": "マイカレンダーから削除", "removeAction": "削除", "removeCalendarConfirmation": "「{title}」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。", - "calendarCannotRemove": "このカレンダーは、このアカウントから削除できません。", - "calendarPendingChangesPreventRemoval": "このカレンダーを削除する前に、保留中の変更の同期が完了するまでお待ちください。", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "カレンダーのインポート", - "calendarImportDescription": "ファイルを選択して予定を確認し、インポート先の書き込み可能なカレンダーを選択します。", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarCannotRemove": "このカレンダーはこのアカウントから削除または解除できません。", + "calendarPendingChangesPreventRemoval": "削除または解除する前に、このカレンダーの保留中の変更が同期されるまで待ってください。", + "calendarSubscriptions": "カレンダーの登録", + "calendarSubscriptionsDescription": "安全な WebCal URL から更新される読み取り専用カレンダーを追加します。", + "addCalendarSubscription": "カレンダー登録を追加", + "subscriptionName": "ローカル名", + "subscriptionUrl": "登録 URL", + "subscriptionUrlHelp": "HTTPS または webcal URL を入力してください。BusyMax は完全な URL を安全なストレージに保存します。", + "subscriptionUrlInvalid": "ユーザー情報やフラグメントを含まない有効な HTTPS または webcal URL を入力してください。", + "subscriptionColor": "ローカルの色", + "subscriptionColorHelp": "#3584E4 のような 6 桁の色を使用してください。", + "subscriptionColorInvalid": "6 桁の 16 進数カラーを入力してください。", + "subscriptionRefreshMode": "更新頻度", + "subscriptionAutomatic": "自動", + "subscriptionHourly": "毎時", + "subscriptionSixHours": "6 時間ごと", + "subscriptionDaily": "毎日", + "subscriptionSafeOrigin": "ソース: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "安全なオリジンをプレビューするには有効な URL を入力してください。", + "subscriptionReadOnly": "読み取り専用の登録", + "subscriptionNeverRefreshed": "まだ更新されていません", + "subscriptionLastRefresh": "最後に成功した更新: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "次回の更新: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "最新", + "subscriptionStatusIssue": "更新の問題: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "今すぐ更新", + "unsubscribe": "登録を解除", + "unsubscribeCalendarTitle": "「{name}」の登録を解除しますか?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "ローカル登録とキャッシュされた予定を削除します。公開されているカレンダーは変更されません。", + "addSubscriptionAction": "登録を追加", + "subscriptionOperationFailed": "カレンダー登録に失敗: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "登録", + "calendarImport": "カレンダーをインポート", + "calendarImportDescription": "ファイルを選択して予定を確認し、受け入れ先の書き込み可能なカレンダーを選択します。", + "importIcsFile": ".ics ファイルをインポート", + "importIcsPreview": "カレンダーの予定をインポート", + "importEventsFound": "インポート可能な予定セット: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "無効な予定: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "意図的に省略: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "書き込み可能な移行先カレンダーがありません。", + "importDestinationCalendar": "移行先カレンダー", + "importIcsConfirm": "予定をインポート", + "importIcsComplete": "インポート完了", + "importQueued": "インポート済みまたはキュー済み: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "重複をスキップ: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "サポートされない繰り返しセット: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "カレンダーファイルをインポートできませんでした: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "オフライン", "networkOfflineDescription": "接続が復旧すると変更が同期されます。", "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。" diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 296f97bf..1ded5c69 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,14 +1,14 @@ { "@@locale": "ko", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Google, Microsoft, Apple iCloud Calendar 또는 Nextcloud 계정을 연결하세요.", "googlePermissionsConsentNotice": "Google 권한 화면에서 캘린더와 할 일 권한을 모두 선택하세요.", "googlePermissionsRequiredRetry": "Google Calendar 및 Google Tasks 권한이 필요합니다. 다시 시도하여 두 체크박스를 모두 선택하세요.", "finishSetup": "설정 완료", "continueSetup": "계속", "onboardingSetupTitle": "BusyMax 설정", "onboardingAccountsStepTitle": "계정 연결", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "사용할 모든 계정을 추가하세요. BusyMax는 각 계정의 지원되는 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.", "onboardingPreferencesStepTitle": "시스템 설정 선택", "onboardingPreferencesStepDescription": "일정을 열기 전에 데스크톱 동작, 미리 알림, 알림 세부 수준 및 화면 모양을 설정하세요.", "signInWithGoogle": "Google로 로그인", @@ -36,16 +36,40 @@ "newCalendar": "새 캘린더", "calendarColor": "캘린더 색상", "calendarColorOption": "색상 {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "이 공급자는 BusyMax에서 캘린더 관리를 지원하지 않습니다.", "primaryCalendarCannotDelete": "기본 캘린더는 삭제할 수 없습니다.", "calendarCreateFailed": "캘린더를 만들 수 없습니다: {error}", "calendarCreatedRefreshPending": "캘린더가 생성되었지만 BusyMax에서 계정을 새로 고치지 못했습니다. 다음 동기화 후에 표시됩니다.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "캘린더를 업데이트할 수 없습니다: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "캘린더를 삭제할 수 없습니다: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "새 일정", "refreshCalendar": "캘린더 새로 고침", "openInProvider": "서비스에서 열기", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "다른 검색어를 사용하거나 현재 필터를 지우세요.", "refresh": "새로 고침", "trayOpenBusyMax": "BusyMax 열기", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "BusyMax 표시", + "trayNewEvent": "새 일정…", + "trayNewTask": "새 할 일…", + "trayToday": "오늘", + "trayAllDay": "종일", + "trayNow": "지금", + "trayCalendarEvent": "캘린더 일정", + "trayUntitledEvent": "제목 없는 일정", + "trayNothingElseToday": "오늘은 더 이상 없음", + "trayTasksDueToday": "{count, plural, =1{오늘 마감인 할 일 1개} other{오늘 마감인 할 일 {count}개}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "오늘 일정 열기", + "traySyncNow": "지금 동기화", + "traySyncing": "동기화 중…", + "trayNotConnected": "연결되지 않음", + "trayNotYetSynced": "아직 동기화되지 않음", + "trayLastSyncedJustNow": "방금 동기화됨", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1분 전에 동기화됨} other{{count}분 전에 동기화됨}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1시간 전에 동기화됨} other{{count}시간 전에 동기화됨}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1일 전에 동기화됨} other{{count}일 전에 동기화됨}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "설정", + "trayQuitBusyMax": "BusyMax 종료", "agendaLoadMoreOverdue": "기한이 지난 할 일 더 불러오기", "agendaLoadMoreNoDate": "날짜 없는 할 일 더 불러오기", "viewDay": "일", @@ -116,7 +164,7 @@ "doesNotRepeat": "반복 안 함", "defaultReminder": "기본 미리 알림", "guests": "참석자", - "noGuests": "참석자 없음", + "noGuests": "게스트 없음", "description": "설명", "availabilityShowAs": "일정 상태 / 표시 방식", "busy": "바쁨", @@ -217,8 +265,8 @@ "removeAccount": "계정 삭제…", "removingAccount": "계정 삭제 중…", "removeAccountDescription": "동기화를 중지하고 이 기기에서 이 계정의 데이터를 삭제합니다.", - "removeAccountTitle": "BusyMax에서 {account} 계정을 삭제할까요?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "{account}을(를) BusyMax에서 삭제할까요?", + "removeAccountConfirmation": "이 작업은 이 기기에서 캐시된 할 일, 캘린더, 일정, 알림 및 보류 중인 오프라인 변경사항을 삭제합니다. 동기화되지 않은 변경사항은 손실됩니다. 제공업체의 캘린더, 일정, 할 일 목록 및 할 일 사본은 삭제되지 않습니다.", "revokeGoogleAccess": "이 Google 계정에 대한 BusyMax의 액세스 권한도 취소", "revokeGoogleAccessDescription": "다시 연결하기 전에 액세스 권한을 다시 부여해야 합니다.", "removeAccountAction": "계정 삭제", @@ -234,7 +282,7 @@ "deleteList": "목록 삭제", "builtInMicrosoftList": "기본 제공", "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do의 기본 제공 목록은 이름을 바꾸거나 삭제할 수 없습니다.", - "deleteListConfirmation": "Google Tasks에서 “{title}” 목록을 삭제할까요?", + "deleteListConfirmation": "Google Tasks에서 “{title}”을(를) 삭제할까요?", "deleteEvent": "일정 삭제", "title": "제목", "create": "만들기", @@ -283,7 +331,7 @@ "reminderDate": "미리 알림 날짜", "reminderTime": "미리 알림 시간", "reminder": "미리 알림", - "addReminder": "미리 알림 추가", + "addReminder": "알림 추가", "addGuest": "참석자 추가", "addGuestEmail": "참석자 이메일 추가", "removeReminder": "미리 알림 삭제", @@ -391,7 +439,7 @@ "syncFailureNotificationTitle": "BusyMax 동기화 실패", "syncFailureNotificationBody": "백그라운드 동기화에 실패했습니다. {message}", "conflictNotificationTitle": "BusyMax 동기화 충돌", - "conflictNotificationBody": "보류 중인 로컬 변경 사항이 차단되었습니다. {summary}", + "conflictNotificationBody": "보류 중인 로컬 변경사항이 차단되었습니다. {summary}", "dueTodayNotificationTitle": "오늘 마감인 할 일", "dueTodayNotificationBody": "{count, plural, =1{오늘 마감인 할 일이 1개 있습니다.} other{오늘 마감인 할 일이 {count}개 있습니다.}}", "eventReminderNotificationTitle": "일정 미리 알림", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "할 일 마감이 얼마 남지 않았습니다.", "notificationOpenAction": "열기", "notificationSnoozeAction": "10분 후 다시 알림", - "notificationDismissAction": "해제", + "notificationDismissAction": "닫기", "notificationDetailsHidden": "개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.", "previousMonth": "이전 달", "nextMonth": "다음 달", @@ -414,51 +462,51 @@ "readOnlyCalendar": "이 캘린더는 읽기 전용입니다.", "selectTimeZone": "시간대 선택", "searchLocations": "위치 검색", - "noLocationsFound": "위치를 찾을 수 없습니다", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "noLocationsFound": "위치를 찾을 수 없음", + "requiredField": "이 필드는 필수입니다.", + "providerConnectionDescription": "다음 제공업체 중 하나에서 캘린더와 할 일을 연결하세요.", + "appleICloudProvider": "Apple iCloud 캘린더", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 할 일", + "addAppleICloudAccount": "Apple iCloud 캘린더 계정 추가", + "addNextcloudAccount": "Nextcloud 계정 추가", + "waitingForAppleICloud": "Apple iCloud에 연결 중…", + "waitingForNextcloud": "Nextcloud 인증 대기 중…", + "connectAppleICloudTitle": "Apple iCloud 캘린더 연결", + "appleAccountEmail": "Apple 계정 이메일", + "appleAppSpecificPassword": "앱 전용 암호", + "appleAppSpecificPasswordHelp": "Apple 계정에서 이중 인증을 활성화한 후 앱 전용 암호를 생성하세요.", + "appleAppSpecificPasswordResetWarning": "Apple 계정 암호를 재설정하면 앱 전용 암호가 취소됩니다.", + "connectNextcloudTitle": "Nextcloud 연결", + "nextcloudServerUrl": "Nextcloud 서버 또는 CalDAV 주소", + "nextcloudServerUrlHelp": "Nextcloud 서버 URL을 입력하거나 Nextcloud에서 복사한 기본 CalDAV 주소를 붙여넣으세요.", + "nextcloudBrowserAuthorizationHelp": "BusyMax가 브라우저를 엽니다. 그곳에서 액세스를 승인한 다음 BusyMax로 돌아오세요.", + "connectAccountAction": "연결", + "cancelAccountConnection": "연결 취소", + "nextcloudAccountRemovedRevokeFailed": "계정은 로컬에서 삭제되었지만 Nextcloud 앱 암호를 취소할 수 없습니다.", + "davCachedOfflineNotice": "오프라인 사용을 위해 캘린더 및 할 일 데이터가 로컬에 캐시됩니다.", + "davReauthenticationRequired": "동기화를 재개하려면 이 계정을 다시 연결하세요.", + "davTemporarilyUnavailable": "이 계정은 일시적으로 사용할 수 없습니다.", + "davPermissionChanged": "서버 권한이 변경되었습니다. 보류 중인 편집이 일시 중지되었습니다.", + "davUnsupportedServer": "이 서버 또는 제공업체 프로필은 지원되지 않습니다.", + "collectionSettings": "캘린더 및 할 일 목록", + "calendarContent": "캘린더 일정", + "taskContent": "할 일", + "readOnlySharedCollection": "읽기 전용", + "pendingLocally": "로컬에서 보류 중", + "conflictBlocked": "충돌로 차단됨", + "authenticationBlocked": "다시 연결할 때까지 차단됨", + "operationFailed": "작업 실패", + "keepServerVersion": "서버 버전 유지", + "reapplyLocalChange": "로컬 변경사항 검토 후 다시 적용", + "duplicateLocalItem": "새 항목으로 복제", "deleteCalendarConfirmation": "“{title}” 캘린더를 삭제할까요?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "연결 상태", + "davConnected": "연결됨", + "davConnecting": "연결 중…", + "davSignedOut": "로그아웃됨", + "davLastSuccessfulSync": "마지막으로 성공한 동기화: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "아직 동기화되지 않음", + "refreshCollections": "캘린더 및 할 일 목록 새로 고침", + "nextcloudServerHost": "서버: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "일정 캘린더", + "collectionSupportsTasks": "할 일 목록", + "collectionSupportsEventsAndTasks": "일정 및 할 일", + "writableCollection": "쓰기 가능", + "sharedCollection": "공유됨", + "collectionLastSynced": "마지막 동기화: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "동기화 문제: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "동기화 충돌", + "remoteChangedAt": "서버 변경: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "로컬 편집: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "충돌을 해결할 수 없습니다.", + "recurringEventScope": "반복 일정 범위", + "entireSeries": "전체 시리즈", "singleOccurrence": "이 일정", "thisAndFollowingEvents": "이 일정 및 향후 일정", "thisAndFutureUnavailable": "이 제공업체에서는 지원되지 않습니다.", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "일정을 복사하고 원본을 삭제할까요?", "copyEventMoveWarning": "BusyMax는 이 일정을 {source}에서 {destination}(으)로 직접 이동할 수 없습니다. 먼저 복사본을 만들고 복사가 성공한 뒤에만 원본을 삭제합니다. 일정 ID가 변경되고 참석자 응답 상태가 초기화되며 초대 또는 취소가 전송될 수 있습니다. 회의 링크, 첨부 파일, 미리 알림, 제공업체별 필드 및 반복 예외는 이전되지 않을 수 있습니다.", "copyAndDelete": "복사 후 삭제", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "이 변경을 전체 시리즈, 이 일정만 또는 이 일정과 이후 일정에 적용할지 선택하세요.", "taskDueBeforeStart": "마감은 시작보다 빠를 수 없습니다.", "taskStartDueTimeModeMismatch": "시작과 마감에 모두 시간을 설정하거나 작업을 종일로 설정하세요.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "할 일 목록을 만들 수 없습니다: {error}", + "taskListRenameFailed": "할 일 목록 이름을 바꿀 수 없습니다: {error}", + "taskListDeleteFailed": "할 일 목록을 삭제할 수 없습니다: {error}", "unshare": "공유 해제", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "완료됨", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "우선 순위", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "반복 주기", - "repeatOn": "Repeat on", + "readOnlyTaskListCannotRename": "이 할 일 목록은 읽기 전용이므로 이름을 바꿀 수 없습니다.", + "taskListCannotDelete": "현재 권한으로는 이 할 일 목록을 삭제할 수 없습니다.", + "deleteTaskListConfirmation": "“{title}” 및 모든 할 일을 삭제할까요?", + "unshareTaskListConfirmation": "이 계정에서 “{title}” 공유를 해제할까요?", + "taskStatus": "상태", + "taskStatusNone": "상태 없음", + "taskStatusNeedsAction": "조치 필요", + "taskStatusInProcess": "진행 중", + "taskStatusCompleted": "완료", + "taskStatusCancelled": "취소됨", + "completionPercent": "{percent}% 완료", + "completionDate": "완료 날짜", + "priority": "우선순위", + "priorityNone": "우선순위 없음", + "priorityHighValue": "우선순위 {priority} · 높음", + "priorityMediumValue": "우선순위 {priority} · 중간", + "priorityLowValue": "우선순위 {priority} · 낮음", + "taskUrl": "할 일 URL", + "invalidTaskUrl": "스킴을 포함한 절대 URL을 입력하세요.", + "classification": "분류", + "classificationPublic": "공유할 때 전체 할 일 표시", + "classificationConfidential": "공유할 때 바쁨 상태만 표시", + "classificationPrivate": "공유할 때 이 할 일 숨기기", + "pinTask": "할 일 고정", + "reminders": "알림", + "noReminders": "알림 없음", + "editReminder": "알림 수정", + "beforeTaskStarts": "할 일 시작 전", + "beforeTaskDue": "할 일 마감 전", + "afterTaskStarts": "할 일 시작 후", + "afterTaskDue": "할 일 마감 후", + "relativeToTaskStart": "할 일 시작일 기준", + "relativeToTaskDue": "할 일 마감일 기준", + "reminderTimeOfDay": "시간", + "absoluteReminder": "날짜 및 시간에", + "reminderAmount": "수량", + "reminderUnitSeconds": "초", + "reminderUnitMinutes": "분", + "reminderUnitHours": "시간", + "reminderUnitDays": "일", + "reminderUnitWeeks": "주", + "reminderAtTaskStart": "할 일 시작 시", + "reminderAtTaskDue": "할 일 마감 시", + "unsupportedReminder": "이 알림 유형은 유지되지만 시간을 수정할 수 없습니다.", + "relatedRemindersTitle": "관련 알림을 유지할까요?", + "relatedRemindersDescription": "이 날짜에 관련 알림이 {count}개 있습니다. 현재 날짜와 시간에 유지할까요?", + "discardRelatedReminders": "알림 삭제", + "keepRelatedReminders": "알림 유지", + "repeatEvery": "반복 간격", + "repeatOn": "반복 요일", "repeatEnd": "반복 종료", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", + "repeatNever": "안 함", + "repeatUntil": "날짜까지", + "repeatAfter": "지정한 횟수 후", + "repeatCount": "반복 횟수", + "repeatDayOfMonth": "월의 날짜", + "repeatMonths": "월", + "repeatOrdinal": "요일 순서", + "repeatSpecificDays": "특정 요일", + "repeatFirst": "첫 번째", + "repeatSecond": "두 번째", + "repeatThird": "세 번째", + "repeatFourth": "네 번째", + "repeatFifth": "다섯 번째", + "repeatSecondToLast": "끝에서 두 번째", + "repeatLast": "마지막", + "repeatAnyDay": "요일", + "repeatWeekday": "평일", + "repeatWeekendDay": "주말", + "repeatEveryDays": "{count}일마다", + "repeatEveryWeeks": "{count}주마다", + "repeatEveryMonths": "{count}개월마다", + "repeatEveryYears": "{count}년마다", + "repeatOnDaysSummary": "{days}에", + "repeatOnMonthDaysSummary": "매월 {days}일", + "repeatOnOrdinalSummary": "{days} {ordinal}", + "repeatInMonthsSummary": "{months}에", "repeatTimesSummary": "{count}회", - "repeatUntilSummary": "until {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatUntilSummary": "{date}까지", + "unsupportedRecurrencePreserved": "이 반복 규칙에는 이 편집기에서 변경하지 않는 옵션이 사용됩니다.", "recurrenceUnsupportedByProvider": "이 반복은 {provider}에서 사용할 수 없습니다.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "duplicateTask": "할 일 복제", + "taskDuplicated": "할 일이 복제되었습니다.", + "taskDuplicateFailed": "할 일을 복제할 수 없습니다: {error}", + "hideSubtasks": "하위 할 일 숨기기", + "hideClosedSubtasks": "완료된 하위 할 일 숨기기", + "reminderUnit": "단위", + "attendeeRequired": "필수", + "attendeeOptional": "선택사항", + "meetingSection": "회의", + "addGoogleMeet": "Google Meet 추가", + "addTeamsMeeting": "Microsoft Teams 회의 추가", + "onlineMeetingAdded": "온라인 회의가 추가됨", + "requestResponses": "응답 요청", + "requestResponsesDescription": "게스트에게 초대에 응답하도록 요청합니다.", + "hideGuestList": "게스트 목록 숨기기", + "hideGuestListDescription": "게스트는 다른 초대 대상자를 볼 수 없습니다.", + "allowNewTimeProposals": "새 시간 제안 허용", + "allowNewTimeProposalsDescription": "게스트가 다른 회의 시간을 제안할 수 있습니다.", + "notifyGuestsTitle": "게스트에게 알릴까요?", + "notifyGuestsSaveMessage": "이 회의에는 게스트가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?", + "notifyGuestsDeleteMessage": "이 회의에는 게스트가 있습니다. 삭제할 때 취소를 보낼까요?", + "sendUpdates": "업데이트 보내기", + "sendCancellation": "취소 보내기", + "doNotSend": "보내지 않음", + "microsoftNotifyGuestsSaveTitle": "회의를 저장할까요?", + "microsoftNotifyGuestsSaveMessage": "Microsoft에서 게스트에게 초대 또는 일정 업데이트를 보냅니다.", + "microsoftNotifyGuestsDeleteTitle": "회의를 삭제할까요?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft에서 게스트에게 취소를 보냅니다.", + "organizer": "주최자", + "yourResponse": "내 응답", + "guestResponses": "게스트 응답", + "respond": "응답", + "acceptInvitation": "수락", + "tentativeInvitation": "미정", + "declineInvitation": "거절", + "joinMeeting": "회의 참가", + "responseAccepted": "수락됨", + "responseTentative": "미정", + "responseDeclined": "거절됨", + "responseNeedsAction": "응답 대기 중", + "responseNotResponded": "응답하지 않음", + "responseOrganizer": "주최자", + "invitationResponseFailed": "응답을 보낼 수 없습니다: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "회의 링크를 열 수 없습니다.", "setCustomCalendarName": "맞춤 이름 설정", "setAction": "설정", "removeFromMyCalendars": "내 캘린더에서 삭제", "removeAction": "삭제", "removeCalendarConfirmation": "Google Calendar 목록에서 \"{title}\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.", "calendarCannotRemove": "이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.", - "calendarPendingChangesPreventRemoval": "이 캘린더를 삭제하거나 제거하기 전에 보류 중인 변경 사항의 동기화가 완료될 때까지 기다리세요.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", + "calendarPendingChangesPreventRemoval": "삭제하거나 제거하기 전에 이 캘린더의 보류 중인 변경사항이 동기화될 때까지 기다리세요.", + "calendarSubscriptions": "캘린더 구독", + "calendarSubscriptionsDescription": "보안 WebCal URL에서 새로 고침되는 읽기 전용 캘린더를 추가하세요.", + "addCalendarSubscription": "캘린더 구독 추가", + "subscriptionName": "로컬 이름", + "subscriptionUrl": "구독 URL", + "subscriptionUrlHelp": "HTTPS 또는 webcal URL을 입력하세요. BusyMax는 전체 URL을 안전한 저장소에 보관합니다.", + "subscriptionUrlInvalid": "사용자 정보나 프래그먼트가 없는 올바른 HTTPS 또는 webcal URL을 입력하세요.", + "subscriptionColor": "로컬 색상", + "subscriptionColorHelp": "#3584E4와 같은 6자리 색상을 사용하세요.", + "subscriptionColorInvalid": "6자리 16진수 색상을 입력하세요.", + "subscriptionRefreshMode": "새로 고침 빈도", + "subscriptionAutomatic": "자동", + "subscriptionHourly": "매시간", + "subscriptionSixHours": "6시간마다", + "subscriptionDaily": "매일", + "subscriptionSafeOrigin": "소스: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "안전한 원본을 미리 보려면 올바른 URL을 입력하세요.", + "subscriptionReadOnly": "읽기 전용 구독", + "subscriptionNeverRefreshed": "아직 새로 고치지 않음", + "subscriptionLastRefresh": "마지막으로 성공한 새로 고침: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "다음 새로 고침: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "최신 상태", + "subscriptionStatusIssue": "새로 고침 문제: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "지금 새로 고침", + "unsubscribe": "구독 취소", + "unsubscribeCalendarTitle": "“{name}” 구독을 취소할까요?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "로컬 구독과 캐시된 일정을 제거합니다. 게시된 캘린더는 변경되지 않습니다.", + "addSubscriptionAction": "구독 추가", + "subscriptionOperationFailed": "캘린더 구독 실패: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "구독", "calendarImport": "캘린더 가져오기", - "calendarImportDescription": "파일을 선택하고 일정을 검토한 다음 가져올 쓰기 가능한 캘린더를 선택하세요.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarImportDescription": "파일을 선택하고 일정을 검토한 다음 이를 받을 쓰기 가능한 캘린더를 선택하세요.", + "importIcsFile": ".ics 파일 가져오기", + "importIcsPreview": "캘린더 일정 가져오기", + "importEventsFound": "가져올 수 있는 일정 세트: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "잘못된 일정: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "의도적으로 생략됨: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "쓰기 가능한 대상 캘린더가 없습니다.", + "importDestinationCalendar": "대상 캘린더", + "importIcsConfirm": "일정 가져오기", + "importIcsComplete": "가져오기 완료", + "importQueued": "가져왔거나 대기열에 추가됨: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "중복 건너뜀: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "지원되지 않는 반복 세트: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "캘린더 파일을 가져올 수 없습니다: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "오프라인", "networkOfflineDescription": "연결이 복구되면 변경 사항이 동기화됩니다.", "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 42233317..308bd4e6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,14 +1,14 @@ { "@@locale": "pt", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Ligue as contas Google, Microsoft, Calendário Apple iCloud ou Nextcloud.", "googlePermissionsConsentNotice": "No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.", "googlePermissionsRequiredRetry": "As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.", "finishSetup": "Concluir configuração", "continueSetup": "Continuar", "onboardingSetupTitle": "Configurar o BusyMax", "onboardingAccountsStepTitle": "Ligar contas", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Adicione todas as contas que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas suportados de cada conta.", "onboardingPreferencesStepTitle": "Escolher definições do sistema", "onboardingPreferencesStepDescription": "Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.", "signInWithGoogle": "Iniciar sessão com a Google", @@ -36,16 +36,40 @@ "newCalendar": "Novo calendário", "calendarColor": "Cor do calendário", "calendarColorOption": "Cor {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, - "calendarManagementUnsupported": "Este provedor não oferece suporte ao gerenciamento de calendários no BusyMax.", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Este fornecedor não suporta a gestão de calendários no BusyMax.", "primaryCalendarCannotDelete": "O calendário principal não pode ser excluído.", "calendarCreateFailed": "Não foi possível criar o calendário: {error}", "calendarCreatedRefreshPending": "O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Ele aparecerá após a próxima sincronização.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Não foi possível atualizar o calendário: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Não foi possível excluir o calendário: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Novo evento", "refreshCalendar": "Atualizar calendário", "openInProvider": "Abrir no serviço", @@ -65,38 +89,62 @@ "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", "refresh": "Atualizar", "trayOpenBusyMax": "Abrir o BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Mostrar o BusyMax", + "trayNewEvent": "Novo evento…", + "trayNewTask": "Nova tarefa…", + "trayToday": "Hoje", + "trayAllDay": "Todo o dia", + "trayNow": "Agora", + "trayCalendarEvent": "Evento do calendário", + "trayUntitledEvent": "Evento sem título", + "trayNothingElseToday": "Nada mais hoje", + "trayTasksDueToday": "{count, plural, =1{1 tarefa termina hoje} other{{count} tarefas terminam hoje}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Abrir a agenda de hoje", + "traySyncNow": "Sincronizar agora", + "traySyncing": "A sincronizar…", + "trayNotConnected": "Não ligado", + "trayNotYetSynced": "Ainda não sincronizado", + "trayLastSyncedJustNow": "Sincronizado agora mesmo", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizado há 1 minuto} other{Sincronizado há {count} minutos}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizado há 1 hora} other{Sincronizado há {count} horas}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizado há 1 dia} other{Sincronizado há {count} dias}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Definições", + "trayQuitBusyMax": "Sair do BusyMax", "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", "viewDay": "Dia", "viewWeek": "Semana", "viewMonth": "Mês", "viewYear": "Ano", - "viewAgenda": "Agenda", + "viewAgenda": "Vista de agenda", "scheduleSettings": "Agenda", "scheduleDisplaySettings": "Apresentação da agenda", "scheduleDisplayHoursDescription": "As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.", @@ -143,7 +191,7 @@ "visibilityPublic": "Pública", "visibilityPrivate": "Privada", "visibilityConfidential": "Confidencial", - "sensitivityNormal": "Normal", + "sensitivityNormal": "Habitual", "sensitivityPersonal": "Pessoal", "tasks": "Tarefas", "allTasks": "Todas as tarefas", @@ -218,7 +266,7 @@ "removingAccount": "A remover conta…", "removeAccountDescription": "Parar a sincronização e remover os dados desta conta deste dispositivo.", "removeAccountTitle": "Remover {account} do BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Esta ação elimina deste dispositivo as tarefas, calendários, eventos, lembretes e alterações offline pendentes colocados em cache. As alterações não sincronizadas serão perdidas. As cópias dos calendários, eventos, listas de tarefas e tarefas no fornecedor não são eliminadas.", "revokeGoogleAccess": "Revogar também o acesso do BusyMax a esta conta Google", "revokeGoogleAccessDescription": "Terá de conceder acesso novamente antes de voltar a ligar a conta.", "removeAccountAction": "Remover conta", @@ -234,7 +282,7 @@ "deleteList": "Eliminar lista", "builtInMicrosoftList": "Incorporada", "builtInMicrosoftListCannotRenameDelete": "As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.", - "deleteListConfirmation": "Eliminar «{title}» do Google Tasks?", + "deleteListConfirmation": "Eliminar “{title}” do Google Tasks?", "deleteEvent": "Eliminar evento", "title": "Título", "create": "Criar", @@ -297,7 +345,7 @@ "repeatYearly": "Anualmente", "importance": "Importância", "importanceLow": "Baixa", - "importanceNormal": "Normal", + "importanceNormal": "Média", "importanceHigh": "Alta", "categories": "Categorias", "scheduleSection": "Agenda", @@ -347,7 +395,7 @@ "taskReminders": "Lembretes de tarefas", "notificationDetailLevel": "Nível de detalhe das notificações", "notificationDetailPrivate": "Privado", - "notificationDetailNormal": "Normal", + "notificationDetailNormal": "Predefinido", "quietHours": "Período de silêncio", "quietHoursDescription": "Pausar as notificações durante este período.", "quietHoursStart": "Início do período de silêncio", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "O prazo da tarefa aproxima-se.", "notificationOpenAction": "Abrir", "notificationSnoozeAction": "Adiar 10 minutos", - "notificationDismissAction": "Dispensar", + "notificationDismissAction": "Fechar", "notificationDetailsHidden": "Os detalhes estão ocultos pelas definições de privacidade.", "previousMonth": "Mês anterior", "nextMonth": "Mês seguinte", @@ -414,51 +462,51 @@ "readOnlyCalendar": "Este calendário é só de leitura.", "selectTimeZone": "Selecionar fuso horário", "searchLocations": "Pesquisar locais", - "noLocationsFound": "Nenhum local encontrado", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "noLocationsFound": "Não foram encontrados locais", + "requiredField": "Este campo é obrigatório.", + "providerConnectionDescription": "Ligue calendários e tarefas a um destes fornecedores.", + "appleICloudProvider": "Calendário Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Tarefas do Nextcloud", + "addAppleICloudAccount": "Adicionar conta do Calendário Apple iCloud", + "addNextcloudAccount": "Adicionar conta Nextcloud", + "waitingForAppleICloud": "A ligar ao Apple iCloud…", + "waitingForNextcloud": "A aguardar autorização do Nextcloud…", + "connectAppleICloudTitle": "Ligar o Calendário Apple iCloud", + "appleAccountEmail": "E-mail da conta Apple", + "appleAppSpecificPassword": "Palavra-passe específica da aplicação", + "appleAppSpecificPasswordHelp": "Crie uma palavra-passe específica da aplicação depois de ativar a autenticação de dois fatores na sua conta Apple.", + "appleAppSpecificPasswordResetWarning": "A reposição da palavra-passe da conta Apple revoga as palavras-passe específicas de aplicações.", + "connectNextcloudTitle": "Ligar ao Nextcloud", + "nextcloudServerUrl": "Servidor Nextcloud ou endereço CalDAV", + "nextcloudServerUrlHelp": "Introduza o URL do servidor Nextcloud ou cole o endereço CalDAV principal copiado do Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "O BusyMax abrirá o navegador. Aprove o acesso aí e volte ao BusyMax.", + "connectAccountAction": "Ligar", + "cancelAccountConnection": "Cancelar ligação", + "nextcloudAccountRemovedRevokeFailed": "A conta foi removida localmente, mas não foi possível revogar a palavra-passe da aplicação Nextcloud.", + "davCachedOfflineNotice": "Os dados de calendários e tarefas são colocados em cache localmente para utilização offline.", + "davReauthenticationRequired": "Volte a ligar esta conta para retomar a sincronização.", + "davTemporarilyUnavailable": "Esta conta está temporariamente indisponível.", + "davPermissionChanged": "As permissões do servidor foram alteradas. As edições pendentes estão em pausa.", + "davUnsupportedServer": "Este servidor ou perfil do fornecedor não é suportado.", + "collectionSettings": "Calendários e listas de tarefas", + "calendarContent": "Eventos do calendário", + "taskContent": "Tarefas", + "readOnlySharedCollection": "Só de leitura", + "pendingLocally": "Pendente localmente", + "conflictBlocked": "Bloqueado por conflito", + "authenticationBlocked": "Bloqueado até voltar a ligar", + "operationFailed": "Operação falhou", + "keepServerVersion": "Manter versão do servidor", + "reapplyLocalChange": "Rever e reaplicar alteração local", + "duplicateLocalItem": "Duplicar como novo item", "deleteCalendarConfirmation": "Eliminar «{title}»?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Estado da ligação", + "davConnected": "Ligado", + "davConnecting": "A ligar…", + "davSignedOut": "Sessão terminada", + "davLastSuccessfulSync": "Última sincronização bem-sucedida: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Ainda não sincronizado", + "refreshCollections": "Atualizar calendários e listas de tarefas", + "nextcloudServerHost": "Servidor: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Calendário de eventos", + "collectionSupportsTasks": "Lista de tarefas", + "collectionSupportsEventsAndTasks": "Eventos e tarefas", + "writableCollection": "Editável", + "sharedCollection": "Partilhado", + "collectionLastSynced": "Última sincronização: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Problema de sincronização: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Conflitos de sincronização", + "remoteChangedAt": "Alteração no servidor: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Edição local: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,216 +562,300 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "Não foi possível resolver o conflito.", + "recurringEventScope": "Âmbito do evento recorrente", + "entireSeries": "Série inteira", "singleOccurrence": "Este evento", "thisAndFollowingEvents": "Este evento e os seguintes", - "thisAndFutureUnavailable": "Não suportado por este provedor.", + "thisAndFutureUnavailable": "Não suportado por este fornecedor.", "thisAndFutureMoveUnavailable": "Este evento e os seguintes não podem ser movidos com segurança. Escolha este evento ou a série inteira.", "entireSeriesMoveUnavailable": "A regra de recorrência não está disponível localmente. Mova apenas este evento.", - "copyEventAndDeleteOriginal": "Copiar o evento e excluir o original?", - "copyEventMoveWarning": "O BusyMax não pode mover este evento diretamente de {source} para {destination}. Ele criará a cópia primeiro e excluirá o original somente depois que a cópia for concluída. Os IDs do evento mudarão; os status de resposta dos participantes poderão ser redefinidos e convites ou cancelamentos poderão ser enviados; e links de reunião, anexos, lembretes, campos específicos do provedor e exceções de recorrência poderão não ser transferidos.", - "copyAndDelete": "Copiar e excluir", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "copyEventAndDeleteOriginal": "Copiar o evento e eliminar o original?", + "copyEventMoveWarning": "O BusyMax não pode mover este evento diretamente de {source} para {destination}. Criará primeiro a cópia e eliminará o original apenas depois de a cópia ser criada com êxito. Os IDs do evento mudarão; os estados das respostas dos participantes poderão ser repostos e poderão ser enviados convites ou cancelamentos; as ligações de reuniões, os anexos, os lembretes, os campos específicos do fornecedor e as exceções de recorrência poderão não ser transferidos.", + "copyAndDelete": "Copiar e eliminar", + "chooseRecurringEventScope": "Escolha se esta alteração se aplica à série inteira, apenas a este evento ou a este e aos eventos seguintes.", "taskDueBeforeStart": "O prazo não pode ser anterior ao início.", "taskStartDueTimeModeMismatch": "Defina horários para o início e o prazo, ou torne a tarefa de dia inteiro.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "Não foi possível criar a lista de tarefas: {error}", + "taskListRenameFailed": "Não foi possível mudar o nome da lista de tarefas: {error}", + "taskListDeleteFailed": "Não foi possível eliminar a lista de tarefas: {error}", "unshare": "Cancelar partilha", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "Em andamento", + "readOnlyTaskListCannotRename": "Esta lista de tarefas é só de leitura e não pode ter o nome alterado.", + "taskListCannotDelete": "Esta lista de tarefas não pode ser eliminada com as permissões atuais.", + "deleteTaskListConfirmation": "Eliminar “{title}” e todas as suas tarefas?", + "unshareTaskListConfirmation": "Cancelar a partilha de “{title}” desta conta?", + "taskStatus": "Estado", + "taskStatusNone": "Sem estado", + "taskStatusNeedsAction": "Requer ação", + "taskStatusInProcess": "Em curso", "taskStatusCompleted": "Concluída", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusCancelled": "Cancelada", + "completionPercent": "{percent}% concluída", + "completionDate": "Data de conclusão", "priority": "Prioridade", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Repetir a cada", - "repeatOn": "Repeat on", - "repeatEnd": "End repeat", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "priorityNone": "Sem prioridade", + "priorityHighValue": "Prioridade {priority} · Alta", + "priorityMediumValue": "Prioridade {priority} · Média", + "priorityLowValue": "Prioridade {priority} · Baixa", + "taskUrl": "URL da tarefa", + "invalidTaskUrl": "Introduza um URL absoluto, incluindo o esquema.", + "classification": "Classificação", + "classificationPublic": "Quando partilhada, mostrar a tarefa completa", + "classificationConfidential": "Quando partilhada, mostrar apenas ocupado", + "classificationPrivate": "Quando partilhada, ocultar esta tarefa", + "pinTask": "Afixar tarefa", + "reminders": "Lembretes", + "noReminders": "Sem lembretes", + "editReminder": "Editar lembrete", + "beforeTaskStarts": "Antes do início da tarefa", + "beforeTaskDue": "Antes do prazo da tarefa", + "afterTaskStarts": "Depois do início da tarefa", + "afterTaskDue": "Depois do prazo da tarefa", + "relativeToTaskStart": "Relativo à data de início da tarefa", + "relativeToTaskDue": "Relativo à data limite da tarefa", + "reminderTimeOfDay": "Hora do dia", + "absoluteReminder": "Numa data e hora", + "reminderAmount": "Quantidade", + "reminderUnitSeconds": "Segundos", + "reminderUnitMinutes": "Minutos", + "reminderUnitHours": "Horas", + "reminderUnitDays": "Dias", + "reminderUnitWeeks": "Semanas", + "reminderAtTaskStart": "No início da tarefa", + "reminderAtTaskDue": "No prazo da tarefa", + "unsupportedReminder": "Este tipo de lembrete é preservado, mas a hora não pode ser editada.", + "relatedRemindersTitle": "Manter lembretes relacionados?", + "relatedRemindersDescription": "Esta data tem {count} lembretes relacionados. Mantê-los na data e hora atuais?", + "discardRelatedReminders": "Eliminar lembretes", + "keepRelatedReminders": "Manter lembretes", + "repeatEvery": "Intervalo", + "repeatOn": "Repetir em", + "repeatEnd": "Terminar repetição", + "repeatNever": "Nunca", + "repeatUntil": "Numa data", + "repeatAfter": "Depois de um número de ocorrências", + "repeatCount": "Ocorrências", + "repeatDayOfMonth": "Dias do mês", + "repeatMonths": "Meses", + "repeatOrdinal": "Posição do dia da semana", + "repeatSpecificDays": "Dias específicos", + "repeatFirst": "Primeiro", + "repeatSecond": "Segundo", + "repeatThird": "Terceiro", + "repeatFourth": "Quarto", + "repeatFifth": "Quinto", + "repeatSecondToLast": "Penúltimo", + "repeatLast": "Último", + "repeatAnyDay": "Dia", + "repeatWeekday": "Dia útil", + "repeatWeekendDay": "Dia do fim de semana", + "repeatEveryDays": "A cada {count} dias", + "repeatEveryWeeks": "A cada {count} semanas", + "repeatEveryMonths": "A cada {count} meses", + "repeatEveryYears": "A cada {count} anos", "repeatOnDaysSummary": "em {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", + "repeatOnMonthDaysSummary": "no dia {days}", + "repeatOnOrdinalSummary": "na {ordinal}ª ocorrência de {days}", + "repeatInMonthsSummary": "em {months}", "repeatTimesSummary": "{count} vezes", "repeatUntilSummary": "até {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "Esta regra de recorrência utiliza opções que este editor não altera.", "recurrenceUnsupportedByProvider": "Esta recorrência não pode ser usada com {provider}.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "duplicateTask": "Duplicar tarefa", + "taskDuplicated": "Tarefa duplicada.", + "taskDuplicateFailed": "Não foi possível duplicar a tarefa: {error}", "hideSubtasks": "Ocultar subtarefas", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "Ocultar subtarefas concluídas", + "reminderUnit": "Unidade", + "attendeeRequired": "Obrigatório", + "attendeeOptional": "Opcional", + "meetingSection": "Reunião", + "addGoogleMeet": "Adicionar o Google Meet", + "addTeamsMeeting": "Adicionar reunião do Microsoft Teams", + "onlineMeetingAdded": "Reunião online adicionada", + "requestResponses": "Pedir respostas", + "requestResponsesDescription": "Peça aos convidados que respondam ao convite.", + "hideGuestList": "Ocultar lista de convidados", + "hideGuestListDescription": "Os convidados não podem ver quem mais foi convidado.", + "allowNewTimeProposals": "Permitir novas propostas de horário", + "allowNewTimeProposalsDescription": "Os convidados podem sugerir uma hora diferente para a reunião.", + "notifyGuestsTitle": "Notificar convidados?", + "notifyGuestsSaveMessage": "Esta reunião tem convidados. Enviar convites ou atualizações do evento ao guardá-la?", + "notifyGuestsDeleteMessage": "Esta reunião tem convidados. Enviar um cancelamento ao eliminá-la?", + "sendUpdates": "Enviar atualizações", + "sendCancellation": "Enviar cancelamento", + "doNotSend": "Não enviar", + "microsoftNotifyGuestsSaveTitle": "Guardar reunião?", + "microsoftNotifyGuestsSaveMessage": "A Microsoft enviará convites ou atualizações do evento aos convidados.", + "microsoftNotifyGuestsDeleteTitle": "Eliminar reunião?", + "microsoftNotifyGuestsDeleteMessage": "A Microsoft enviará um cancelamento aos convidados.", + "organizer": "Organizador", + "yourResponse": "A sua resposta", + "guestResponses": "Respostas dos convidados", + "respond": "Responder", + "acceptInvitation": "Aceitar", + "tentativeInvitation": "Provisório", + "declineInvitation": "Recusar", + "joinMeeting": "Participar na reunião", + "responseAccepted": "Aceite", + "responseTentative": "Provisório", + "responseDeclined": "Recusado", + "responseNeedsAction": "A aguardar resposta", + "responseNotResponded": "Sem resposta", + "responseOrganizer": "Organizador", + "invitationResponseFailed": "Não foi possível enviar a sua resposta: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Não foi possível abrir a ligação da reunião.", "setCustomCalendarName": "Definir nome personalizado", "setAction": "Definir", "removeFromMyCalendars": "Remover dos meus calendários", "removeAction": "Remover", - "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Agenda? A agenda compartilhada e os eventos dela não serão excluídos.", - "calendarCannotRemove": "Não é possível excluir nem remover esta agenda desta conta.", - "calendarPendingChangesPreventRemoval": "Aguarde a sincronização das alterações pendentes desta agenda antes de excluí-la ou removê-la.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "Importar calendário", - "calendarImportDescription": "Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os receberá.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, - "networkOffline": "Offline", + "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Calendar? O calendário partilhado e os respetivos eventos não serão eliminados.", + "calendarCannotRemove": "Não é possível eliminar nem remover este calendário desta conta.", + "calendarPendingChangesPreventRemoval": "Aguarde que as alterações pendentes deste calendário terminem de sincronizar antes de o eliminar ou remover.", + "calendarSubscriptions": "Subscrições de calendários", + "calendarSubscriptionsDescription": "Adicione calendários só de leitura que sejam atualizados a partir de um URL WebCal seguro.", + "addCalendarSubscription": "Adicionar subscrição de calendário", + "subscriptionName": "Nome local", + "subscriptionUrl": "URL da subscrição", + "subscriptionUrlHelp": "Introduza um URL HTTPS ou webcal. O BusyMax mantém o URL completo num armazenamento seguro.", + "subscriptionUrlInvalid": "Introduza um URL HTTPS ou webcal válido sem informações de utilizador ou fragmento.", + "subscriptionColor": "Cor local", + "subscriptionColorHelp": "Utilize uma cor de seis dígitos, como #3584E4.", + "subscriptionColorInvalid": "Introduza uma cor hexadecimal de seis dígitos.", + "subscriptionRefreshMode": "Frequência de atualização", + "subscriptionAutomatic": "Automática", + "subscriptionHourly": "De hora a hora", + "subscriptionSixHours": "A cada seis horas", + "subscriptionDaily": "Diária", + "subscriptionSafeOrigin": "Origem: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Introduza um URL válido para pré-visualizar a origem segura.", + "subscriptionReadOnly": "Subscrição só de leitura", + "subscriptionNeverRefreshed": "Ainda não atualizada", + "subscriptionLastRefresh": "Última atualização bem-sucedida: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Próxima atualização: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Atualizada", + "subscriptionStatusIssue": "Problema de atualização: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Atualizar agora", + "unsubscribe": "Cancelar subscrição", + "unsubscribeCalendarTitle": "Cancelar subscrição de “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Isto remove a subscrição local e os eventos colocados em cache. O calendário publicado não é alterado.", + "addSubscriptionAction": "Adicionar subscrição", + "subscriptionOperationFailed": "Falha na subscrição do calendário: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Subscrições", + "calendarImport": "Importação de calendário", + "calendarImportDescription": "Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os deve receber.", + "importIcsFile": "Importar ficheiro .ics", + "importIcsPreview": "Importar eventos do calendário", + "importEventsFound": "Conjuntos de eventos importáveis: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Eventos inválidos: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Omitidos intencionalmente: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Não está disponível nenhum calendário de destino editável.", + "importDestinationCalendar": "Calendário de destino", + "importIcsConfirm": "Importar eventos", + "importIcsComplete": "Importação concluída", + "importQueued": "Importados ou em fila: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Duplicados ignorados: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Conjuntos de recorrência não suportados: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Não foi possível importar o ficheiro de calendário: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "networkOffline": "Sem ligação", "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 546ce553..fcf39ed5 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,14 +1,14 @@ { "@@locale": "ru", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Подключите аккаунты Google, Microsoft, Apple iCloud Calendar или Nextcloud.", "googlePermissionsConsentNotice": "На экране запроса доступа Google установите флажки «Google Календарь» и «Google Задачи».", "googlePermissionsRequiredRetry": "BusyMax требуется доступ к Google Календарю и Google Задачам. Повторите попытку и установите оба флажка.", "finishSetup": "Завершить настройку", "continueSetup": "Продолжить", "onboardingSetupTitle": "Настройка BusyMax", "onboardingAccountsStepTitle": "Подключите аккаунты", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Добавьте все нужные аккаунты. BusyMax синхронизирует поддерживаемые календари, события, списки задач и задачи из каждого аккаунта.", "onboardingPreferencesStepTitle": "Выберите системные параметры", "onboardingPreferencesStepDescription": "Настройте поведение приложения на рабочем столе, напоминания, уровень детализации уведомлений и внешний вид, прежде чем открыть расписание.", "signInWithGoogle": "Войти через Google", @@ -36,16 +36,40 @@ "newCalendar": "Новый календарь", "calendarColor": "Цвет календаря", "calendarColorOption": "Цвет {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "Этот провайдер не поддерживает управление календарями в BusyMax.", "primaryCalendarCannotDelete": "Основной календарь нельзя удалить.", "calendarCreateFailed": "Не удалось создать календарь: {error}", "calendarCreatedRefreshPending": "Календарь создан, но BusyMax не смог обновить учётную запись. Он появится после следующей синхронизации.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Не удалось обновить календарь: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Не удалось удалить календарь: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Новое событие", "refreshCalendar": "Обновить календарь", "openInProvider": "Открыть в сервисе", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "Попробуйте изменить запрос или сбросить текущие фильтры.", "refresh": "Обновить", "trayOpenBusyMax": "Открыть BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Показать BusyMax", + "trayNewEvent": "Новое событие…", + "trayNewTask": "Новая задача…", + "trayToday": "Сегодня", + "trayAllDay": "Весь день", + "trayNow": "Сейчас", + "trayCalendarEvent": "Событие календаря", + "trayUntitledEvent": "Событие без названия", + "trayNothingElseToday": "На сегодня больше ничего", + "trayTasksDueToday": "{count, plural, one{Сегодня истекает срок 1 задачи} few{Сегодня истекает срок {count} задач} many{Сегодня истекает срок {count} задач} other{Сегодня истекает срок {count} задачи}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Открыть расписание на сегодня", + "traySyncNow": "Синхронизировать сейчас", + "traySyncing": "Синхронизация…", + "trayNotConnected": "Не подключено", + "trayNotYetSynced": "Ещё не синхронизировано", + "trayLastSyncedJustNow": "Синхронизировано только что", + "trayLastSyncedMinutesAgo": "{count, plural, one{Синхронизировано минуту назад} few{Синхронизировано {count} минуты назад} many{Синхронизировано {count} минут назад} other{Синхронизировано {count} минуты назад}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, one{Синхронизировано час назад} few{Синхронизировано {count} часа назад} many{Синхронизировано {count} часов назад} other{Синхронизировано {count} часа назад}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, one{Синхронизировано день назад} few{Синхронизировано {count} дня назад} many{Синхронизировано {count} дней назад} other{Синхронизировано {count} дня назад}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Настройки", + "trayQuitBusyMax": "Выйти из BusyMax", "agendaLoadMoreOverdue": "Загрузить ещё просроченные задачи", "agendaLoadMoreNoDate": "Загрузить ещё задачи без даты", "viewDay": "День", @@ -218,7 +266,7 @@ "removingAccount": "Удаление аккаунта…", "removeAccountDescription": "Остановить синхронизацию и удалить данные этого аккаунта с устройства.", "removeAccountTitle": "Удалить {account} из BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Копии календарей, событий, списков задач и задач у поставщика не удаляются.", "revokeGoogleAccess": "Также отозвать у BusyMax доступ к этому аккаунту Google", "revokeGoogleAccessDescription": "Перед повторным подключением аккаунта потребуется снова предоставить доступ.", "removeAccountAction": "Удалить аккаунт", @@ -271,7 +319,7 @@ "primarySection": "Основные сведения", "statusSection": "Состояние", "openStatus": "Открыта", - "doneStatus": "Выполнена", + "doneStatus": "Выполнено", "notes": "Заметки", "dueDate": "Срок", "clearDueDate": "Удалить срок выполнения", @@ -391,7 +439,7 @@ "syncFailureNotificationTitle": "Сбой синхронизации BusyMax", "syncFailureNotificationBody": "Сбой фоновой синхронизации. {message}", "conflictNotificationTitle": "Конфликт синхронизации BusyMax", - "conflictNotificationBody": "Локальное изменение не удалось синхронизировать. {summary}", + "conflictNotificationBody": "Ожидающее локальное изменение заблокировано. {summary}", "dueTodayNotificationTitle": "Задачи на сегодня", "dueTodayNotificationBody": "{count, plural, one{Сегодня нужно выполнить {count} задачу.} few{Сегодня нужно выполнить {count} задачи.} many{Сегодня нужно выполнить {count} задач.} other{Сегодня нужно выполнить {count} задачи.}}", "eventReminderNotificationTitle": "Напоминание о событии", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "Срок выполнения задачи скоро наступит.", "notificationOpenAction": "Открыть", "notificationSnoozeAction": "Отложить на 10 минут", - "notificationDismissAction": "Отклонить", + "notificationDismissAction": "Закрыть", "notificationDetailsHidden": "Сведения скрыты настройками конфиденциальности.", "previousMonth": "Предыдущий месяц", "nextMonth": "Следующий месяц", @@ -413,52 +461,52 @@ "scheduleItemCount": "{count, plural, one{{count} запись} few{{count} записи} many{{count} записей} other{{count} записи}}", "readOnlyCalendar": "Этот календарь доступен только для чтения.", "selectTimeZone": "Выберите часовой пояс", - "searchLocations": "Поиск города", - "noLocationsFound": "Ничего не найдено", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "searchLocations": "Поиск мест", + "noLocationsFound": "Места не найдены", + "requiredField": "Это поле обязательно.", + "providerConnectionDescription": "Подключите календари и задачи одного из следующих поставщиков.", + "appleICloudProvider": "Календарь Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Задачи Nextcloud", + "addAppleICloudAccount": "Добавить аккаунт Календаря Apple iCloud", + "addNextcloudAccount": "Добавить аккаунт Nextcloud", + "waitingForAppleICloud": "Подключение к Apple iCloud…", + "waitingForNextcloud": "Ожидание авторизации Nextcloud…", + "connectAppleICloudTitle": "Подключить Календарь Apple iCloud", + "appleAccountEmail": "Электронная почта аккаунта Apple", + "appleAppSpecificPassword": "Пароль для приложения", + "appleAppSpecificPasswordHelp": "Создайте пароль для приложения после включения двухфакторной аутентификации аккаунта Apple.", + "appleAppSpecificPasswordResetWarning": "Сброс пароля аккаунта Apple отзывает пароли для приложений.", + "connectNextcloudTitle": "Подключить Nextcloud", + "nextcloudServerUrl": "Сервер Nextcloud или адрес CalDAV", + "nextcloudServerUrlHelp": "Введите URL сервера Nextcloud или вставьте основной адрес CalDAV, скопированный из Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax откроет браузер. Разрешите доступ и вернитесь в BusyMax.", + "connectAccountAction": "Подключить", + "cancelAccountConnection": "Отменить подключение", + "nextcloudAccountRemovedRevokeFailed": "Аккаунт удалён локально, но пароль приложения Nextcloud не удалось отозвать.", + "davCachedOfflineNotice": "Данные календаря и задач кэшируются локально для работы без подключения.", + "davReauthenticationRequired": "Подключите аккаунт заново, чтобы возобновить синхронизацию.", + "davTemporarilyUnavailable": "Этот аккаунт временно недоступен.", + "davPermissionChanged": "Разрешения сервера изменились. Ожидающие изменения приостановлены.", + "davUnsupportedServer": "Этот сервер или профиль поставщика не поддерживается.", + "collectionSettings": "Календари и списки задач", + "calendarContent": "События календаря", + "taskContent": "Задачи", + "readOnlySharedCollection": "Только чтение", + "pendingLocally": "Ожидает локально", + "conflictBlocked": "Заблокировано из-за конфликта", + "authenticationBlocked": "Заблокировано до повторного подключения", + "operationFailed": "Операция не выполнена", + "keepServerVersion": "Сохранить версию сервера", + "reapplyLocalChange": "Проверить и повторно применить локальное изменение", + "duplicateLocalItem": "Дублировать как новый элемент", "deleteCalendarConfirmation": "Удалить «{title}»?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Состояние подключения", + "davConnected": "Подключено", + "davConnecting": "Подключение…", + "davSignedOut": "Выполнен выход", + "davLastSuccessfulSync": "Последняя успешная синхронизация: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Ещё не синхронизировано", + "refreshCollections": "Обновить календари и списки задач", + "nextcloudServerHost": "Сервер: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Календарь событий", + "collectionSupportsTasks": "Список задач", + "collectionSupportsEventsAndTasks": "События и задачи", + "writableCollection": "Доступно для записи", + "sharedCollection": "Общий доступ", + "collectionLastSynced": "Последняя синхронизация: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Проблема синхронизации: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Конфликты синхронизации", + "remoteChangedAt": "Изменено на сервере: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Локальное изменение: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "Не удалось разрешить конфликт.", + "recurringEventScope": "Область повторяющегося события", + "entireSeries": "Вся серия", "singleOccurrence": "Это событие", "thisAndFollowingEvents": "Это и последующие события", "thisAndFutureUnavailable": "Не поддерживается этим поставщиком.", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "Скопировать событие и удалить оригинал?", "copyEventMoveWarning": "BusyMax не может напрямую переместить это событие из {source} в {destination}. Сначала будет создана копия, а оригинал будет удалён только после успешного копирования. Идентификаторы события изменятся; статусы ответов участников могут быть сброшены, а приглашения или отмены — отправлены; ссылки на конференции, вложения, напоминания, поля поставщика и исключения повтора могут не сохраниться.", "copyAndDelete": "Скопировать и удалить", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Выберите, применять ли это изменение ко всей серии, только к этому событию или к этому и последующим событиям.", "taskDueBeforeStart": "Срок не может быть раньше начала.", "taskStartDueTimeModeMismatch": "Укажите время начала и срока или сделайте задачу на весь день.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Закрыть доступ", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "taskListCreateFailed": "Не удалось создать список задач: {error}", + "taskListRenameFailed": "Не удалось переименовать список задач: {error}", + "taskListDeleteFailed": "Не удалось удалить список задач: {error}", + "unshare": "Отменить общий доступ", + "readOnlyTaskListCannotRename": "Этот список задач доступен только для чтения и не может быть переименован.", + "taskListCannotDelete": "Этот список задач нельзя удалить с текущими разрешениями.", + "deleteTaskListConfirmation": "Удалить «{title}» и все его задачи?", + "unshareTaskListConfirmation": "Отменить общий доступ к «{title}» для этого аккаунта?", + "taskStatus": "Статус", + "taskStatusNone": "Нет статуса", "taskStatusNeedsAction": "Требуется действие", - "taskStatusInProcess": "Выполянется", - "taskStatusCompleted": "Завершённые", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "Выполняется", + "taskStatusCompleted": "Завершена", + "taskStatusCancelled": "Отменена", + "completionPercent": "Выполнено на {percent}%", + "completionDate": "Дата выполнения", "priority": "Приоритет", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "Напоминаний нет", - "editReminder": "Edit reminder", + "priorityNone": "Без приоритета", + "priorityHighValue": "Приоритет {priority} · высокий", + "priorityMediumValue": "Приоритет {priority} · средний", + "priorityLowValue": "Приоритет {priority} · низкий", + "taskUrl": "URL задачи", + "invalidTaskUrl": "Введите абсолютный URL вместе со схемой.", + "classification": "Классификация", + "classificationPublic": "При общем доступе показывать задачу полностью", + "classificationConfidential": "При общем доступе показывать только занятость", + "classificationPrivate": "При общем доступе скрывать эту задачу", + "pinTask": "Закрепить задачу", + "reminders": "Напоминания", + "noReminders": "Нет напоминаний", + "editReminder": "Изменить напоминание", "beforeTaskStarts": "До начала задачи", "beforeTaskDue": "До срока выполнения задачи", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Отменить напоминания", + "afterTaskStarts": "После начала задачи", + "afterTaskDue": "После срока выполнения задачи", + "relativeToTaskStart": "Относительно даты начала задачи", + "relativeToTaskDue": "Относительно срока выполнения задачи", + "reminderTimeOfDay": "Время суток", + "absoluteReminder": "В указанную дату и время", + "reminderAmount": "Количество", + "reminderUnitSeconds": "Секунды", + "reminderUnitMinutes": "Минуты", + "reminderUnitHours": "Часы", + "reminderUnitDays": "Дни", + "reminderUnitWeeks": "Недели", + "reminderAtTaskStart": "В момент начала задачи", + "reminderAtTaskDue": "В момент наступления срока задачи", + "unsupportedReminder": "Тип этого напоминания сохраняется, но его время нельзя изменить.", + "relatedRemindersTitle": "Сохранить связанные напоминания?", + "relatedRemindersDescription": "На эту дату приходится {count} связанных напоминаний. Сохранить их текущие дату и время?", + "discardRelatedReminders": "Удалить напоминания", "keepRelatedReminders": "Сохранить напоминания", - "repeatEvery": "Повторять каждые", - "repeatOn": "Repeat on", - "repeatEnd": "Прекратить повтор", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "в {days}", - "repeatOnMonthDaysSummary": " {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "в {months}", + "repeatEvery": "Интервал", + "repeatOn": "Повторять в", + "repeatEnd": "Завершить повтор", + "repeatNever": "Никогда", + "repeatUntil": "В указанную дату", + "repeatAfter": "После указанного числа повторений", + "repeatCount": "Число повторений", + "repeatDayOfMonth": "Дни месяца", + "repeatMonths": "Месяцы", + "repeatOrdinal": "Порядок дня недели", + "repeatSpecificDays": "Определённые дни", + "repeatFirst": "Первый", + "repeatSecond": "Второй", + "repeatThird": "Третий", + "repeatFourth": "Четвёртый", + "repeatFifth": "Пятый", + "repeatSecondToLast": "Предпоследний", + "repeatLast": "Последний", + "repeatAnyDay": "День", + "repeatWeekday": "Будний день", + "repeatWeekendDay": "Выходной день", + "repeatEveryDays": "Каждые {count} дн.", + "repeatEveryWeeks": "Каждые {count} нед.", + "repeatEveryMonths": "Каждые {count} мес.", + "repeatEveryYears": "Каждые {count} г.", + "repeatOnDaysSummary": "в дни {days}", + "repeatOnMonthDaysSummary": "{days}-го числа месяца", + "repeatOnOrdinalSummary": "{ordinal} {days}", + "repeatInMonthsSummary": "в месяцы {months}", "repeatTimesSummary": "{count} раз", "repeatUntilSummary": "до {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "unsupportedRecurrencePreserved": "Это правило повторения использует параметры, которые этот редактор не изменяет.", "recurrenceUnsupportedByProvider": "Это повторение нельзя использовать с {provider}.", "duplicateTask": "Дублировать задачу", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Скрыть вложенные задачи", + "taskDuplicated": "Задача продублирована.", + "taskDuplicateFailed": "Не удалось продублировать задачу: {error}", + "hideSubtasks": "Скрыть подзадачи", "hideClosedSubtasks": "Скрыть закрытые подзадачи", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "Единица", + "attendeeRequired": "Обязательно", + "attendeeOptional": "Необязательно", + "meetingSection": "Встреча", + "addGoogleMeet": "Добавить Google Meet", + "addTeamsMeeting": "Добавить встречу Microsoft Teams", + "onlineMeetingAdded": "Онлайн-встреча добавлена", + "requestResponses": "Запрашивать ответы", + "requestResponsesDescription": "Попросите гостей ответить на приглашение.", + "hideGuestList": "Скрыть список гостей", + "hideGuestListDescription": "Гости не видят других приглашённых.", + "allowNewTimeProposals": "Разрешить новые предложения времени", + "allowNewTimeProposalsDescription": "Гости могут предложить другое время встречи.", + "notifyGuestsTitle": "Уведомить гостей?", + "notifyGuestsSaveMessage": "На встрече есть гости. Отправить приглашения или обновления события при сохранении?", + "notifyGuestsDeleteMessage": "На встрече есть гости. Отправить отмену при удалении?", + "sendUpdates": "Отправить обновления", + "sendCancellation": "Отправить отмену", + "doNotSend": "Не отправлять", + "microsoftNotifyGuestsSaveTitle": "Сохранить встречу?", + "microsoftNotifyGuestsSaveMessage": "Microsoft отправит гостям приглашения или обновления события.", + "microsoftNotifyGuestsDeleteTitle": "Удалить встречу?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft отправит гостям отмену.", + "organizer": "Организатор", + "yourResponse": "Ваш ответ", + "guestResponses": "Ответы гостей", + "respond": "Ответить", + "acceptInvitation": "Принять", + "tentativeInvitation": "Под вопросом", + "declineInvitation": "Отклонить", + "joinMeeting": "Присоединиться к встрече", + "responseAccepted": "Принято", + "responseTentative": "Под вопросом", + "responseDeclined": "Отклонено", + "responseNeedsAction": "Ожидается ответ", + "responseNotResponded": "Нет ответа", + "responseOrganizer": "Организатор", + "invitationResponseFailed": "Не удалось отправить ответ: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Не удалось открыть ссылку на встречу.", "setCustomCalendarName": "Задать своё название", "setAction": "Задать", "removeFromMyCalendars": "Удалить из моих календарей", "removeAction": "Удалить", "removeCalendarConfirmation": "Удалить «{title}» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.", - "calendarCannotRemove": "Этот календарь нельзя удалить из этой учётной записи.", - "calendarPendingChangesPreventRemoval": "Дождитесь синхронизации ожидающих изменений этого календаря, прежде чем удалять его.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", + "calendarCannotRemove": "Этот календарь нельзя удалить или убрать из аккаунта.", + "calendarPendingChangesPreventRemoval": "Дождитесь синхронизации ожидающих изменений этого календаря перед удалением или отключением.", + "calendarSubscriptions": "Подписки на календари", + "calendarSubscriptionsDescription": "Добавляйте календари только для чтения, обновляемые по защищённому URL WebCal.", + "addCalendarSubscription": "Добавить подписку на календарь", + "subscriptionName": "Локальное имя", + "subscriptionUrl": "URL подписки", + "subscriptionUrlHelp": "Введите HTTPS- или webcal-URL. BusyMax хранит полный URL в защищённом хранилище.", + "subscriptionUrlInvalid": "Введите действительный HTTPS- или webcal-URL без данных пользователя или фрагмента.", + "subscriptionColor": "Локальный цвет", + "subscriptionColorHelp": "Используйте шестизначный цвет, например #3584E4.", + "subscriptionColorInvalid": "Введите шестизначный шестнадцатеричный цвет.", + "subscriptionRefreshMode": "Частота обновления", + "subscriptionAutomatic": "Автоматически", + "subscriptionHourly": "Ежечасно", + "subscriptionSixHours": "Каждые шесть часов", + "subscriptionDaily": "Ежедневно", + "subscriptionSafeOrigin": "Источник: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Введите действительный URL для просмотра безопасного источника.", + "subscriptionReadOnly": "Подписка только для чтения", + "subscriptionNeverRefreshed": "Ещё не обновлялось", + "subscriptionLastRefresh": "Последнее успешное обновление: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Следующее обновление: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Актуально", + "subscriptionStatusIssue": "Проблема обновления: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Обновить сейчас", + "unsubscribe": "Отменить подписку", + "unsubscribeCalendarTitle": "Отменить подписку на «{name}»?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Локальная подписка и кэшированные события будут удалены. Опубликованный календарь не изменится.", + "addSubscriptionAction": "Добавить подписку", + "subscriptionOperationFailed": "Ошибка подписки на календарь: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Подписки", "calendarImport": "Импорт календаря", - "calendarImportDescription": "Выберите файл, проверьте события, затем выберите доступный для записи календарь назначения.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarImportDescription": "Выберите файл, просмотрите его события, а затем выберите доступный для записи календарь, который должен их принять.", + "importIcsFile": "Импортировать файл .ics", + "importIcsPreview": "Импортировать события календаря", + "importEventsFound": "Наборы импортируемых событий: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Недопустимые события: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Намеренно пропущено: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Нет доступного календаря назначения с правом записи.", + "importDestinationCalendar": "Календарь назначения", + "importIcsConfirm": "Импортировать события", + "importIcsComplete": "Импорт завершён", + "importQueued": "Импортировано или поставлено в очередь: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Пропущено дубликатов: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Неподдерживаемые наборы повторений: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Не удалось импортировать файл календаря: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Нет подключения", "networkOfflineDescription": "Изменения будут синхронизированы после восстановления подключения.", "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку." diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index bd42641e..eb6c1276 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,14 +1,14 @@ { "@@locale": "vi", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "Kết nối tài khoản Google, Microsoft, Apple iCloud Calendar hoặc Nextcloud.", "googlePermissionsConsentNotice": "Trên màn hình cấp quyền của Google, hãy chọn cả quyền truy cập Lịch và Công việc.", "googlePermissionsRequiredRetry": "Cần có quyền truy cập Google Calendar và Google Tasks. Vui lòng thử lại và chọn cả hai hộp kiểm.", "finishSetup": "Hoàn tất thiết lập", "continueSetup": "Tiếp tục", "onboardingSetupTitle": "Thiết lập BusyMax", "onboardingAccountsStepTitle": "Kết nối tài khoản", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "Thêm mọi tài khoản bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc được hỗ trợ từ từng tài khoản.", "onboardingPreferencesStepTitle": "Chọn cài đặt hệ thống", "onboardingPreferencesStepDescription": "Thiết lập cách ứng dụng hoạt động trên máy tính, lời nhắc, mức độ chi tiết của thông báo và giao diện trước khi mở lịch biểu.", "signInWithGoogle": "Đăng nhập bằng Google", @@ -36,16 +36,40 @@ "newCalendar": "Lịch mới", "calendarColor": "Màu lịch", "calendarColorOption": "Màu {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "Nhà cung cấp này không hỗ trợ quản lý lịch trong BusyMax.", "primaryCalendarCannotDelete": "Không thể xóa lịch chính.", "calendarCreateFailed": "Không thể tạo lịch: {error}", "calendarCreatedRefreshPending": "Lịch đã được tạo nhưng BusyMax không thể làm mới tài khoản. Lịch sẽ xuất hiện sau lần đồng bộ tiếp theo.", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "Không thể cập nhật lịch: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Không thể xóa lịch: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "Sự kiện mới", "refreshCalendar": "Làm mới lịch", "openInProvider": "Mở trong dịch vụ", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "Thử tìm kiếm khác hoặc xóa các bộ lọc hiện tại.", "refresh": "Làm mới", "trayOpenBusyMax": "Mở BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "Hiển thị BusyMax", + "trayNewEvent": "Sự kiện mới…", + "trayNewTask": "Công việc mới…", + "trayToday": "Hôm nay", + "trayAllDay": "Cả ngày", + "trayNow": "Bây giờ", + "trayCalendarEvent": "Sự kiện lịch", + "trayUntitledEvent": "Sự kiện chưa có tiêu đề", + "trayNothingElseToday": "Hôm nay không còn gì khác", + "trayTasksDueToday": "{count, plural, =1{1 công việc đến hạn hôm nay} other{{count} công việc đến hạn hôm nay}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Mở lịch hôm nay", + "traySyncNow": "Đồng bộ ngay", + "traySyncing": "Đang đồng bộ…", + "trayNotConnected": "Chưa kết nối", + "trayNotYetSynced": "Chưa đồng bộ", + "trayLastSyncedJustNow": "Vừa đồng bộ", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Đồng bộ 1 phút trước} other{Đồng bộ {count} phút trước}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Đồng bộ 1 giờ trước} other{Đồng bộ {count} giờ trước}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Đồng bộ 1 ngày trước} other{Đồng bộ {count} ngày trước}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Cài đặt", + "trayQuitBusyMax": "Thoát BusyMax", "agendaLoadMoreOverdue": "Tải thêm công việc quá hạn", "agendaLoadMoreNoDate": "Tải thêm công việc không có ngày", "viewDay": "Ngày", @@ -116,7 +164,7 @@ "doesNotRepeat": "Không lặp lại", "defaultReminder": "Lời nhắc mặc định", "guests": "Khách mời", - "noGuests": "Không có khách mời", + "noGuests": "Không có khách", "description": "Mô tả", "availabilityShowAs": "Tình trạng rảnh/bận / Hiển thị là", "busy": "Bận", @@ -218,7 +266,7 @@ "removingAccount": "Đang xóa tài khoản…", "removeAccountDescription": "Dừng đồng bộ và xóa dữ liệu của tài khoản này khỏi thiết bị.", "removeAccountTitle": "Xóa {account} khỏi BusyMax?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "Thao tác này xóa khỏi thiết bị các công việc, lịch, sự kiện, lời nhắc và thay đổi ngoại tuyến đang chờ được lưu trong bộ nhớ đệm. Các thay đổi chưa đồng bộ sẽ mất. Bản sao lịch, sự kiện, danh sách công việc và công việc ở nhà cung cấp không bị xóa.", "revokeGoogleAccess": "Đồng thời thu hồi quyền truy cập của BusyMax vào tài khoản Google này", "revokeGoogleAccessDescription": "Bạn sẽ cần cấp lại quyền truy cập trước khi kết nối lại.", "removeAccountAction": "Xóa tài khoản", @@ -271,7 +319,7 @@ "primarySection": "Chính", "statusSection": "Trạng thái", "openStatus": "Chưa hoàn thành", - "doneStatus": "Đã hoàn thành", + "doneStatus": "Đã xong", "notes": "Ghi chú", "dueDate": "Ngày đến hạn", "clearDueDate": "Xóa ngày đến hạn", @@ -320,7 +368,7 @@ "id": "ID", "etag": "ETag", "updated": "Đã cập nhật", - "parent": "Công việc cha", + "parent": "Công việc chính", "position": "Vị trí", "webLink": "Liên kết web", "assignment": "Phân công", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "Công việc sắp đến hạn.", "notificationOpenAction": "Mở", "notificationSnoozeAction": "Báo lại sau 10 phút", - "notificationDismissAction": "Bỏ qua", + "notificationDismissAction": "Đóng", "notificationDetailsHidden": "Chi tiết bị ẩn theo cài đặt quyền riêng tư.", "previousMonth": "Tháng trước", "nextMonth": "Tháng sau", @@ -415,50 +463,50 @@ "selectTimeZone": "Chọn múi giờ", "searchLocations": "Tìm kiếm địa điểm", "noLocationsFound": "Không tìm thấy địa điểm", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "Trường này là bắt buộc.", + "providerConnectionDescription": "Kết nối lịch và công việc từ một trong các nhà cung cấp sau.", + "appleICloudProvider": "Lịch Apple iCloud", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Công việc Nextcloud", + "addAppleICloudAccount": "Thêm tài khoản Lịch Apple iCloud", + "addNextcloudAccount": "Thêm tài khoản Nextcloud", + "waitingForAppleICloud": "Đang kết nối với Apple iCloud…", + "waitingForNextcloud": "Đang chờ ủy quyền Nextcloud…", + "connectAppleICloudTitle": "Kết nối Lịch Apple iCloud", + "appleAccountEmail": "Email tài khoản Apple", + "appleAppSpecificPassword": "Mật khẩu dành riêng cho ứng dụng", + "appleAppSpecificPasswordHelp": "Tạo mật khẩu dành riêng cho ứng dụng sau khi bật xác thực hai yếu tố cho tài khoản Apple.", + "appleAppSpecificPasswordResetWarning": "Đặt lại mật khẩu tài khoản Apple sẽ thu hồi các mật khẩu dành riêng cho ứng dụng.", + "connectNextcloudTitle": "Kết nối Nextcloud", + "nextcloudServerUrl": "Máy chủ Nextcloud hoặc địa chỉ CalDAV", + "nextcloudServerUrlHelp": "Nhập URL máy chủ Nextcloud hoặc dán địa chỉ CalDAV chính được sao chép từ Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "BusyMax sẽ mở trình duyệt. Phê duyệt quyền truy cập ở đó rồi quay lại BusyMax.", + "connectAccountAction": "Kết nối", + "cancelAccountConnection": "Hủy kết nối", + "nextcloudAccountRemovedRevokeFailed": "Tài khoản đã bị xóa cục bộ nhưng không thể thu hồi mật khẩu ứng dụng Nextcloud.", + "davCachedOfflineNotice": "Dữ liệu lịch và công việc được lưu trong bộ nhớ đệm cục bộ để sử dụng ngoại tuyến.", + "davReauthenticationRequired": "Kết nối lại tài khoản này để tiếp tục đồng bộ.", + "davTemporarilyUnavailable": "Tài khoản này tạm thời không khả dụng.", + "davPermissionChanged": "Quyền máy chủ đã thay đổi. Các chỉnh sửa đang chờ bị tạm dừng.", + "davUnsupportedServer": "Máy chủ hoặc hồ sơ nhà cung cấp này không được hỗ trợ.", + "collectionSettings": "Lịch và danh sách công việc", + "calendarContent": "Sự kiện lịch", + "taskContent": "Công việc", + "readOnlySharedCollection": "Chỉ đọc", + "pendingLocally": "Đang chờ cục bộ", + "conflictBlocked": "Bị chặn do xung đột", + "authenticationBlocked": "Bị chặn cho đến khi kết nối lại", + "operationFailed": "Thao tác không thành công", + "keepServerVersion": "Giữ phiên bản máy chủ", + "reapplyLocalChange": "Xem lại và áp dụng lại thay đổi cục bộ", + "duplicateLocalItem": "Nhân bản thành mục mới", "deleteCalendarConfirmation": "Xóa “{title}”?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "Trạng thái kết nối", + "davConnected": "Đã kết nối", + "davConnecting": "Đang kết nối…", + "davSignedOut": "Đã đăng xuất", + "davLastSuccessfulSync": "Lần đồng bộ thành công gần nhất: {time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "Chưa đồng bộ", + "refreshCollections": "Làm mới lịch và danh sách công việc", + "nextcloudServerHost": "Máy chủ: {host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "Lịch sự kiện", + "collectionSupportsTasks": "Danh sách công việc", + "collectionSupportsEventsAndTasks": "Sự kiện và công việc", + "writableCollection": "Có thể ghi", + "sharedCollection": "Đã chia sẻ", + "collectionLastSynced": "Đồng bộ lần cuối: {time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "Sự cố đồng bộ: {code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "Xung đột đồng bộ", + "remoteChangedAt": "Máy chủ đã thay đổi: {time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "Chỉnh sửa cục bộ: {summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "Không thể giải quyết xung đột.", + "recurringEventScope": "Phạm vi sự kiện lặp lại", + "entireSeries": "Toàn bộ chuỗi", "singleOccurrence": "Sự kiện này", "thisAndFollowingEvents": "Sự kiện này và các sự kiện tiếp theo", "thisAndFutureUnavailable": "Nhà cung cấp này không hỗ trợ.", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "Sao chép sự kiện và xóa bản gốc?", "copyEventMoveWarning": "BusyMax không thể di chuyển trực tiếp sự kiện này từ {source} sang {destination}. Ứng dụng sẽ tạo bản sao trước và chỉ xóa bản gốc sau khi sao chép thành công. ID sự kiện sẽ thay đổi; trạng thái phản hồi của người tham dự có thể bị đặt lại và lời mời hoặc thông báo hủy có thể được gửi; liên kết cuộc họp, tệp đính kèm, lời nhắc, trường riêng của nhà cung cấp và ngoại lệ lặp lại có thể không được chuyển sang.", "copyAndDelete": "Sao chép và xóa", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "Chọn áp dụng thay đổi này cho toàn bộ chuỗi, chỉ sự kiện này hay sự kiện này và các sự kiện tiếp theo.", "taskDueBeforeStart": "Hạn chót không được trước thời gian bắt đầu.", "taskStartDueTimeModeMismatch": "Đặt giờ cho cả thời gian bắt đầu và hạn chót, hoặc đặt công việc là cả ngày.", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "Bỏ chia sẽ", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", - "taskStatusNeedsAction": "Needs action", - "taskStatusInProcess": "In process", - "taskStatusCompleted": "Hoàn thành", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "Priority", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", - "noReminders": "No reminders", - "editReminder": "Edit reminder", - "beforeTaskStarts": "Before the task starts", - "beforeTaskDue": "Before the task is due", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "discardRelatedReminders": "Discard reminders", - "keepRelatedReminders": "Keep reminders", - "repeatEvery": "Lặp lại mỗi", - "repeatOn": "Repeat on", - "repeatEnd": "Kết thúc lập lại", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "on {days}", - "repeatOnMonthDaysSummary": "on day {days}", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", - "repeatInMonthsSummary": "in {months}", + "taskListCreateFailed": "Không thể tạo danh sách công việc: {error}", + "taskListRenameFailed": "Không thể đổi tên danh sách công việc: {error}", + "taskListDeleteFailed": "Không thể xóa danh sách công việc: {error}", + "unshare": "Ngừng chia sẻ", + "readOnlyTaskListCannotRename": "Danh sách công việc này ở chế độ chỉ đọc và không thể đổi tên.", + "taskListCannotDelete": "Bạn không thể xóa danh sách công việc này với quyền hiện tại.", + "deleteTaskListConfirmation": "Xóa “{title}” và tất cả công việc trong đó?", + "unshareTaskListConfirmation": "Ngừng chia sẻ “{title}” khỏi tài khoản này?", + "taskStatus": "Trạng thái", + "taskStatusNone": "Không có trạng thái", + "taskStatusNeedsAction": "Cần xử lý", + "taskStatusInProcess": "Đang thực hiện", + "taskStatusCompleted": "Đã hoàn thành", + "taskStatusCancelled": "Đã hủy", + "completionPercent": "Đã hoàn thành {percent}%", + "completionDate": "Ngày hoàn thành", + "priority": "Mức độ ưu tiên", + "priorityNone": "Không có mức độ ưu tiên", + "priorityHighValue": "Ưu tiên {priority} · Cao", + "priorityMediumValue": "Ưu tiên {priority} · Trung bình", + "priorityLowValue": "Ưu tiên {priority} · Thấp", + "taskUrl": "URL công việc", + "invalidTaskUrl": "Nhập URL tuyệt đối, bao gồm lược đồ.", + "classification": "Phân loại", + "classificationPublic": "Khi chia sẻ, hiển thị toàn bộ công việc", + "classificationConfidential": "Khi chia sẻ, chỉ hiển thị trạng thái bận", + "classificationPrivate": "Khi chia sẻ, ẩn công việc này", + "pinTask": "Ghim công việc", + "reminders": "Lời nhắc", + "noReminders": "Không có lời nhắc", + "editReminder": "Chỉnh sửa lời nhắc", + "beforeTaskStarts": "Trước khi công việc bắt đầu", + "beforeTaskDue": "Trước hạn chót của công việc", + "afterTaskStarts": "Sau khi công việc bắt đầu", + "afterTaskDue": "Sau hạn chót của công việc", + "relativeToTaskStart": "Theo ngày bắt đầu công việc", + "relativeToTaskDue": "Theo ngày đến hạn công việc", + "reminderTimeOfDay": "Thời gian trong ngày", + "absoluteReminder": "Vào ngày và giờ cụ thể", + "reminderAmount": "Số lượng", + "reminderUnitSeconds": "Giây", + "reminderUnitMinutes": "Phút", + "reminderUnitHours": "Giờ", + "reminderUnitDays": "Ngày", + "reminderUnitWeeks": "Tuần", + "reminderAtTaskStart": "Khi công việc bắt đầu", + "reminderAtTaskDue": "Khi công việc đến hạn", + "unsupportedReminder": "Loại lời nhắc này được giữ lại nhưng không thể chỉnh sửa thời gian.", + "relatedRemindersTitle": "Giữ lời nhắc liên quan?", + "relatedRemindersDescription": "Ngày này có {count} lời nhắc liên quan. Giữ chúng ở ngày và giờ hiện tại?", + "discardRelatedReminders": "Xóa lời nhắc", + "keepRelatedReminders": "Giữ lời nhắc", + "repeatEvery": "Khoảng lặp lại", + "repeatOn": "Lặp lại vào", + "repeatEnd": "Kết thúc lặp lại", + "repeatNever": "Không bao giờ", + "repeatUntil": "Vào ngày", + "repeatAfter": "Sau một số lần xuất hiện", + "repeatCount": "Số lần lặp", + "repeatDayOfMonth": "Ngày trong tháng", + "repeatMonths": "Tháng", + "repeatOrdinal": "Vị trí ngày trong tuần", + "repeatSpecificDays": "Ngày cụ thể", + "repeatFirst": "Thứ nhất", + "repeatSecond": "Thứ hai", + "repeatThird": "Thứ ba", + "repeatFourth": "Thứ tư", + "repeatFifth": "Thứ năm", + "repeatSecondToLast": "Áp chót", + "repeatLast": "Cuối cùng", + "repeatAnyDay": "Ngày", + "repeatWeekday": "Ngày trong tuần", + "repeatWeekendDay": "Ngày cuối tuần", + "repeatEveryDays": "Mỗi {count} ngày", + "repeatEveryWeeks": "Mỗi {count} tuần", + "repeatEveryMonths": "Mỗi {count} tháng", + "repeatEveryYears": "Mỗi {count} năm", + "repeatOnDaysSummary": "vào {days}", + "repeatOnMonthDaysSummary": "vào ngày {days} trong tháng", + "repeatOnOrdinalSummary": "vào {days} thứ {ordinal}", + "repeatInMonthsSummary": "vào {months}", "repeatTimesSummary": "{count} lần", - "repeatUntilSummary": "cho đến {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatUntilSummary": "đến {date}", + "unsupportedRecurrencePreserved": "Quy tắc lặp lại này sử dụng các tùy chọn mà trình chỉnh sửa này không thay đổi.", "recurrenceUnsupportedByProvider": "Không thể sử dụng kiểu lặp lại này với {provider}.", - "duplicateTask": "Duplicate task", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", - "hideSubtasks": "Hide subtasks", - "hideClosedSubtasks": "Hide closed subtasks", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "duplicateTask": "Nhân bản công việc", + "taskDuplicated": "Đã nhân bản công việc.", + "taskDuplicateFailed": "Không thể nhân bản công việc: {error}", + "hideSubtasks": "Ẩn công việc phụ", + "hideClosedSubtasks": "Ẩn công việc phụ đã đóng", + "reminderUnit": "Đơn vị", + "attendeeRequired": "Bắt buộc", + "attendeeOptional": "Tùy chọn", + "meetingSection": "Cuộc họp", + "addGoogleMeet": "Thêm Google Meet", + "addTeamsMeeting": "Thêm cuộc họp Microsoft Teams", + "onlineMeetingAdded": "Đã thêm cuộc họp trực tuyến", + "requestResponses": "Yêu cầu phản hồi", + "requestResponsesDescription": "Yêu cầu khách trả lời lời mời.", + "hideGuestList": "Ẩn danh sách khách", + "hideGuestListDescription": "Khách không thể xem những người khác được mời.", + "allowNewTimeProposals": "Cho phép đề xuất thời gian mới", + "allowNewTimeProposalsDescription": "Khách có thể đề xuất thời gian họp khác.", + "notifyGuestsTitle": "Thông báo cho khách?", + "notifyGuestsSaveMessage": "Cuộc họp này có khách. Gửi lời mời hoặc cập nhật sự kiện khi lưu?", + "notifyGuestsDeleteMessage": "Cuộc họp này có khách. Gửi thông báo hủy khi xóa?", + "sendUpdates": "Gửi cập nhật", + "sendCancellation": "Gửi thông báo hủy", + "doNotSend": "Không gửi", + "microsoftNotifyGuestsSaveTitle": "Lưu cuộc họp?", + "microsoftNotifyGuestsSaveMessage": "Microsoft sẽ gửi lời mời hoặc cập nhật sự kiện cho khách.", + "microsoftNotifyGuestsDeleteTitle": "Xóa cuộc họp?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft sẽ gửi thông báo hủy cho khách.", + "organizer": "Người tổ chức", + "yourResponse": "Phản hồi của bạn", + "guestResponses": "Phản hồi của khách", + "respond": "Phản hồi", + "acceptInvitation": "Chấp nhận", + "tentativeInvitation": "Tạm thời", + "declineInvitation": "Từ chối", + "joinMeeting": "Tham gia cuộc họp", + "responseAccepted": "Đã chấp nhận", + "responseTentative": "Tạm thời", + "responseDeclined": "Đã từ chối", + "responseNeedsAction": "Đang chờ phản hồi", + "responseNotResponded": "Chưa phản hồi", + "responseOrganizer": "Người tổ chức", + "invitationResponseFailed": "Không thể gửi phản hồi: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Không thể mở liên kết cuộc họp.", "setCustomCalendarName": "Đặt tên tùy chỉnh", "setAction": "Đặt", "removeFromMyCalendars": "Xóa khỏi lịch của tôi", "removeAction": "Xóa", "removeCalendarConfirmation": "Xóa “{title}” khỏi danh sách Google Calendar của bạn? Lịch dùng chung và các sự kiện trong đó sẽ không bị xóa.", - "calendarCannotRemove": "Không thể xóa lịch này khỏi tài khoản.", - "calendarPendingChangesPreventRemoval": "Hãy đợi các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", + "calendarCannotRemove": "Không thể xóa hoặc gỡ lịch này khỏi tài khoản.", + "calendarPendingChangesPreventRemoval": "Hãy chờ các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.", + "calendarSubscriptions": "Gói đăng ký lịch", + "calendarSubscriptionsDescription": "Thêm lịch chỉ đọc được làm mới từ URL WebCal bảo mật.", + "addCalendarSubscription": "Thêm gói đăng ký lịch", + "subscriptionName": "Tên cục bộ", + "subscriptionUrl": "URL gói đăng ký", + "subscriptionUrlHelp": "Nhập URL HTTPS hoặc webcal. BusyMax giữ URL đầy đủ trong bộ nhớ an toàn.", + "subscriptionUrlInvalid": "Nhập URL HTTPS hoặc webcal hợp lệ không có thông tin người dùng hoặc phân đoạn.", + "subscriptionColor": "Màu cục bộ", + "subscriptionColorHelp": "Sử dụng màu sáu chữ số như #3584E4.", + "subscriptionColorInvalid": "Nhập màu thập lục phân sáu chữ số.", + "subscriptionRefreshMode": "Tần suất làm mới", + "subscriptionAutomatic": "Tự động", + "subscriptionHourly": "Hàng giờ", + "subscriptionSixHours": "Mỗi sáu giờ", + "subscriptionDaily": "Hàng ngày", + "subscriptionSafeOrigin": "Nguồn: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Nhập URL hợp lệ để xem trước nguồn an toàn.", + "subscriptionReadOnly": "Gói đăng ký chỉ đọc", + "subscriptionNeverRefreshed": "Chưa làm mới", + "subscriptionLastRefresh": "Lần làm mới thành công gần nhất: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Lần làm mới tiếp theo: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Đã cập nhật", + "subscriptionStatusIssue": "Sự cố làm mới: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Làm mới ngay", + "unsubscribe": "Hủy đăng ký", + "unsubscribeCalendarTitle": "Hủy đăng ký “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Thao tác này xóa gói đăng ký cục bộ và các sự kiện đã lưu trong bộ nhớ đệm. Lịch đã xuất bản không thay đổi.", + "addSubscriptionAction": "Thêm gói đăng ký", + "subscriptionOperationFailed": "Gói đăng ký lịch không thành công: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Gói đăng ký", "calendarImport": "Nhập lịch", - "calendarImportDescription": "Chọn một tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarImportDescription": "Chọn tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.", + "importIcsFile": "Nhập tệp .ics", + "importIcsPreview": "Nhập sự kiện lịch", + "importEventsFound": "Bộ sự kiện có thể nhập: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Sự kiện không hợp lệ: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Cố ý bỏ qua: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Không có lịch đích có thể ghi.", + "importDestinationCalendar": "Lịch đích", + "importIcsConfirm": "Nhập sự kiện", + "importIcsComplete": "Đã nhập xong", + "importQueued": "Đã nhập hoặc xếp hàng: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Đã bỏ qua bản sao: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Bộ lặp lại không được hỗ trợ: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Không thể nhập tệp lịch: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Ngoại tuyến", "networkOfflineDescription": "Các thay đổi sẽ được đồng bộ khi kết nối được khôi phục.", "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 14ae02d6..3db01963 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,14 +1,14 @@ { "@@locale": "zh", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。", "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", "finishSetup": "完成设置", "continueSetup": "继续", "onboardingSetupTitle": "设置 BusyMax", "onboardingAccountsStepTitle": "连接帐户", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。", "onboardingPreferencesStepTitle": "选择系统设置", "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", "signInWithGoogle": "使用 Google 登录", @@ -36,16 +36,40 @@ "newCalendar": "新建日历", "calendarColor": "日历颜色", "calendarColorOption": "颜色 {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", "primaryCalendarCannotDelete": "无法删除主日历。", "calendarCreateFailed": "无法创建日历:{error}", "calendarCreatedRefreshPending": "日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "无法更新日历:{error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "无法删除日历:{error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新建日程", "refreshCalendar": "刷新日历", "openInProvider": "在服务中打开", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "显示 BusyMax", + "trayNewEvent": "新活动…", + "trayNewTask": "新任务…", + "trayToday": "今天", + "trayAllDay": "全天", + "trayNow": "现在", + "trayCalendarEvent": "日历活动", + "trayUntitledEvent": "无标题活动", + "trayNothingElseToday": "今天没有其他内容", + "trayTasksDueToday": "{count, plural, =1{今天有 1 个任务到期} other{今天有 {count} 个任务到期}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "打开今天的日程", + "traySyncNow": "立即同步", + "traySyncing": "正在同步…", + "trayNotConnected": "未连接", + "trayNotYetSynced": "尚未同步", + "trayLastSyncedJustNow": "刚刚同步", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分钟前同步} other{{count} 分钟前同步}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 小时前同步} other{{count} 小时前同步}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 天前同步} other{{count} 天前同步}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "设置", + "trayQuitBusyMax": "退出 BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -116,7 +164,7 @@ "doesNotRepeat": "不重复", "defaultReminder": "默认提醒", "guests": "参与者", - "noGuests": "没有参与者", + "noGuests": "无参与者", "description": "说明", "availabilityShowAs": "空闲状态 / 显示为", "busy": "忙碌", @@ -218,13 +266,13 @@ "removingAccount": "正在移除帐户…", "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", "removeAccountTitle": "从 BusyMax 中移除 {account}?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。", "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", "removeAccountAction": "移除帐户", "removeAccountFailed": "无法完成帐户移除。请重试。", "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", - "newTaskList": "新建任务列表", + "newTaskList": "新任务列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", "listActions": "列表操作", @@ -234,7 +282,7 @@ "deleteList": "删除列表", "builtInMicrosoftList": "内置", "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", - "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteListConfirmation": "要从 Google Tasks 中删除“{title}”吗?", "deleteEvent": "删除日程", "title": "标题", "create": "新建", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "任务即将到期。", "notificationOpenAction": "打开", "notificationSnoozeAction": "10 分钟后提醒", - "notificationDismissAction": "忽略", + "notificationDismissAction": "关闭", "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", "previousMonth": "上个月", "nextMonth": "下个月", @@ -415,50 +463,50 @@ "selectTimeZone": "选择时区", "searchLocations": "搜索地点", "noLocationsFound": "未找到地点", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "此字段为必填项。", + "providerConnectionDescription": "连接以下任一提供商中的日历和任务。", + "appleICloudProvider": "Apple iCloud 日历", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 任务", + "addAppleICloudAccount": "添加 Apple iCloud 日历账户", + "addNextcloudAccount": "添加 Nextcloud 账户", + "waitingForAppleICloud": "正在连接 Apple iCloud…", + "waitingForNextcloud": "正在等待 Nextcloud 授权…", + "connectAppleICloudTitle": "连接 Apple iCloud 日历", + "appleAccountEmail": "Apple 账户电子邮件", + "appleAppSpecificPassword": "App 专用密码", + "appleAppSpecificPasswordHelp": "为 Apple 账户启用双重认证后,创建 App 专用密码。", + "appleAppSpecificPasswordResetWarning": "重置 Apple 账户密码会撤销 App 专用密码。", + "connectNextcloudTitle": "连接 Nextcloud", + "nextcloudServerUrl": "Nextcloud 服务器或 CalDAV 地址", + "nextcloudServerUrlHelp": "输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。", + "nextcloudBrowserAuthorizationHelp": "BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。", + "connectAccountAction": "连接", + "cancelAccountConnection": "取消连接", + "nextcloudAccountRemovedRevokeFailed": "账户已在本地移除,但无法撤销 Nextcloud App 密码。", + "davCachedOfflineNotice": "日历和任务数据会缓存在本地,以供离线使用。", + "davReauthenticationRequired": "重新连接此账户以恢复同步。", + "davTemporarilyUnavailable": "此账户暂时不可用。", + "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", + "davUnsupportedServer": "不支持此服务器或提供商配置。", + "collectionSettings": "日历和任务列表", + "calendarContent": "日历活动", + "taskContent": "任务", + "readOnlySharedCollection": "只读", + "pendingLocally": "本地待处理", + "conflictBlocked": "因冲突而阻止", + "authenticationBlocked": "重新连接前阻止", + "operationFailed": "操作失败", + "keepServerVersion": "保留服务器版本", + "reapplyLocalChange": "查看并重新应用本地更改", + "duplicateLocalItem": "复制为新项目", "deleteCalendarConfirmation": "删除“{title}”?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "连接状态", + "davConnected": "已连接", + "davConnecting": "正在连接…", + "davSignedOut": "已退出登录", + "davLastSuccessfulSync": "上次成功同步:{time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "尚未同步", + "refreshCollections": "刷新日历和任务列表", + "nextcloudServerHost": "服务器:{host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "活动日历", + "collectionSupportsTasks": "任务列表", + "collectionSupportsEventsAndTasks": "活动和任务", + "writableCollection": "可写", + "sharedCollection": "已共享", + "collectionLastSynced": "上次同步:{time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同步问题:{code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同步冲突", + "remoteChangedAt": "服务器更改:{time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "本地编辑:{summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "无法解决冲突。", + "recurringEventScope": "重复活动范围", + "entireSeries": "整个系列", "singleOccurrence": "此事件", "thisAndFollowingEvents": "此事件及后续事件", "thisAndFutureUnavailable": "此提供商不支持。", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "复制活动并删除原活动?", "copyEventMoveWarning": "BusyMax 无法将此活动直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", "copyAndDelete": "复制并删除", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "无法创建任务列表:{error}", + "taskListRenameFailed": "无法重命名任务列表:{error}", + "taskListDeleteFailed": "无法删除任务列表:{error}", "unshare": "取消共享", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "此任务列表为只读,无法重命名。", + "taskListCannotDelete": "使用当前权限无法删除此任务列表。", + "deleteTaskListConfirmation": "要删除“{title}”及其所有任务吗?", + "unshareTaskListConfirmation": "要取消此账户对“{title}”的共享吗?", + "taskStatus": "状态", + "taskStatusNone": "无状态", "taskStatusNeedsAction": "需要操作", - "taskStatusInProcess": "处理中", - "taskStatusCompleted": "完成", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "进行中", + "taskStatusCompleted": "已完成", + "taskStatusCancelled": "已取消", + "completionPercent": "已完成 {percent}%", + "completionDate": "完成日期", "priority": "优先级", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "无优先级", + "priorityHighValue": "优先级 {priority} · 高", + "priorityMediumValue": "优先级 {priority} · 中", + "priorityLowValue": "优先级 {priority} · 低", + "taskUrl": "任务 URL", + "invalidTaskUrl": "请输入包含方案的绝对 URL。", + "classification": "分类", + "classificationPublic": "共享时显示完整任务", + "classificationConfidential": "共享时仅显示忙碌状态", + "classificationPrivate": "共享时隐藏此任务", + "pinTask": "固定任务", + "reminders": "提醒", "noReminders": "无提醒", - "editReminder": "Edit reminder", + "editReminder": "编辑提醒", "beforeTaskStarts": "任务开始前", - "beforeTaskDue": "任务截止前", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "beforeTaskDue": "任务到期前", + "afterTaskStarts": "任务开始后", + "afterTaskDue": "任务到期后", + "relativeToTaskStart": "相对于任务开始日期", + "relativeToTaskDue": "相对于任务到期日期", + "reminderTimeOfDay": "时间", + "absoluteReminder": "在指定日期和时间", + "reminderAmount": "数量", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分钟", + "reminderUnitHours": "小时", + "reminderUnitDays": "天", + "reminderUnitWeeks": "周", + "reminderAtTaskStart": "任务开始时", + "reminderAtTaskDue": "任务到期时", + "unsupportedReminder": "此提醒类型会保留,但无法编辑其时间。", + "relatedRemindersTitle": "保留相关提醒?", + "relatedRemindersDescription": "此日期有 {count} 个相关提醒。要保留它们当前的日期和时间吗?", "discardRelatedReminders": "舍弃提醒", "keepRelatedReminders": "保留提醒", - "repeatEvery": "重复每", - "repeatOn": "Repeat on", + "repeatEvery": "重复间隔", + "repeatOn": "重复日期", "repeatEnd": "结束重复", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "repeatNever": "从不", + "repeatUntil": "指定日期", + "repeatAfter": "指定次数后", + "repeatCount": "重复次数", + "repeatDayOfMonth": "每月日期", + "repeatMonths": "月份", + "repeatOrdinal": "星期位置", + "repeatSpecificDays": "特定日期", + "repeatFirst": "第一", + "repeatSecond": "第二", + "repeatThird": "第三", + "repeatFourth": "第四", + "repeatFifth": "第五", + "repeatSecondToLast": "倒数第二", + "repeatLast": "最后", + "repeatAnyDay": "日期", + "repeatWeekday": "工作日", + "repeatWeekendDay": "周末", + "repeatEveryDays": "每 {count} 天", + "repeatEveryWeeks": "每 {count} 周", + "repeatEveryMonths": "每 {count} 个月", + "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", - "repeatOnMonthDaysSummary": "在第 {days} 天", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatOnMonthDaysSummary": "每月 {days} 日", + "repeatOnOrdinalSummary": "{days} 的第 {ordinal} 个", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "{count}次", - "repeatUntilSummary": "至 {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatTimesSummary": "重复 {count} 次", + "repeatUntilSummary": "直到 {date}", + "unsupportedRecurrencePreserved": "此重复规则使用了编辑器不会更改的选项。", "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", "duplicateTask": "复制任务", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "任务已复制。", + "taskDuplicateFailed": "无法复制任务:{error}", "hideSubtasks": "隐藏子任务", - "hideClosedSubtasks": "隐藏关闭的子任务", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "隐藏已关闭的子任务", + "reminderUnit": "单位", + "attendeeRequired": "必需", + "attendeeOptional": "可选", + "meetingSection": "会议", + "addGoogleMeet": "添加 Google Meet", + "addTeamsMeeting": "添加 Microsoft Teams 会议", + "onlineMeetingAdded": "已添加在线会议", + "requestResponses": "请求回复", + "requestResponsesDescription": "要求参与者回复邀请。", + "hideGuestList": "隐藏参与者列表", + "hideGuestListDescription": "参与者无法查看其他受邀者。", + "allowNewTimeProposals": "允许提出新时间", + "allowNewTimeProposalsDescription": "参与者可以建议其他会议时间。", + "notifyGuestsTitle": "通知参与者?", + "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或活动更新吗?", + "notifyGuestsDeleteMessage": "此会议有参与者。删除时发送取消通知吗?", + "sendUpdates": "发送更新", + "sendCancellation": "发送取消通知", + "doNotSend": "不发送", + "microsoftNotifyGuestsSaveTitle": "保存会议?", + "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或活动更新。", + "microsoftNotifyGuestsDeleteTitle": "删除会议?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft 将向参与者发送取消通知。", + "organizer": "组织者", + "yourResponse": "你的回复", + "guestResponses": "参与者回复", + "respond": "回复", + "acceptInvitation": "接受", + "tentativeInvitation": "暂定", + "declineInvitation": "拒绝", + "joinMeeting": "加入会议", + "responseAccepted": "已接受", + "responseTentative": "暂定", + "responseDeclined": "已拒绝", + "responseNeedsAction": "等待回复", + "responseNotResponded": "未回复", + "responseOrganizer": "组织者", + "invitationResponseFailed": "无法发送回复:{error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "无法打开会议链接。", "setCustomCalendarName": "设置自定义名称", "setAction": "设置", "removeFromMyCalendars": "从“我的日历”中移除", "removeAction": "移除", "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", - "calendarCannotRemove": "无法从此账号中删除或移除此日历。", - "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改同步完成,然后再删除或移除它。", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "导入日历", - "calendarImportDescription": "选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarCannotRemove": "无法从此账户删除或移除此日历。", + "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", + "calendarSubscriptions": "日历订阅", + "calendarSubscriptionsDescription": "添加从安全 WebCal URL 刷新的只读日历。", + "addCalendarSubscription": "添加日历订阅", + "subscriptionName": "本地名称", + "subscriptionUrl": "订阅 URL", + "subscriptionUrlHelp": "输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。", + "subscriptionUrlInvalid": "请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。", + "subscriptionColor": "本地颜色", + "subscriptionColorHelp": "使用六位颜色,例如 #3584E4。", + "subscriptionColorInvalid": "请输入六位十六进制颜色。", + "subscriptionRefreshMode": "刷新频率", + "subscriptionAutomatic": "自动", + "subscriptionHourly": "每小时", + "subscriptionSixHours": "每六小时", + "subscriptionDaily": "每天", + "subscriptionSafeOrigin": "来源:{origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "请输入有效 URL 以预览其安全来源。", + "subscriptionReadOnly": "只读订阅", + "subscriptionNeverRefreshed": "尚未刷新", + "subscriptionLastRefresh": "上次成功刷新:{time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "下次刷新:{time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "已是最新", + "subscriptionStatusIssue": "刷新问题:{code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "立即刷新", + "unsubscribe": "取消订阅", + "unsubscribeCalendarTitle": "要取消订阅“{name}”吗?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的活动。已发布的日历不会更改。", + "addSubscriptionAction": "添加订阅", + "subscriptionOperationFailed": "日历订阅失败:{error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "订阅", + "calendarImport": "日历导入", + "calendarImportDescription": "选择文件,查看其中的活动,然后选择接收这些活动的可写日历。", + "importIcsFile": "导入 .ics 文件", + "importIcsPreview": "导入日历活动", + "importEventsFound": "可导入活动集:{count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "无效活动:{count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "有意省略:{fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "没有可用的可写目标日历。", + "importDestinationCalendar": "目标日历", + "importIcsConfirm": "导入活动", + "importIcsComplete": "导入完成", + "importQueued": "已导入或已排队:{count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "已跳过重复项:{count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "不支持的重复集:{count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "无法导入日历文件:{error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index a8370de3..89dd98fd 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,14 +1,14 @@ { "@@locale": "zh_Hans", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。", "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", "googlePermissionsRequiredRetry": "必须授予 Google 日历和 Google Tasks 权限。请重试并选中两个复选框。", "finishSetup": "完成设置", "continueSetup": "继续", "onboardingSetupTitle": "设置 BusyMax", "onboardingAccountsStepTitle": "连接帐户", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。", "onboardingPreferencesStepTitle": "选择系统设置", "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", "signInWithGoogle": "使用 Google 登录", @@ -36,16 +36,40 @@ "newCalendar": "新建日历", "calendarColor": "日历颜色", "calendarColorOption": "颜色 {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "此提供商不支持 BusyMax 中的日历管理。", "primaryCalendarCannotDelete": "无法删除主日历。", "calendarCreateFailed": "无法创建日历:{error}", "calendarCreatedRefreshPending": "日历已创建,但 BusyMax 无法刷新账户。它将在下次同步后显示。", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "无法更新日历:{error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "无法删除日历:{error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新建日程", "refreshCalendar": "刷新日历", "openInProvider": "在服务中打开", @@ -65,31 +89,55 @@ "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayShowBusyMax": "显示 BusyMax", + "trayNewEvent": "新活动…", + "trayNewTask": "新任务…", + "trayToday": "今天", + "trayAllDay": "全天", + "trayNow": "现在", + "trayCalendarEvent": "日历活动", + "trayUntitledEvent": "无标题活动", + "trayNothingElseToday": "今天没有其他内容", + "trayTasksDueToday": "{count, plural, =1{今天有 1 个任务到期} other{今天有 {count} 个任务到期}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "打开今天的日程", + "traySyncNow": "立即同步", + "traySyncing": "正在同步…", + "trayNotConnected": "未连接", + "trayNotYetSynced": "尚未同步", + "trayLastSyncedJustNow": "刚刚同步", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分钟前同步} other{{count} 分钟前同步}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 小时前同步} other{{count} 小时前同步}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 天前同步} other{{count} 天前同步}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "设置", + "trayQuitBusyMax": "退出 BusyMax", "agendaLoadMoreOverdue": "加载更多逾期任务", "agendaLoadMoreNoDate": "加载更多无日期任务", "viewDay": "日", @@ -116,7 +164,7 @@ "doesNotRepeat": "不重复", "defaultReminder": "默认提醒", "guests": "参与者", - "noGuests": "没有参与者", + "noGuests": "无参与者", "description": "说明", "availabilityShowAs": "空闲状态 / 显示为", "busy": "忙碌", @@ -218,13 +266,13 @@ "removingAccount": "正在移除帐户…", "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", "removeAccountTitle": "从 BusyMax 中移除 {account}?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。", "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", "removeAccountAction": "移除帐户", "removeAccountFailed": "无法完成帐户移除。请重试。", "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", - "newTaskList": "新建任务列表", + "newTaskList": "新任务列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", "listActions": "列表操作", @@ -234,7 +282,7 @@ "deleteList": "删除列表", "builtInMicrosoftList": "内置", "builtInMicrosoftListCannotRenameDelete": "无法重命名或删除 Microsoft To Do 内置列表。", - "deleteListConfirmation": "从 Google Tasks 中删除“{title}”?", + "deleteListConfirmation": "要从 Google Tasks 中删除“{title}”吗?", "deleteEvent": "删除日程", "title": "标题", "create": "新建", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "任务即将到期。", "notificationOpenAction": "打开", "notificationSnoozeAction": "10 分钟后提醒", - "notificationDismissAction": "忽略", + "notificationDismissAction": "关闭", "notificationDetailsHidden": "根据隐私设置,详细信息已隐藏。", "previousMonth": "上个月", "nextMonth": "下个月", @@ -415,50 +463,50 @@ "selectTimeZone": "选择时区", "searchLocations": "搜索地点", "noLocationsFound": "未找到地点", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "requiredField": "此字段为必填项。", + "providerConnectionDescription": "连接以下任一提供商中的日历和任务。", + "appleICloudProvider": "Apple iCloud 日历", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 任务", + "addAppleICloudAccount": "添加 Apple iCloud 日历账户", + "addNextcloudAccount": "添加 Nextcloud 账户", + "waitingForAppleICloud": "正在连接 Apple iCloud…", + "waitingForNextcloud": "正在等待 Nextcloud 授权…", + "connectAppleICloudTitle": "连接 Apple iCloud 日历", + "appleAccountEmail": "Apple 账户电子邮件", + "appleAppSpecificPassword": "App 专用密码", + "appleAppSpecificPasswordHelp": "为 Apple 账户启用双重认证后,创建 App 专用密码。", + "appleAppSpecificPasswordResetWarning": "重置 Apple 账户密码会撤销 App 专用密码。", + "connectNextcloudTitle": "连接 Nextcloud", + "nextcloudServerUrl": "Nextcloud 服务器或 CalDAV 地址", + "nextcloudServerUrlHelp": "输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。", + "nextcloudBrowserAuthorizationHelp": "BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。", + "connectAccountAction": "连接", + "cancelAccountConnection": "取消连接", + "nextcloudAccountRemovedRevokeFailed": "账户已在本地移除,但无法撤销 Nextcloud App 密码。", + "davCachedOfflineNotice": "日历和任务数据会缓存在本地,以供离线使用。", + "davReauthenticationRequired": "重新连接此账户以恢复同步。", + "davTemporarilyUnavailable": "此账户暂时不可用。", + "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", + "davUnsupportedServer": "不支持此服务器或提供商配置。", + "collectionSettings": "日历和任务列表", + "calendarContent": "日历活动", + "taskContent": "任务", + "readOnlySharedCollection": "只读", + "pendingLocally": "本地待处理", + "conflictBlocked": "因冲突而阻止", + "authenticationBlocked": "重新连接前阻止", + "operationFailed": "操作失败", + "keepServerVersion": "保留服务器版本", + "reapplyLocalChange": "查看并重新应用本地更改", + "duplicateLocalItem": "复制为新项目", "deleteCalendarConfirmation": "删除“{title}”?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "连接状态", + "davConnected": "已连接", + "davConnecting": "正在连接…", + "davSignedOut": "已退出登录", + "davLastSuccessfulSync": "上次成功同步:{time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "尚未同步", + "refreshCollections": "刷新日历和任务列表", + "nextcloudServerHost": "服务器:{host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "活动日历", + "collectionSupportsTasks": "任务列表", + "collectionSupportsEventsAndTasks": "活动和任务", + "writableCollection": "可写", + "sharedCollection": "已共享", + "collectionLastSynced": "上次同步:{time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同步问题:{code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同步冲突", + "remoteChangedAt": "服务器更改:{time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "本地编辑:{summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "无法解决冲突。", + "recurringEventScope": "重复活动范围", + "entireSeries": "整个系列", "singleOccurrence": "此事件", "thisAndFollowingEvents": "此事件及后续事件", "thisAndFutureUnavailable": "此提供商不支持。", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "复制活动并删除原活动?", "copyEventMoveWarning": "BusyMax 无法将此活动直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", "copyAndDelete": "复制并删除", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", + "taskListCreateFailed": "无法创建任务列表:{error}", + "taskListRenameFailed": "无法重命名任务列表:{error}", + "taskListDeleteFailed": "无法删除任务列表:{error}", "unshare": "取消共享", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "readOnlyTaskListCannotRename": "此任务列表为只读,无法重命名。", + "taskListCannotDelete": "使用当前权限无法删除此任务列表。", + "deleteTaskListConfirmation": "要删除“{title}”及其所有任务吗?", + "unshareTaskListConfirmation": "要取消此账户对“{title}”的共享吗?", + "taskStatus": "状态", + "taskStatusNone": "无状态", "taskStatusNeedsAction": "需要操作", - "taskStatusInProcess": "处理中", - "taskStatusCompleted": "完成", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", + "taskStatusInProcess": "进行中", + "taskStatusCompleted": "已完成", + "taskStatusCancelled": "已取消", + "completionPercent": "已完成 {percent}%", + "completionDate": "完成日期", "priority": "优先级", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "priorityNone": "无优先级", + "priorityHighValue": "优先级 {priority} · 高", + "priorityMediumValue": "优先级 {priority} · 中", + "priorityLowValue": "优先级 {priority} · 低", + "taskUrl": "任务 URL", + "invalidTaskUrl": "请输入包含方案的绝对 URL。", + "classification": "分类", + "classificationPublic": "共享时显示完整任务", + "classificationConfidential": "共享时仅显示忙碌状态", + "classificationPrivate": "共享时隐藏此任务", + "pinTask": "固定任务", + "reminders": "提醒", "noReminders": "无提醒", - "editReminder": "Edit reminder", + "editReminder": "编辑提醒", "beforeTaskStarts": "任务开始前", - "beforeTaskDue": "任务截止前", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "beforeTaskDue": "任务到期前", + "afterTaskStarts": "任务开始后", + "afterTaskDue": "任务到期后", + "relativeToTaskStart": "相对于任务开始日期", + "relativeToTaskDue": "相对于任务到期日期", + "reminderTimeOfDay": "时间", + "absoluteReminder": "在指定日期和时间", + "reminderAmount": "数量", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分钟", + "reminderUnitHours": "小时", + "reminderUnitDays": "天", + "reminderUnitWeeks": "周", + "reminderAtTaskStart": "任务开始时", + "reminderAtTaskDue": "任务到期时", + "unsupportedReminder": "此提醒类型会保留,但无法编辑其时间。", + "relatedRemindersTitle": "保留相关提醒?", + "relatedRemindersDescription": "此日期有 {count} 个相关提醒。要保留它们当前的日期和时间吗?", "discardRelatedReminders": "舍弃提醒", "keepRelatedReminders": "保留提醒", - "repeatEvery": "重复每", - "repeatOn": "Repeat on", + "repeatEvery": "重复间隔", + "repeatOn": "重复日期", "repeatEnd": "结束重复", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", + "repeatNever": "从不", + "repeatUntil": "指定日期", + "repeatAfter": "指定次数后", + "repeatCount": "重复次数", + "repeatDayOfMonth": "每月日期", + "repeatMonths": "月份", + "repeatOrdinal": "星期位置", + "repeatSpecificDays": "特定日期", + "repeatFirst": "第一", + "repeatSecond": "第二", + "repeatThird": "第三", + "repeatFourth": "第四", + "repeatFifth": "第五", + "repeatSecondToLast": "倒数第二", + "repeatLast": "最后", + "repeatAnyDay": "日期", + "repeatWeekday": "工作日", + "repeatWeekendDay": "周末", + "repeatEveryDays": "每 {count} 天", + "repeatEveryWeeks": "每 {count} 周", + "repeatEveryMonths": "每 {count} 个月", + "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", - "repeatOnMonthDaysSummary": "在第 {days} 天", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatOnMonthDaysSummary": "每月 {days} 日", + "repeatOnOrdinalSummary": "{days} 的第 {ordinal} 个", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "{count}次", - "repeatUntilSummary": "至 {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatTimesSummary": "重复 {count} 次", + "repeatUntilSummary": "直到 {date}", + "unsupportedRecurrencePreserved": "此重复规则使用了编辑器不会更改的选项。", "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", "duplicateTask": "复制任务", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "taskDuplicated": "任务已复制。", + "taskDuplicateFailed": "无法复制任务:{error}", "hideSubtasks": "隐藏子任务", - "hideClosedSubtasks": "隐藏关闭的子任务", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "hideClosedSubtasks": "隐藏已关闭的子任务", + "reminderUnit": "单位", + "attendeeRequired": "必需", + "attendeeOptional": "可选", + "meetingSection": "会议", + "addGoogleMeet": "添加 Google Meet", + "addTeamsMeeting": "添加 Microsoft Teams 会议", + "onlineMeetingAdded": "已添加在线会议", + "requestResponses": "请求回复", + "requestResponsesDescription": "要求参与者回复邀请。", + "hideGuestList": "隐藏参与者列表", + "hideGuestListDescription": "参与者无法查看其他受邀者。", + "allowNewTimeProposals": "允许提出新时间", + "allowNewTimeProposalsDescription": "参与者可以建议其他会议时间。", + "notifyGuestsTitle": "通知参与者?", + "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或活动更新吗?", + "notifyGuestsDeleteMessage": "此会议有参与者。删除时发送取消通知吗?", + "sendUpdates": "发送更新", + "sendCancellation": "发送取消通知", + "doNotSend": "不发送", + "microsoftNotifyGuestsSaveTitle": "保存会议?", + "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或活动更新。", + "microsoftNotifyGuestsDeleteTitle": "删除会议?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft 将向参与者发送取消通知。", + "organizer": "组织者", + "yourResponse": "你的回复", + "guestResponses": "参与者回复", + "respond": "回复", + "acceptInvitation": "接受", + "tentativeInvitation": "暂定", + "declineInvitation": "拒绝", + "joinMeeting": "加入会议", + "responseAccepted": "已接受", + "responseTentative": "暂定", + "responseDeclined": "已拒绝", + "responseNeedsAction": "等待回复", + "responseNotResponded": "未回复", + "responseOrganizer": "组织者", + "invitationResponseFailed": "无法发送回复:{error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "无法打开会议链接。", "setCustomCalendarName": "设置自定义名称", "setAction": "设置", "removeFromMyCalendars": "从“我的日历”中移除", "removeAction": "移除", "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", - "calendarCannotRemove": "无法从此账号中删除或移除此日历。", - "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改同步完成,然后再删除或移除它。", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", - "calendarImport": "导入日历", - "calendarImportDescription": "选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarCannotRemove": "无法从此账户删除或移除此日历。", + "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", + "calendarSubscriptions": "日历订阅", + "calendarSubscriptionsDescription": "添加从安全 WebCal URL 刷新的只读日历。", + "addCalendarSubscription": "添加日历订阅", + "subscriptionName": "本地名称", + "subscriptionUrl": "订阅 URL", + "subscriptionUrlHelp": "输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。", + "subscriptionUrlInvalid": "请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。", + "subscriptionColor": "本地颜色", + "subscriptionColorHelp": "使用六位颜色,例如 #3584E4。", + "subscriptionColorInvalid": "请输入六位十六进制颜色。", + "subscriptionRefreshMode": "刷新频率", + "subscriptionAutomatic": "自动", + "subscriptionHourly": "每小时", + "subscriptionSixHours": "每六小时", + "subscriptionDaily": "每天", + "subscriptionSafeOrigin": "来源:{origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "请输入有效 URL 以预览其安全来源。", + "subscriptionReadOnly": "只读订阅", + "subscriptionNeverRefreshed": "尚未刷新", + "subscriptionLastRefresh": "上次成功刷新:{time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "下次刷新:{time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "已是最新", + "subscriptionStatusIssue": "刷新问题:{code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "立即刷新", + "unsubscribe": "取消订阅", + "unsubscribeCalendarTitle": "要取消订阅“{name}”吗?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的活动。已发布的日历不会更改。", + "addSubscriptionAction": "添加订阅", + "subscriptionOperationFailed": "日历订阅失败:{error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "订阅", + "calendarImport": "日历导入", + "calendarImportDescription": "选择文件,查看其中的活动,然后选择接收这些活动的可写日历。", + "importIcsFile": "导入 .ics 文件", + "importIcsPreview": "导入日历活动", + "importEventsFound": "可导入活动集:{count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "无效活动:{count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "有意省略:{fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "没有可用的可写目标日历。", + "importDestinationCalendar": "目标日历", + "importIcsConfirm": "导入活动", + "importIcsComplete": "导入完成", + "importQueued": "已导入或已排队:{count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "已跳过重复项:{count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "不支持的重复集:{count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "无法导入日历文件:{error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index da1df534..b830df9b 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,14 +1,14 @@ { "@@locale": "zh_Hant", "appTitle": "BusyMax", - "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", + "connectGoogleAccount": "連接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 帳戶。", "googlePermissionsConsentNotice": "在 Google 權限畫面中,同時選取行事曆和待辦事項權限。", "googlePermissionsRequiredRetry": "必須授予 Google 日曆和 Google Tasks 權限。請再試一次並勾選兩個核取方塊。", "finishSetup": "完成設定", "continueSetup": "繼續", "onboardingSetupTitle": "設定 BusyMax", "onboardingAccountsStepTitle": "連結帳戶", - "onboardingAccountsStepDescription": "Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.", + "onboardingAccountsStepDescription": "新增所有要使用的帳戶。BusyMax 會同步每個帳戶中支援的行事曆、活動、待辦清單和待辦事項。", "onboardingPreferencesStepTitle": "選擇系統設定", "onboardingPreferencesStepDescription": "開啟行程前,請設定桌面行為、提醒、通知詳細程度和外觀。", "signInWithGoogle": "使用 Google 登入", @@ -36,16 +36,40 @@ "newCalendar": "新增行事曆", "calendarColor": "行事曆顏色", "calendarColorOption": "顏色 {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "此提供者不支援 BusyMax 中的行事曆管理。", "primaryCalendarCannotDelete": "無法刪除主要行事曆。", "calendarCreateFailed": "無法建立行事曆:{error}", "calendarCreatedRefreshPending": "行事曆已建立,但 BusyMax 無法重新整理帳戶。它會在下次同步後顯示。", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarUpdateFailed": "無法更新行事曆:{error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "無法刪除行事曆:{error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "新增活動", "refreshCalendar": "重新整理行事曆", "openInProvider": "在服務中開啟", @@ -64,32 +88,56 @@ "scheduleNoSearchResults": "沒有相符的活動或待辦事項", "scheduleNoSearchResultsDescription": "請嘗試其他搜尋內容或清除目前的篩選條件。", "refresh": "重新整理", - "trayOpenBusyMax": "開啟 BusyMax", - "trayShowBusyMax": "Show BusyMax", - "trayNewEvent": "New event…", - "trayNewTask": "New task…", - "trayToday": "Today", - "trayAllDay": "All day", - "trayNow": "Now", - "trayCalendarEvent": "Calendar event", - "trayUntitledEvent": "Untitled event", - "trayNothingElseToday": "Nothing else today", - "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, - "trayOpenTodayAgenda": "Open today’s agenda", - "traySyncNow": "Sync now", - "traySyncing": "Syncing…", - "trayNotConnected": "Not connected", - "trayNotYetSynced": "Not yet synced", - "trayLastSyncedJustNow": "Last synced just now", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, - "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, - "traySettings": "Settings", - "trayQuitBusyMax": "Quit BusyMax", + "trayOpenBusyMax": "打開 BusyMax", + "trayShowBusyMax": "顯示 BusyMax", + "trayNewEvent": "新增活動…", + "trayNewTask": "新增待辦事項…", + "trayToday": "今天", + "trayAllDay": "全天", + "trayNow": "現在", + "trayCalendarEvent": "行事曆活動", + "trayUntitledEvent": "未命名活動", + "trayNothingElseToday": "今天沒有其他內容", + "trayTasksDueToday": "{count, plural, =1{今天有 1 個任務到期} other{今天有 {count} 個任務到期}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "打開今天的日程", + "traySyncNow": "立即同步", + "traySyncing": "正在同步…", + "trayNotConnected": "未連接", + "trayNotYetSynced": "尚未同步", + "trayLastSyncedJustNow": "剛剛同步", + "trayLastSyncedMinutesAgo": "{count, plural, =1{1 分鐘前同步} other{{count} 分鐘前同步}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{1 小時前同步} other{{count} 小時前同步}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{1 天前同步} other{{count} 天前同步}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "設定", + "trayQuitBusyMax": "結束 BusyMax", "agendaLoadMoreOverdue": "載入更多逾期待辦事項", "agendaLoadMoreNoDate": "載入更多無日期待辦事項", "viewDay": "日", @@ -116,7 +164,7 @@ "doesNotRepeat": "不重複", "defaultReminder": "預設提醒", "guests": "參與者", - "noGuests": "沒有參與者", + "noGuests": "無參與者", "description": "說明", "availabilityShowAs": "空閒狀態 / 顯示為", "busy": "忙碌", @@ -217,14 +265,14 @@ "removeAccount": "移除帳戶…", "removingAccount": "正在移除帳戶…", "removeAccountDescription": "停止同步並從此裝置移除此帳戶的資料。", - "removeAccountTitle": "要從 BusyMax 移除 {account} 嗎?", - "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", + "removeAccountTitle": "從 BusyMax 中移除 {account}?", + "removeAccountConfirmation": "這會從此設備刪除緩存的任務、日曆、活動、提醒和待處理的離線更改。未同步的更改將會丟失。提供商中的日曆、活動、任務列表和任務副本不會被刪除。", "revokeGoogleAccess": "同時撤銷 BusyMax 對此 Google 帳戶的存取權", "revokeGoogleAccessDescription": "重新連結前,您必須再次授予存取權。", "removeAccountAction": "移除帳戶", "removeAccountFailed": "無法完成帳戶移除。請再試一次。", "accountRemovedGoogleRevokeFailed": "該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。", - "newTaskList": "新增待辦清單", + "newTaskList": "新任務列表", "signInToViewTaskLists": "登入以查看待辦清單。", "noTaskListsSynced": "尚未同步任何待辦清單。", "listActions": "清單動作", @@ -234,7 +282,7 @@ "deleteList": "刪除清單", "builtInMicrosoftList": "內建", "builtInMicrosoftListCannotRenameDelete": "無法重新命名或刪除 Microsoft To Do 內建清單。", - "deleteListConfirmation": "要從 Google Tasks 刪除「{title}」嗎?", + "deleteListConfirmation": "要從 Google Tasks 中刪除“{title}”嗎?", "deleteEvent": "刪除活動", "title": "標題", "create": "新增", @@ -283,7 +331,7 @@ "reminderDate": "提醒日期", "reminderTime": "提醒時間", "reminder": "提醒", - "addReminder": "新增提醒", + "addReminder": "添加提醒", "addGuest": "新增參與者", "addGuestEmail": "新增參與者電子郵件", "removeReminder": "移除提醒", @@ -391,7 +439,7 @@ "syncFailureNotificationTitle": "BusyMax 同步失敗", "syncFailureNotificationBody": "背景同步失敗。{message}", "conflictNotificationTitle": "BusyMax 同步衝突", - "conflictNotificationBody": "一項待處理的本機變更遭到封鎖。{summary}", + "conflictNotificationBody": "一項擱置中的本機變更已被封鎖。{summary}", "dueTodayNotificationTitle": "今天到期的待辦事項", "dueTodayNotificationBody": "{count, plural, =1{今天有 1 項待辦事項到期。} other{今天有 {count} 項待辦事項到期。}}", "eventReminderNotificationTitle": "活動提醒", @@ -400,7 +448,7 @@ "taskReminderNotificationBody": "待辦事項即將到期。", "notificationOpenAction": "開啟", "notificationSnoozeAction": "10 分鐘後提醒", - "notificationDismissAction": "忽略", + "notificationDismissAction": "關閉", "notificationDetailsHidden": "根據隱私權設定,詳細資料已隱藏。", "previousMonth": "上個月", "nextMonth": "下個月", @@ -413,52 +461,52 @@ "scheduleItemCount": "{count, plural, =1{1 項} other{{count} 項}}", "readOnlyCalendar": "此行事曆為唯讀。", "selectTimeZone": "選擇時區", - "searchLocations": "搜尋地點", - "noLocationsFound": "找不到地點", - "requiredField": "This field is required.", - "providerConnectionDescription": "Connect calendars and tasks from one of these providers.", - "appleICloudProvider": "Apple iCloud Calendar", + "searchLocations": "搜索地點", + "noLocationsFound": "未找到地點", + "requiredField": "此字段為必填項。", + "providerConnectionDescription": "連接以下任一提供商中的日曆和任務。", + "appleICloudProvider": "Apple iCloud 行事曆", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Nextcloud Tasks", - "addAppleICloudAccount": "Add Apple iCloud Calendar account", - "addNextcloudAccount": "Add Nextcloud account", - "waitingForAppleICloud": "Connecting to Apple iCloud…", - "waitingForNextcloud": "Waiting for Nextcloud authorization…", - "connectAppleICloudTitle": "Connect Apple iCloud Calendar", - "appleAccountEmail": "Apple Account email", - "appleAppSpecificPassword": "App-specific password", - "appleAppSpecificPasswordHelp": "Create an app-specific password after enabling two-factor authentication for your Apple Account.", - "appleAppSpecificPasswordResetWarning": "Resetting your Apple Account password revokes app-specific passwords.", - "connectNextcloudTitle": "Connect Nextcloud", - "nextcloudServerUrl": "Nextcloud server or CalDAV address", - "nextcloudServerUrlHelp": "Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "BusyMax will open your browser. Approve access there, then return to BusyMax.", - "connectAccountAction": "Connect", - "cancelAccountConnection": "Cancel connection", - "nextcloudAccountRemovedRevokeFailed": "The account was removed locally, but its Nextcloud app password could not be revoked.", - "davCachedOfflineNotice": "Calendar and task data is cached locally for offline use.", - "davReauthenticationRequired": "Reconnect this account to resume synchronization.", - "davTemporarilyUnavailable": "This account is temporarily unavailable.", - "davPermissionChanged": "Server permissions changed. Pending edits are paused.", - "davUnsupportedServer": "This server or provider profile is not supported.", - "collectionSettings": "Calendars and task lists", - "calendarContent": "Calendar events", - "taskContent": "Tasks", - "readOnlySharedCollection": "Read-only", - "pendingLocally": "Pending locally", - "conflictBlocked": "Blocked by conflict", - "authenticationBlocked": "Blocked until reconnect", - "operationFailed": "Operation failed", - "keepServerVersion": "Keep server version", - "reapplyLocalChange": "Review and reapply local change", - "duplicateLocalItem": "Duplicate as new item", + "nextcloudTasksProvider": "Nextcloud 待辦事項", + "addAppleICloudAccount": "新增 Apple iCloud 行事曆帳戶", + "addNextcloudAccount": "新增 Nextcloud 帳戶", + "waitingForAppleICloud": "正在連接 Apple iCloud…", + "waitingForNextcloud": "正在等待 Nextcloud 授權…", + "connectAppleICloudTitle": "連接 Apple iCloud 行事曆", + "appleAccountEmail": "Apple 賬戶電子郵件", + "appleAppSpecificPassword": "App 專用密碼", + "appleAppSpecificPasswordHelp": "為 Apple 賬戶啓用雙重認證後,創建 App 專用密碼。", + "appleAppSpecificPasswordResetWarning": "重置 Apple 賬戶密碼會撤銷 App 專用密碼。", + "connectNextcloudTitle": "連接 Nextcloud", + "nextcloudServerUrl": "Nextcloud 伺服器或 CalDAV 位址", + "nextcloudServerUrlHelp": "輸入 Nextcloud 服務器 URL,或粘貼從 Nextcloud 複製的主要 CalDAV 地址。", + "nextcloudBrowserAuthorizationHelp": "BusyMax 將打開瀏覽器。請在那裡批准訪問,然後返回 BusyMax。", + "connectAccountAction": "連接", + "cancelAccountConnection": "取消連接", + "nextcloudAccountRemovedRevokeFailed": "賬戶已在本地移除,但無法撤銷 Nextcloud App 密碼。", + "davCachedOfflineNotice": "日曆和任務數據會緩存在本地,以供離線使用。", + "davReauthenticationRequired": "重新連接此賬戶以恢復同步。", + "davTemporarilyUnavailable": "此賬戶暫時不可用。", + "davPermissionChanged": "服務器權限已更改。待處理的編輯已暫停。", + "davUnsupportedServer": "不支持此服務器或提供商配置。", + "collectionSettings": "行事曆和待辦清單", + "calendarContent": "行事曆活動", + "taskContent": "待辦事項", + "readOnlySharedCollection": "唯讀", + "pendingLocally": "本機擱置中", + "conflictBlocked": "因衝突而封鎖", + "authenticationBlocked": "重新連接前阻止", + "operationFailed": "操作失敗", + "keepServerVersion": "保留伺服器版本", + "reapplyLocalChange": "查看並重新應用本地更改", + "duplicateLocalItem": "複製為新項目", "deleteCalendarConfirmation": "要刪除「{title}」嗎?", - "davConnectionState": "Connection state", - "davConnected": "Connected", - "davConnecting": "Connecting…", - "davSignedOut": "Signed out", - "davLastSuccessfulSync": "Last successful sync: {time}", + "davConnectionState": "連線狀態", + "davConnected": "已連線", + "davConnecting": "正在連線…", + "davSignedOut": "已登出", + "davLastSuccessfulSync": "上次成功同步:{time}", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -466,9 +514,9 @@ } } }, - "davNeverSynced": "Not synchronized yet", - "refreshCollections": "Refresh calendars and task lists", - "nextcloudServerHost": "Server: {host}", + "davNeverSynced": "尚未同步", + "refreshCollections": "刷新日曆和任務列表", + "nextcloudServerHost": "服務器:{host}", "@nextcloudServerHost": { "placeholders": { "host": { @@ -476,12 +524,12 @@ } } }, - "collectionSupportsEvents": "Event calendar", - "collectionSupportsTasks": "Task list", - "collectionSupportsEventsAndTasks": "Events and tasks", - "writableCollection": "Writable", - "sharedCollection": "Shared", - "collectionLastSynced": "Last synchronized: {time}", + "collectionSupportsEvents": "活動日曆", + "collectionSupportsTasks": "任務列表", + "collectionSupportsEventsAndTasks": "活動和任務", + "writableCollection": "可寫", + "sharedCollection": "已共享", + "collectionLastSynced": "上次同步:{time}", "@collectionLastSynced": { "placeholders": { "time": { @@ -489,7 +537,7 @@ } } }, - "collectionSyncError": "Sync issue: {code}", + "collectionSyncError": "同步問題:{code}", "@collectionSyncError": { "placeholders": { "code": { @@ -497,8 +545,8 @@ } } }, - "syncConflicts": "Synchronization conflicts", - "remoteChangedAt": "Server changed: {time}", + "syncConflicts": "同步衝突", + "remoteChangedAt": "服務器更改:{time}", "@remoteChangedAt": { "placeholders": { "time": { @@ -506,7 +554,7 @@ } } }, - "localPendingEdit": "Local edit: {summary}", + "localPendingEdit": "本地編輯:{summary}", "@localPendingEdit": { "placeholders": { "summary": { @@ -514,9 +562,9 @@ } } }, - "conflictResolutionFailed": "The conflict could not be resolved.", - "recurringEventScope": "Recurring event scope", - "entireSeries": "Entire series", + "conflictResolutionFailed": "無法解決衝突。", + "recurringEventScope": "重複活動範圍", + "entireSeries": "整個系列", "singleOccurrence": "此事件", "thisAndFollowingEvents": "此事件及後續事件", "thisAndFutureUnavailable": "此提供者不支援。", @@ -525,204 +573,288 @@ "copyEventAndDeleteOriginal": "複製活動並刪除原活動?", "copyEventMoveWarning": "BusyMax 無法將此活動直接從 {source} 移動到 {destination}。應用程式會先建立副本,並且只在複製成功後刪除原活動。活動 ID 將會變更;參與者的回覆狀態可能會重設,並可能傳送邀請或取消通知;會議連結、附件、提醒、提供者特有的欄位和重複例外可能無法保留。", "copyAndDelete": "複製並刪除", - "chooseRecurringEventScope": "Choose whether this change applies to the entire series or only this occurrence.", + "chooseRecurringEventScope": "選擇此變更適用於整個系列、僅此活動,還是此活動及後續活動。", "taskDueBeforeStart": "到期時間不得早於開始時間。", - "taskStartDueTimeModeMismatch": "請同時設定開始與到期時間,或將工作設為全天。", - "taskListCreateFailed": "Could not create the task list: {error}", - "taskListRenameFailed": "Could not rename the task list: {error}", - "taskListDeleteFailed": "Could not delete the task list: {error}", - "unshare": "撤回分享", - "readOnlyTaskListCannotRename": "This task list is read-only and cannot be renamed.", - "taskListCannotDelete": "This task list cannot be deleted with your current permissions.", - "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "taskStatus": "Status", - "taskStatusNone": "No status", + "taskStartDueTimeModeMismatch": "請同時設定開始與到期時間,或將待辦事項設為全天。", + "taskListCreateFailed": "無法創建任務列表:{error}", + "taskListRenameFailed": "無法重命名任務列表:{error}", + "taskListDeleteFailed": "無法刪除任務列表:{error}", + "unshare": "取消共享", + "readOnlyTaskListCannotRename": "此任務列表為只讀,無法重命名。", + "taskListCannotDelete": "使用當前權限無法刪除此任務列表。", + "deleteTaskListConfirmation": "要刪除“{title}”及其所有任務嗎?", + "unshareTaskListConfirmation": "要取消此賬戶對“{title}”的共享嗎?", + "taskStatus": "狀態", + "taskStatusNone": "無狀態", "taskStatusNeedsAction": "需要動作", "taskStatusInProcess": "進行中", - "taskStatusCompleted": "完成", - "taskStatusCancelled": "Cancelled", - "completionPercent": "{percent}% completed", - "completionDate": "Completion date", - "priority": "優先", - "priorityNone": "No priority", - "priorityHighValue": "Priority {priority} · High", - "priorityMediumValue": "Priority {priority} · Medium", - "priorityLowValue": "Priority {priority} · Low", - "taskUrl": "URL", - "invalidTaskUrl": "Enter an absolute URL, including its scheme.", - "classification": "Classification", - "classificationPublic": "When shared, show the full task", - "classificationConfidential": "When shared, show only busy", - "classificationPrivate": "When shared, hide this task", - "pinTask": "Pin task", - "reminders": "Reminders", + "taskStatusCompleted": "已完成", + "taskStatusCancelled": "已取消", + "completionPercent": "已完成 {percent}%", + "completionDate": "完成日期", + "priority": "優先順序", + "priorityNone": "無優先順序", + "priorityHighValue": "優先級 {priority} · 高", + "priorityMediumValue": "優先級 {priority} · 中", + "priorityLowValue": "優先級 {priority} · 低", + "taskUrl": "任務 URL", + "invalidTaskUrl": "請輸入包含方案的絕對 URL。", + "classification": "分類", + "classificationPublic": "共享時顯示完整任務", + "classificationConfidential": "共享時僅顯示忙碌狀態", + "classificationPrivate": "共享時隱藏此任務", + "pinTask": "固定任務", + "reminders": "提醒", "noReminders": "無提醒", - "editReminder": "Edit reminder", - "beforeTaskStarts": "在任務開始前", - "beforeTaskDue": "任務截止前", - "afterTaskStarts": "After the task starts", - "afterTaskDue": "After the task is due", - "relativeToTaskStart": "Relative to the task start date", - "relativeToTaskDue": "Relative to the task due date", - "reminderTimeOfDay": "Time of day", - "absoluteReminder": "At a date and time", - "reminderAmount": "Amount", - "reminderUnitSeconds": "Seconds", - "reminderUnitMinutes": "Minutes", - "reminderUnitHours": "Hours", - "reminderUnitDays": "Days", - "reminderUnitWeeks": "Weeks", - "reminderAtTaskStart": "At the task start", - "reminderAtTaskDue": "At the task due time", - "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", - "relatedRemindersTitle": "Keep related reminders?", - "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", + "editReminder": "編輯提醒", + "beforeTaskStarts": "待辦事項開始前", + "beforeTaskDue": "待辦事項到期前", + "afterTaskStarts": "任務開始後", + "afterTaskDue": "任務到期後", + "relativeToTaskStart": "相對於任務開始日期", + "relativeToTaskDue": "相對於任務到期日期", + "reminderTimeOfDay": "時間", + "absoluteReminder": "在指定日期和時間", + "reminderAmount": "數量", + "reminderUnitSeconds": "秒", + "reminderUnitMinutes": "分鐘", + "reminderUnitHours": "小時", + "reminderUnitDays": "天", + "reminderUnitWeeks": "週", + "reminderAtTaskStart": "任務開始時", + "reminderAtTaskDue": "任務到期時", + "unsupportedReminder": "此提醒類型會保留,但無法編輯其時間。", + "relatedRemindersTitle": "保留相關提醒?", + "relatedRemindersDescription": "此日期有 {count} 個相關提醒。要保留它們當前的日期和時間嗎?", "discardRelatedReminders": "捨棄提醒", "keepRelatedReminders": "保留提醒", - "repeatEvery": "重複循環", - "repeatOn": "Repeat on", - "repeatEnd": "停止重複", - "repeatNever": "Never", - "repeatUntil": "On date", - "repeatAfter": "After a number of occurrences", - "repeatCount": "Occurrences", - "repeatDayOfMonth": "Days of month", - "repeatMonths": "Months", - "repeatOrdinal": "Weekday position", - "repeatSpecificDays": "Specific days", - "repeatFirst": "First", - "repeatSecond": "Second", - "repeatThird": "Third", - "repeatFourth": "Fourth", - "repeatFifth": "Fifth", - "repeatSecondToLast": "Second to last", - "repeatLast": "Last", - "repeatAnyDay": "Day", - "repeatWeekday": "Weekday", - "repeatWeekendDay": "Weekend day", - "repeatEveryDays": "Every {count} days", - "repeatEveryWeeks": "Every {count} weeks", - "repeatEveryMonths": "Every {count} months", - "repeatEveryYears": "Every {count} years", - "repeatOnDaysSummary": "於 {days}", - "repeatOnMonthDaysSummary": "於 {days} 天", - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatEvery": "重複間隔", + "repeatOn": "重複日期", + "repeatEnd": "結束重複", + "repeatNever": "永不", + "repeatUntil": "指定日期", + "repeatAfter": "指定次數後", + "repeatCount": "重複次數", + "repeatDayOfMonth": "每月日期", + "repeatMonths": "月份", + "repeatOrdinal": "星期位置", + "repeatSpecificDays": "特定日期", + "repeatFirst": "第一", + "repeatSecond": "第二", + "repeatThird": "第三", + "repeatFourth": "第四", + "repeatFifth": "第五", + "repeatSecondToLast": "倒數第二", + "repeatLast": "最後", + "repeatAnyDay": "日期", + "repeatWeekday": "工作日", + "repeatWeekendDay": "週末", + "repeatEveryDays": "每 {count} 天", + "repeatEveryWeeks": "每 {count} 週", + "repeatEveryMonths": "每 {count} 個月", + "repeatEveryYears": "每 {count} 年", + "repeatOnDaysSummary": "在 {days}", + "repeatOnMonthDaysSummary": "每月 {days} 日", + "repeatOnOrdinalSummary": "{days} 的第 {ordinal} 個", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "{count} 次", - "repeatUntilSummary": "到 {date}", - "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", + "repeatTimesSummary": "重複 {count} 次", + "repeatUntilSummary": "直到 {date}", + "unsupportedRecurrencePreserved": "此重複規則使用了編輯器不會更改的選項。", "recurrenceUnsupportedByProvider": "此重複規則無法用於 {provider}。", - "duplicateTask": "再製任務", - "taskDuplicated": "Task duplicated.", - "taskDuplicateFailed": "Could not duplicate the task: {error}", + "duplicateTask": "複製待辦事項", + "taskDuplicated": "任務已複製。", + "taskDuplicateFailed": "無法複製任務:{error}", "hideSubtasks": "隱藏子工作項目", "hideClosedSubtasks": "隱藏已關閉的子工作項目", - "reminderUnit": "Unit", - "attendeeRequired": "Required", - "attendeeOptional": "Optional", - "meetingSection": "Meeting", - "addGoogleMeet": "Add Google Meet", - "addTeamsMeeting": "Add Microsoft Teams meeting", - "onlineMeetingAdded": "Online meeting added", - "requestResponses": "Request responses", - "requestResponsesDescription": "Ask guests to respond to the invitation.", - "hideGuestList": "Hide guest list", - "hideGuestListDescription": "Guests cannot see who else was invited.", - "allowNewTimeProposals": "Allow new time proposals", - "allowNewTimeProposalsDescription": "Guests can suggest a different meeting time.", - "notifyGuestsTitle": "Notify guests?", - "notifyGuestsSaveMessage": "This meeting has guests. Send invitations or event updates when it is saved?", - "notifyGuestsDeleteMessage": "This meeting has guests. Send a cancellation when it is deleted?", - "sendUpdates": "Send updates", - "sendCancellation": "Send cancellation", - "doNotSend": "Don’t send", - "microsoftNotifyGuestsSaveTitle": "Save meeting?", - "microsoftNotifyGuestsSaveMessage": "Microsoft will send invitations or event updates to guests.", - "microsoftNotifyGuestsDeleteTitle": "Delete meeting?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft will send a cancellation to guests.", - "organizer": "Organizer", - "yourResponse": "Your response", - "guestResponses": "Guest responses", - "respond": "Respond", - "acceptInvitation": "Accept", - "tentativeInvitation": "Tentative", - "declineInvitation": "Decline", - "joinMeeting": "Join meeting", - "responseAccepted": "Accepted", - "responseTentative": "Tentative", - "responseDeclined": "Declined", - "responseNeedsAction": "Awaiting response", - "responseNotResponded": "Not responded", - "responseOrganizer": "Organizer", - "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, - "joinMeetingFailed": "Could not open the meeting link.", + "reminderUnit": "單位", + "attendeeRequired": "必要", + "attendeeOptional": "選用", + "meetingSection": "會議", + "addGoogleMeet": "添加 Google Meet", + "addTeamsMeeting": "添加 Microsoft Teams 會議", + "onlineMeetingAdded": "已添加在線會議", + "requestResponses": "要求回覆", + "requestResponsesDescription": "要求參與者回復邀請。", + "hideGuestList": "隱藏參與者清單", + "hideGuestListDescription": "參與者無法查看其他受邀者。", + "allowNewTimeProposals": "允許提出新時間", + "allowNewTimeProposalsDescription": "參與者可以建議其他會議時間。", + "notifyGuestsTitle": "通知參與者?", + "notifyGuestsSaveMessage": "此會議有參與者。保存時發送邀請或活動更新嗎?", + "notifyGuestsDeleteMessage": "此會議有參與者。刪除時發送取消通知嗎?", + "sendUpdates": "傳送更新", + "sendCancellation": "傳送取消通知", + "doNotSend": "不要傳送", + "microsoftNotifyGuestsSaveTitle": "保存會議?", + "microsoftNotifyGuestsSaveMessage": "Microsoft 將向參與者發送邀請或活動更新。", + "microsoftNotifyGuestsDeleteTitle": "刪除會議?", + "microsoftNotifyGuestsDeleteMessage": "Microsoft 將向參與者發送取消通知。", + "organizer": "主辦人", + "yourResponse": "你的回覆", + "guestResponses": "參與者回覆", + "respond": "回復", + "acceptInvitation": "接受", + "tentativeInvitation": "暫定", + "declineInvitation": "拒絕", + "joinMeeting": "加入會議", + "responseAccepted": "已接受", + "responseTentative": "暫定", + "responseDeclined": "已拒絕", + "responseNeedsAction": "等待回復", + "responseNotResponded": "未回復", + "responseOrganizer": "組織者", + "invitationResponseFailed": "無法發送回復:{error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "無法打開會議鏈接。", "setCustomCalendarName": "設定自訂名稱", "setAction": "設定", "removeFromMyCalendars": "從「我的日曆」移除", "removeAction": "移除", "removeCalendarConfirmation": "要從您的 Google 日曆清單移除「{title}」嗎?共用日曆及其活動不會被刪除。", - "calendarCannotRemove": "無法從此帳戶刪除或移除此日曆。", - "calendarPendingChangesPreventRemoval": "請等待此行事曆的待處理變更同步完成,再刪除或移除它。", - "calendarSubscriptions": "Calendar subscriptions", - "calendarSubscriptionsDescription": "Add read-only calendars that refresh from a secure WebCal URL.", - "addCalendarSubscription": "Add calendar subscription", - "subscriptionName": "Local name", - "subscriptionUrl": "Subscription URL", - "subscriptionUrlHelp": "Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.", - "subscriptionUrlInvalid": "Enter a valid HTTPS or webcal URL without user information or a fragment.", - "subscriptionColor": "Local color", - "subscriptionColorHelp": "Use a six-digit color such as #3584E4.", - "subscriptionColorInvalid": "Enter a six-digit hexadecimal color.", - "subscriptionRefreshMode": "Refresh frequency", - "subscriptionAutomatic": "Automatic", - "subscriptionHourly": "Hourly", - "subscriptionSixHours": "Every six hours", - "subscriptionDaily": "Daily", - "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, - "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", - "subscriptionReadOnly": "Read-only subscription", - "subscriptionNeverRefreshed": "Not refreshed yet", - "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, - "subscriptionStatusHealthy": "Up to date", - "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, - "refreshNow": "Refresh now", - "unsubscribe": "Unsubscribe", - "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, - "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", - "addSubscriptionAction": "Add subscription", - "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, - "subscriptions": "Subscriptions", + "calendarCannotRemove": "無法從此賬戶刪除或移除此日曆。", + "calendarPendingChangesPreventRemoval": "請等待此日曆的待處理更改完成同步,然後再刪除或移除它。", + "calendarSubscriptions": "行事曆訂閱", + "calendarSubscriptionsDescription": "添加從安全 WebCal URL 刷新的只讀日曆。", + "addCalendarSubscription": "新增行事曆訂閱", + "subscriptionName": "本機名稱", + "subscriptionUrl": "訂閱 URL", + "subscriptionUrlHelp": "輸入 HTTPS 或 webcal URL。BusyMax 會將完整 URL 保存在安全存儲中。", + "subscriptionUrlInvalid": "請輸入不含用戶信息或片段的有效 HTTPS 或 webcal URL。", + "subscriptionColor": "本地顏色", + "subscriptionColorHelp": "使用六位顏色,例如 #3584E4。", + "subscriptionColorInvalid": "請輸入六位十六進制顏色。", + "subscriptionRefreshMode": "刷新頻率", + "subscriptionAutomatic": "自動", + "subscriptionHourly": "每小時", + "subscriptionSixHours": "每六小時", + "subscriptionDaily": "每天", + "subscriptionSafeOrigin": "來源:{origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "請輸入有效 URL 以預覽其安全來源。", + "subscriptionReadOnly": "只讀訂閱", + "subscriptionNeverRefreshed": "尚未刷新", + "subscriptionLastRefresh": "上次成功刷新:{time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "下次刷新:{time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "已是最新", + "subscriptionStatusIssue": "刷新問題:{code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "立即重新整理", + "unsubscribe": "取消訂閱", + "unsubscribeCalendarTitle": "要取消訂閱“{name}”嗎?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "這會移除本地訂閱及其緩存的活動。已發佈的日曆不會更改。", + "addSubscriptionAction": "添加訂閱", + "subscriptionOperationFailed": "日曆訂閱失敗:{error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "訂閱", "calendarImport": "匯入行事曆", - "calendarImportDescription": "選擇檔案並檢查其中的行程,然後選擇要接收這些行程的可寫入行事曆。", - "importIcsFile": "Import .ics file", - "importIcsPreview": "Import calendar events", - "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, - "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, - "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, - "noWritableCalendars": "No writable destination calendar is available.", - "importDestinationCalendar": "Destination calendar", - "importIcsConfirm": "Import events", - "importIcsComplete": "Import complete", - "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, - "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, - "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, - "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "calendarImportDescription": "選擇文件,查看其中的活動,然後選擇接收這些活動的可寫日曆。", + "importIcsFile": "匯入 .ics 檔案", + "importIcsPreview": "匯入行事曆活動", + "importEventsFound": "可導入活動集:{count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "無效活動:{count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "有意省略:{fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "沒有可用的可寫目標日曆。", + "importDestinationCalendar": "目標日曆", + "importIcsConfirm": "導入活動", + "importIcsComplete": "匯入完成", + "importQueued": "已導入或已排隊:{count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "已跳過重複項:{count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "不支持的重複集:{count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "無法導入日曆文件:{error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "離線", "networkOfflineDescription": "連線恢復後將同步變更。", "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。" diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index fe716833..9c689914 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -138,7 +138,7 @@ abstract class AppLocalizations { /// **'BusyMax'** String get appTitle; - /// No description provided for @connectGoogleAccount. + /// Shown in account onboarding and settings; names the supported account providers. /// /// In en, this message translates to: /// **'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'** @@ -180,7 +180,7 @@ abstract class AppLocalizations { /// **'Connect accounts'** String get onboardingAccountsStepTitle; - /// No description provided for @onboardingAccountsStepDescription. + /// Onboarding explanation of the account data BusyMax synchronizes. /// /// In en, this message translates to: /// **'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'** @@ -2013,7 +2013,7 @@ abstract class AppLocalizations { /// No description provided for @taskStatusInProcess. /// /// In en, this message translates to: - /// **'In process'** + /// **'In progress'** String get taskStatusInProcess; /// No description provided for @taskStatusCompleted. @@ -2208,7 +2208,7 @@ abstract class AppLocalizations { /// **'Before the task starts'** String get beforeTaskStarts; - /// No description provided for @beforeTaskDue. + /// Reminder timing relative to the task's due time/deadline, not task completion. /// /// In en, this message translates to: /// **'Before the task is due'** @@ -2400,13 +2400,13 @@ abstract class AppLocalizations { /// **'Yearly'** String get repeatYearly; - /// No description provided for @repeatEvery. + /// Standalone label for the numeric interval used by daily, weekly, monthly, and yearly recurrence. /// /// In en, this message translates to: - /// **'Repeat every'** + /// **'Interval'** String get repeatEvery; - /// No description provided for @repeatOn. + /// Recurrence editor label introducing the selected weekdays, month days, or ordinal weekday. /// /// In en, this message translates to: /// **'Repeat on'** @@ -2550,37 +2550,37 @@ abstract class AppLocalizations { /// **'Every {count} years'** String repeatEveryYears(int count); - /// No description provided for @repeatOnDaysSummary. + /// Sentence fragment appended to a recurrence summary; {days} is a localized list of weekdays. /// /// In en, this message translates to: /// **'on {days}'** String repeatOnDaysSummary(String days); - /// No description provided for @repeatOnMonthDaysSummary. + /// Sentence fragment appended to a recurrence summary; {days} is a localized list of day-of-month numbers. /// /// In en, this message translates to: /// **'on day {days}'** String repeatOnMonthDaysSummary(String days); - /// No description provided for @repeatOnOrdinalSummary. + /// Sentence fragment for ordinal recurrence, such as the first Monday; {ordinal} and {days} are localized. /// /// In en, this message translates to: /// **'on the {ordinal} {days}'** String repeatOnOrdinalSummary(String ordinal, String days); - /// No description provided for @repeatInMonthsSummary. + /// Sentence fragment for selected recurrence months; {months} is a localized list of month names. /// /// In en, this message translates to: /// **'in {months}'** String repeatInMonthsSummary(String months); - /// No description provided for @repeatTimesSummary. + /// Sentence fragment summarizing a finite recurrence count. /// /// In en, this message translates to: /// **'{count} times'** String repeatTimesSummary(int count); - /// No description provided for @repeatUntilSummary. + /// Sentence fragment for the recurrence end date. /// /// In en, this message translates to: /// **'until {date}'** @@ -2778,7 +2778,7 @@ abstract class AppLocalizations { /// **'Updated'** String get updated; - /// No description provided for @parent. + /// Metadata label for the immediate parent task of a subtask. /// /// In en, this message translates to: /// **'Parent'** @@ -3258,7 +3258,7 @@ abstract class AppLocalizations { /// **'Snooze 10 minutes'** String get notificationSnoozeAction; - /// No description provided for @notificationDismissAction. + /// Non-destructive action that closes or dismisses a desktop notification. Translate distinctly from reject, discard, or cancel. /// /// In en, this message translates to: /// **'Dismiss'** @@ -3750,10 +3750,10 @@ abstract class AppLocalizations { /// **'Copy and delete'** String get copyAndDelete; - /// No description provided for @chooseRecurringEventScope. + /// Explains the three recurring-event scope choices: entire series, one occurrence, or this and following events. /// /// In en, this message translates to: - /// **'Choose which events this change applies to.'** + /// **'Choose whether this change applies to the entire series, only this occurrence, or this and following events.'** String get chooseRecurringEventScope; /// No description provided for @taskDueBeforeStart. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index f9331981..ee3961ad 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -17,7 +17,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'اربط حسابات Google أو Microsoft أو تقويم Apple iCloud أو Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'أضف كل الحسابات التي تريد استخدامها. يزامن BusyMax التقويمات والأحداث وقوائم المهام والمهام المدعومة من كل حساب.'; @override String get onboardingPreferencesStepTitle => 'اختيار إعدادات النظام'; @@ -218,68 +218,72 @@ class AppLocalizationsAr extends AppLocalizations { String get trayOpenBusyMax => 'فتح BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'إظهار BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'حدث جديد…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'مهمة جديدة…'; @override - String get trayToday => 'Today'; + String get trayToday => 'اليوم'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'طوال اليوم'; @override - String get trayNow => 'Now'; + String get trayNow => 'الآن'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'حدث في التقويم'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'حدث بلا عنوان'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'لا شيء آخر اليوم'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count مهمة مستحقة اليوم', + many: '$count مهمة مستحقة اليوم', + few: '$count مهام مستحقة اليوم', + two: 'مهمتان مستحقتان اليوم', + one: 'مهمة واحدة مستحقة اليوم', + zero: 'لا توجد مهام مستحقة اليوم', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'فتح جدول أعمال اليوم'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'مزامنة الآن'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'جارٍ العمل على المزامنة…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'غير متصل'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'لم تتم المزامنة بعد'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'تمت المزامنة للتو'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'تمت المزامنة قبل $count دقائق', + one: 'تمت المزامنة قبل دقيقة واحدة', ); return '$_temp0'; } @@ -289,8 +293,8 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'تمت المزامنة قبل $count ساعات', + one: 'تمت المزامنة قبل ساعة واحدة', ); return '$_temp0'; } @@ -300,17 +304,17 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'تمت المزامنة قبل $count أيام', + one: 'تمت المزامنة قبل يوم واحد', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'الإعدادات'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'إنهاء BusyMax'; @override String get agendaLoadMoreOverdue => 'تحميل المزيد من المهام المتأخرة'; @@ -392,130 +396,129 @@ class AppLocalizationsAr extends AppLocalizations { String get guests => 'المدعوون'; @override - String get noGuests => 'لا يوجد مدعوون'; + String get noGuests => 'لا يوجد ضيوف'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'مطلوب'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'اختياري'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'الاجتماع'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'إضافة Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'إضافة اجتماع Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'تمت إضافة الاجتماع عبر الإنترنت'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'طلب الردود'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => 'اطلب من الضيوف الرد على الدعوة.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'إخفاء قائمة الضيوف'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'لا يمكن للضيوف رؤية المدعوين الآخرين.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'السماح باقتراح أوقات جديدة'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'يمكن للضيوف اقتراح وقت مختلف للاجتماع.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'إبلاغ الضيوف؟'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'يضم هذا الاجتماع ضيوفًا. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'يضم هذا الاجتماع ضيوفًا. هل تريد إرسال إلغاء عند حذفه؟'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'إرسال التحديثات'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'إرسال الإلغاء'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'عدم الإرسال'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'حفظ الاجتماع؟'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'سترسل Microsoft الدعوات أو تحديثات الحدث إلى الضيوف.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'حذف الاجتماع؟'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'سترسل Microsoft إلغاءً إلى الضيوف.'; @override - String get organizer => 'Organizer'; + String get organizer => 'المنظّم'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'ردك'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'ردود الضيوف'; @override - String get respond => 'Respond'; + String get respond => 'الرد'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'قبول'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'مبدئي'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'رفض'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'الانضمام إلى الاجتماع'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'مقبول'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'مبدئي'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'مرفوض'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'بانتظار الرد'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'لم يتم الرد'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'المنظّم'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'تعذّر إرسال ردك: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'تعذّر فتح رابط الاجتماع.'; @override String get description => 'الوصف'; @@ -873,12 +876,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'إزالة ⁨$account⁩ من BusyMax؟'; + return 'إزالة $account من BusyMax؟'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'سيؤدي هذا إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المخزنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات التي لم تتم مزامنتها، ولن تُحذف نسخ التقويمات والأحداث وقوائم المهام والمهام لدى موفّر الخدمة.'; @override String get revokeGoogleAccess => @@ -903,17 +906,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'تعذّر إنشاء قائمة المهام: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'تعذّر إعادة تسمية قائمة المهام: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'تعذّر حذف قائمة المهام: $error'; } @override @@ -942,11 +945,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'قائمة المهام هذه للقراءة فقط ولا يمكن إعادة تسميتها.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'لا يمكن حذف قائمة المهام هذه باستخدام أذوناتك الحالية.'; @override String get builtInMicrosoftList => 'مدمجة'; @@ -957,17 +960,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'حذف «⁨$title⁩» من Google Tasks؟'; + return 'حذف \"$title\" من Google Tasks؟'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'حذف \"$title\" وجميع مهامها؟'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'إلغاء مشاركة \"$title\" من هذا الحساب؟'; } @override @@ -1089,75 +1092,75 @@ class AppLocalizationsAr extends AppLocalizations { String get openStatus => 'مفتوحة'; @override - String get doneStatus => 'منجزة'; + String get doneStatus => 'منجز'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'الحالة'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'بلا حالة'; @override String get taskStatusNeedsAction => 'تحتاج إلى إجراء'; @override - String get taskStatusInProcess => 'تحت الإجراء'; + String get taskStatusInProcess => 'قيد التنفيذ'; @override - String get taskStatusCompleted => 'مُكتمِل'; + String get taskStatusCompleted => 'مكتملة'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'ملغاة'; @override String completionPercent(int percent) { - return '$percent% completed'; + return 'اكتمل بنسبة $percent٪'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'تاريخ الإكمال'; @override String get priority => 'الأولوية'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'بلا أولوية'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'الأولوية $priority · عالية'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'الأولوية $priority · متوسطة'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'الأولوية $priority · منخفضة'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL المهمة'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'أدخل عنوان URL مطلقًا يتضمن مخططه.'; @override - String get classification => 'Classification'; + String get classification => 'التصنيف'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'عند المشاركة، أظهر المهمة كاملة'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'عند المشاركة، أظهر الانشغال فقط'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'عند المشاركة، أخفِ هذه المهمة'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'تثبيت المهمة'; @override String get notes => 'ملاحظات'; @@ -1196,82 +1199,82 @@ class AppLocalizationsAr extends AppLocalizations { String get addReminder => 'إضافة تذكير'; @override - String get reminders => 'Reminders'; + String get reminders => 'التذكيرات'; @override - String get noReminders => 'لا توجد أي تذكيرات'; + String get noReminders => 'لا توجد تذكيرات'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'تعديل التذكير'; @override - String get beforeTaskStarts => 'قبل أن تبدأ المهمة'; + String get beforeTaskStarts => 'قبل بدء المهمة'; @override - String get beforeTaskDue => 'قبل اكتمال المهمة'; + String get beforeTaskDue => 'قبل موعد استحقاق المهمة'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'بعد بدء المهمة'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'بعد استحقاق المهمة'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'بالنسبة إلى تاريخ بدء المهمة'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'بالنسبة إلى تاريخ استحقاق المهمة'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'وقت اليوم'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'في تاريخ ووقت'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'الكمية'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'الوحدة'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'ثوانٍ'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'دقائق'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'ساعات'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'أيام'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'أسابيع'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'عند بدء المهمة'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'عند وقت استحقاق المهمة'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'يُحتفظ بنوع هذا التذكير، لكن لا يمكن تعديل وقته.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'الاحتفاظ بالتذكيرات المرتبطة؟'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'يحتوي هذا التاريخ على $count من التذكيرات المرتبطة. هل تريد الاحتفاظ بها في تاريخها ووقتها الحاليين؟'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'تجاهل التذكيرات'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'الاحتفاظ بالتذكيرات'; @override String get addGuest => 'إضافة مدعو'; @@ -1307,106 +1310,106 @@ class AppLocalizationsAr extends AppLocalizations { String get repeatYearly => 'سنويًا'; @override - String get repeatEvery => 'تكرار كل'; + String get repeatEvery => 'الفاصل الزمني'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'التكرار في'; @override - String get repeatEnd => 'نهاية التكرار'; + String get repeatEnd => 'إنهاء التكرار'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'مطلقًا'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'في تاريخ'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'بعد عدد من التكرارات'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'عدد التكرارات'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'أيام الشهر'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'الأشهر'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'ترتيب يوم الأسبوع'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'أيام محددة'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'الأول'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'الثاني'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'الثالث'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'الرابع'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'الخامس'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'ما قبل الأخير'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'الأخير'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'اليوم'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'يوم من أيام الأسبوع'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'يوم عطلة نهاية الأسبوع'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'كل $count أيام'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'كل $count أسابيع'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'كل $count أشهر'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'كل $count سنوات'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'في $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'في يوم $days'; + return 'في اليوم $days من الشهر'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'في $ordinal $days'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'في $months'; } @override @@ -1416,12 +1419,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatUntilSummary(String date) { - return 'until $date'; + return 'حتى $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'تستخدم قاعدة التكرار هذه خيارات لا يغيّرها هذا المحرر.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1481,21 +1484,21 @@ class AppLocalizationsAr extends AppLocalizations { String get subtasks => 'مهام فرعية'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'تكرار المهمة'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'تم تكرار المهمة.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'تعذّر تكرار المهمة: $error'; } @override - String get hideSubtasks => 'إخْفِ المهام الفرعية'; + String get hideSubtasks => 'إخفاء المهام الفرعية'; @override - String get hideClosedSubtasks => 'إخف المهام الفرعية المغلقة'; + String get hideClosedSubtasks => 'إخفاء المهام الفرعية المغلقة'; @override String get moveToTop => 'نقل إلى الأعلى'; @@ -1756,7 +1759,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'تم حظر تغيير محلي معلّق. ⁨$summary⁩'; + return 'تم حظر تغيير محلي معلق. $summary'; } @override @@ -1796,7 +1799,7 @@ class AppLocalizationsAr extends AppLocalizations { String get notificationSnoozeAction => 'غفوة لمدة 10 دقائق'; @override - String get notificationDismissAction => 'تجاهل'; + String get notificationDismissAction => 'إغلاق'; @override String get notificationDetailsHidden => @@ -1850,20 +1853,20 @@ class AppLocalizationsAr extends AppLocalizations { String get selectTimeZone => 'اختيار المنطقة الزمنية'; @override - String get searchLocations => 'البحث عن مواقع'; + String get searchLocations => 'البحث عن المواقع'; @override String get noLocationsFound => 'لم يتم العثور على مواقع'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'هذا الحقل مطلوب.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'اربط التقويمات والمهام من أحد موفّري الخدمة هؤلاء.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'تقويم Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1872,188 +1875,187 @@ class AppLocalizationsAr extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'مهام Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'إضافة حساب تقويم Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'إضافة حساب Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'جارٍ الاتصال بـ Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'بانتظار تفويض Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'ربط تقويم Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'البريد الإلكتروني لحساب Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'كلمة مرور خاصة بالتطبيق'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'أنشئ كلمة مرور خاصة بالتطبيق بعد تفعيل المصادقة الثنائية لحساب Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'تؤدي إعادة تعيين كلمة مرور حساب Apple إلى إبطال كلمات المرور الخاصة بالتطبيقات.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'ربط Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'خادم Nextcloud أو عنوان CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'أدخل عنوان URL لخادم Nextcloud أو الصق عنوان CalDAV الأساسي المنسوخ من Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'سيفتح BusyMax متصفحك. وافق على الوصول هناك، ثم عد إلى BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'ربط'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'إلغاء الربط'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'تمت إزالة الحساب محليًا، لكن تعذّر إبطال كلمة مرور تطبيق Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'تُخزّن بيانات التقويم والمهام محليًا للاستخدام دون اتصال.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'أعد ربط هذا الحساب لاستئناف المزامنة.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'هذا الحساب غير متاح مؤقتًا.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'تغيّرت أذونات الخادم. تم إيقاف التعديلات المعلقة مؤقتًا.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'هذا الخادم أو ملف موفّر الخدمة غير مدعوم.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'التقويمات وقوائم المهام'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'أحداث التقويم'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'المهام'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'للقراءة فقط'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'معلق محليًا'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'محظور بسبب تعارض'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'محظور حتى إعادة الاتصال'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'فشلت العملية'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'الاحتفاظ بإصدار الخادم'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'مراجعة التغيير المحلي وإعادة تطبيقه'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'تكرار كعنصر جديد'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'حالة الاتصال'; @override - String get davConnected => 'Connected'; + String get davConnected => 'متصل'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'جارٍ الاتصال…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'تم تسجيل الخروج'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'آخر مزامنة ناجحة: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'لم تتم المزامنة بعد'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'تحديث التقويمات وقوائم المهام'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'الخادم: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'تقويم أحداث'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'قائمة مهام'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'الأحداث والمهام'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'قابل للكتابة'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'مشترك'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'آخر مزامنة: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'مشكلة في المزامنة: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'تعارضات المزامنة'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'غيّر الخادم في: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'تعديل محلي: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'تعذّر حل التعارض.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'نطاق الحدث المتكرر'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'السلسلة بأكملها'; @override String get singleOccurrence => 'هذا الحدث'; @@ -2085,7 +2087,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'اختر ما إذا كان هذا التغيير ينطبق على السلسلة بأكملها، أو هذا الحدث فقط، أو هذا الحدث والأحداث التالية.'; @override String get taskDueBeforeStart => 'يجب ألا يكون موعد الاستحقاق قبل وقت البدء.'; @@ -2122,174 +2124,174 @@ class AppLocalizationsAr extends AppLocalizations { @override String get calendarPendingChangesPreventRemoval => - 'انتظر حتى تنتهي مزامنة التغييرات المعلّقة لهذا التقويم قبل حذفه أو إزالته.'; + 'انتظر حتى تنتهي مزامنة التغييرات المعلقة لهذا التقويم قبل حذفه أو إزالته.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'اشتراكات التقويم'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'أضف تقاويم للقراءة فقط يتم تحديثها من عنوان WebCal آمن.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'إضافة اشتراك تقويم'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'الاسم المحلي'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'عنوان URL للاشتراك'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'أدخل عنوان HTTPS أو webcal. يحتفظ BusyMax بعنوان URL الكامل في التخزين الآمن.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'أدخل عنوان HTTPS أو webcal صالحًا من دون معلومات مستخدم أو جزء.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'اللون المحلي'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => 'استخدم لونًا من ستة أرقام مثل #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'أدخل لونًا سداسيًا عشريًا من ستة أرقام.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'تكرار التحديث'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'تلقائي'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'كل ساعة'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'كل ست ساعات'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'يوميًا'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'المصدر: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'أدخل عنوان URL صالحًا لمعاينة مصدره الآمن.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'اشتراك للقراءة فقط'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'لم يتم التحديث بعد'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'آخر تحديث ناجح: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'التحديث التالي: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'محدّث'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'مشكلة في التحديث: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'تحديث الآن'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'إلغاء الاشتراك'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'إلغاء الاشتراك من «$name»؟'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'يؤدي هذا إلى إزالة الاشتراك المحلي والأحداث المخزنة مؤقتًا. لن يتغير التقويم المنشور.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'إضافة اشتراك'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'فشل اشتراك التقويم: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'الاشتراكات'; @override String get calendarImport => 'استيراد التقويم'; @override String get calendarImportDescription => - 'حدّد ملفًا وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي سيتلقاها.'; + 'حدد ملفًا، وراجع أحداثه، ثم اختر التقويم القابل للكتابة الذي ينبغي أن يستقبلها.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'استيراد ملف ‎.ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'استيراد أحداث التقويم'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'مجموعات أحداث قابلة للاستيراد: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'أحداث غير صالحة: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'تم الاستبعاد عمدًا: $fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => 'لا يوجد تقويم وجهة قابل للكتابة.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'تقويم الوجهة'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'استيراد الأحداث'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'اكتمل الاستيراد'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'تم الاستيراد أو وضعه في قائمة الانتظار: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'تم تخطي التكرارات: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'مجموعات تكرار غير مدعومة: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'تعذّر استيراد ملف التقويم: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 2d2e29fb..4846af29 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -17,7 +17,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Verbinden Sie Google-, Microsoft-, Apple-iCloud-Kalender- oder Nextcloud-Konten.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Fügen Sie alle Konten hinzu, die Sie verwenden möchten. BusyMax synchronisiert unterstützte Kalender, Ereignisse, Aufgabenlisten und Aufgaben aus jedem Konto.'; @override String get onboardingPreferencesStepTitle => 'Systemeinstellungen wählen'; @@ -219,68 +219,68 @@ class AppLocalizationsDe extends AppLocalizations { String get trayOpenBusyMax => 'BusyMax öffnen'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'BusyMax anzeigen'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Neuer Termin…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Neue Aufgabe…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Heute'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Ganztägig'; @override - String get trayNow => 'Now'; + String get trayNow => 'Jetzt'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Kalendertermin'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Termin ohne Titel'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Heute nichts Weiteres'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count Aufgaben heute fällig', + one: '1 Aufgabe heute fällig', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Heutige Agenda öffnen'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Jetzt synchronisieren'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Synchronisierung…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Nicht verbunden'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Noch nicht synchronisiert'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Gerade synchronisiert'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Vor $count Minuten synchronisiert', + one: 'Vor 1 Minute synchronisiert', ); return '$_temp0'; } @@ -290,8 +290,8 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Vor $count Stunden synchronisiert', + one: 'Vor 1 Stunde synchronisiert', ); return '$_temp0'; } @@ -301,17 +301,17 @@ class AppLocalizationsDe extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Vor $count Tagen synchronisiert', + one: 'Vor 1 Tag synchronisiert', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Einstellungen'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'BusyMax beenden'; @override String get agendaLoadMoreOverdue => 'Weitere überfällige Aufgaben laden'; @@ -332,7 +332,7 @@ class AppLocalizationsDe extends AppLocalizations { String get viewYear => 'Jahr'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Tagesübersicht'; @override String get scheduleSettings => 'Zeitplan'; @@ -396,127 +396,128 @@ class AppLocalizationsDe extends AppLocalizations { String get noGuests => 'Keine Gäste'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Erforderlich'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Freiwillig'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Besprechung'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet hinzufügen'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft-Teams-Besprechung hinzufügen'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Onlinebesprechung hinzugefügt'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Antworten anfordern'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Bitten Sie Gäste, auf die Einladung zu antworten.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Gästeliste ausblenden'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Gäste können nicht sehen, wer sonst eingeladen wurde.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Neue Zeitvorschläge zulassen'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Gäste können eine andere Besprechungszeit vorschlagen.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Gäste benachrichtigen?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Diese Besprechung hat Gäste. Sollen beim Speichern Einladungen oder Terminaktualisierungen gesendet werden?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Diese Besprechung hat Gäste. Soll beim Löschen eine Absage gesendet werden?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Aktualisierungen senden'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Absage senden'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Nicht senden'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Besprechung speichern?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft sendet Einladungen oder Terminaktualisierungen an die Gäste.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Besprechung löschen?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft sendet eine Absage an die Gäste.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organisator'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Ihre Antwort'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Gästeantworten'; @override - String get respond => 'Respond'; + String get respond => 'Antworten'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Annehmen'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Mit Vorbehalt'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Ablehnen'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'An Besprechung teilnehmen'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Angenommen'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Mit Vorbehalt'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Abgelehnt'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Antwort ausstehend'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Nicht beantwortet'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organisator'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Ihre Antwort konnte nicht gesendet werden: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => + 'Der Besprechungslink konnte nicht geöffnet werden.'; @override String get description => 'Beschreibung'; @@ -621,7 +622,7 @@ class AppLocalizationsDe extends AppLocalizations { String get visibilityConfidential => 'Vertraulich'; @override - String get sensitivityNormal => 'Normal'; + String get sensitivityNormal => 'Gewöhnlich'; @override String get sensitivityPersonal => 'Persönlich'; @@ -641,7 +642,7 @@ class AppLocalizationsDe extends AppLocalizations { String get taskLists => 'Aufgabenlisten'; @override - String get navigation => 'Navigation'; + String get navigation => 'Bedienung'; @override String get mainMenu => 'Hauptmenü'; @@ -657,7 +658,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Diese Übersicht der Tastenkürzel anzeigen'; @override - String get shortcutGroupNavigation => 'Navigation'; + String get shortcutGroupNavigation => 'Bedienung'; @override String get shortcutNextPeriod => 'Nächster Zeitraum'; @@ -726,7 +727,7 @@ class AppLocalizationsDe extends AppLocalizations { String get apacheLicenseName => 'Apache License 2.0'; @override - String get website => 'Website'; + String get website => 'Webseite'; @override String get sourceCode => 'Quellcode'; @@ -869,7 +870,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Dadurch werden zwischengespeicherte Aufgaben, Kalender, Termine, Erinnerungen und ausstehende Offline-Änderungen von diesem Gerät gelöscht. Nicht synchronisierte Änderungen gehen verloren. Kopien der Kalender, Termine, Aufgabenlisten und Aufgaben beim Anbieter werden nicht gelöscht.'; @override String get revokeGoogleAccess => @@ -895,17 +896,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Aufgabenliste konnte nicht erstellt werden: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Aufgabenliste konnte nicht umbenannt werden: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Aufgabenliste konnte nicht gelöscht werden: $error'; } @override @@ -935,11 +936,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Diese Aufgabenliste ist schreibgeschützt und kann nicht umbenannt werden.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Diese Aufgabenliste kann mit Ihren aktuellen Berechtigungen nicht gelöscht werden.'; @override String get builtInMicrosoftList => 'Integriert'; @@ -950,17 +951,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '\"$title\" aus Google Tasks löschen?'; + return '„$title“ aus Google Tasks löschen?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '„$title“ und alle Aufgaben löschen?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Freigabe von „$title“ für dieses Konto aufheben?'; } @override @@ -1080,7 +1081,7 @@ class AppLocalizationsDe extends AppLocalizations { String get primarySection => 'Primär'; @override - String get statusSection => 'Status'; + String get statusSection => 'Aufgabenstatus'; @override String get openStatus => 'Offen'; @@ -1089,10 +1090,10 @@ class AppLocalizationsDe extends AppLocalizations { String get doneStatus => 'Erledigt'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Aufgabenstatus'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Kein Status'; @override String get taskStatusNeedsAction => 'Handlungsbedarf'; @@ -1101,60 +1102,63 @@ class AppLocalizationsDe extends AppLocalizations { String get taskStatusInProcess => 'In Bearbeitung'; @override - String get taskStatusCompleted => 'Fertiggestellt'; + String get taskStatusCompleted => 'Erledigt'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Abgebrochen'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent% abgeschlossen'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Abschlussdatum'; @override String get priority => 'Priorität'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Keine Priorität'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Priorität $priority · Hoch'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Priorität $priority · Mittel'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Priorität $priority · Niedrig'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'Aufgaben-URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => + 'Geben Sie eine absolute URL einschließlich ihres Schemas ein.'; @override - String get classification => 'Classification'; + String get classification => 'Klassifizierung'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Bei Freigabe die vollständige Aufgabe anzeigen'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Bei Freigabe nur den Belegt-Status anzeigen'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Diese Aufgabe bei Freigabe ausblenden'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Aufgabe anheften'; @override String get notes => 'Notizen'; @@ -1193,75 +1197,75 @@ class AppLocalizationsDe extends AppLocalizations { String get addReminder => 'Erinnerung hinzufügen'; @override - String get reminders => 'Reminders'; + String get reminders => 'Erinnerungen'; @override String get noReminders => 'Keine Erinnerungen'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Erinnerung bearbeiten'; @override - String get beforeTaskStarts => 'Bevor die Aufgabe startet'; + String get beforeTaskStarts => 'Vor Beginn der Aufgabe'; @override - String get beforeTaskDue => 'Bevor die Aufgabe fällig ist'; + String get beforeTaskDue => 'Vor Fälligkeit der Aufgabe'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Nach Beginn der Aufgabe'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Nach Fälligkeit der Aufgabe'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Relativ zum Startdatum der Aufgabe'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Relativ zum Fälligkeitsdatum der Aufgabe'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Tageszeit'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Zu einem Datum und einer Uhrzeit'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Menge'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Einheit'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Sekunden'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minuten'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Stunden'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Tage'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Wochen'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Zu Beginn der Aufgabe'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Zum Fälligkeitszeitpunkt der Aufgabe'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Dieser Erinnerungstyp bleibt erhalten, aber seine Zeit kann nicht bearbeitet werden.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Verknüpfte Erinnerungen behalten?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Für dieses Datum gibt es $count verknüpfte Erinnerungen. Sollen sie ihr aktuelles Datum und ihre aktuelle Uhrzeit behalten?'; } @override @@ -1304,86 +1308,86 @@ class AppLocalizationsDe extends AppLocalizations { String get repeatYearly => 'Jährlich'; @override - String get repeatEvery => 'Wiederhole jeden'; + String get repeatEvery => 'Intervall'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Wiederholen an'; @override String get repeatEnd => 'Wiederholung beenden'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Nie'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'An einem Datum'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Nach einer Anzahl von Wiederholungen'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Wiederholungen'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Tage des Monats'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Monate'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Wochentagsposition'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Bestimmte Tage'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Erste'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Zweite'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Dritte'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Vierte'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Fünfte'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Vorletzte'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Letzte'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Tag'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Wochentag'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Wochenendtag'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Alle $count Tage'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Alle $count Wochen'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Alle $count Monate'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Alle $count Jahre'; } @override @@ -1393,22 +1397,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return 'am Tag $days'; + return 'am $days. Tag'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'am $ordinal $days'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'in den Monaten $months'; } @override String repeatTimesSummary(int count) { - return '$count mal'; + return '$count-mal'; } @override @@ -1418,7 +1422,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Diese Wiederholungsregel verwendet Optionen, die dieser Editor nicht ändert.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1432,7 +1436,7 @@ class AppLocalizationsDe extends AppLocalizations { String get importanceLow => 'Niedrig'; @override - String get importanceNormal => 'Normal'; + String get importanceNormal => 'Mittel'; @override String get importanceHigh => 'Hoch'; @@ -1447,7 +1451,7 @@ class AppLocalizationsDe extends AppLocalizations { String get dueGroup => 'Fällig'; @override - String get startGroup => 'Start'; + String get startGroup => 'Beginn'; @override String get reminderGroup => 'Erinnerung'; @@ -1481,11 +1485,11 @@ class AppLocalizationsDe extends AppLocalizations { String get duplicateTask => 'Aufgabe duplizieren'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Aufgabe dupliziert.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Aufgabe konnte nicht dupliziert werden: $error'; } @override @@ -1524,7 +1528,7 @@ class AppLocalizationsDe extends AppLocalizations { String get parent => 'Übergeordnete Aufgabe'; @override - String get position => 'Position'; + String get position => 'Reihenfolge'; @override String get webLink => 'Weblink'; @@ -1610,7 +1614,7 @@ class AppLocalizationsDe extends AppLocalizations { String get notificationDetailPrivate => 'Privat'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Standard'; @override String get quietHours => 'Ruhezeiten'; @@ -1635,7 +1639,7 @@ class AppLocalizationsDe extends AppLocalizations { String get theme => 'Design'; @override - String get themeSystem => 'System'; + String get themeSystem => 'Systemstandard'; @override String get themeLight => 'Hell'; @@ -1797,7 +1801,7 @@ class AppLocalizationsDe extends AppLocalizations { String get notificationSnoozeAction => 'In 10 Minuten erinnern'; @override - String get notificationDismissAction => 'Verwerfen'; + String get notificationDismissAction => 'Schließen'; @override String get notificationDetailsHidden => @@ -1854,14 +1858,14 @@ class AppLocalizationsDe extends AppLocalizations { String get noLocationsFound => 'Keine Orte gefunden'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Dieses Feld ist erforderlich.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Verbinden Sie Kalender und Aufgaben mit einem dieser Anbieter.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple-iCloud-Kalender'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1870,194 +1874,196 @@ class AppLocalizationsDe extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud-Aufgaben'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple-iCloud-Kalenderkonto hinzufügen'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud-Konto hinzufügen'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => + 'Verbindung mit Apple iCloud wird hergestellt…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Warten auf die Nextcloud-Autorisierung…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple-iCloud-Kalender verbinden'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-Mail des Apple-Accounts'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App-spezifisches Passwort'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Erstellen Sie nach dem Aktivieren der Zwei-Faktor-Authentifizierung für Ihren Apple-Account ein app-spezifisches Passwort.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Durch das Zurücksetzen Ihres Apple-Account-Passworts werden app-spezifische Passwörter widerrufen.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud verbinden'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud-Server oder CalDAV-Adresse'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Geben Sie die URL Ihres Nextcloud-Servers ein oder fügen Sie die aus Nextcloud kopierte primäre CalDAV-Adresse ein.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax öffnet Ihren Browser. Genehmigen Sie dort den Zugriff und kehren Sie anschließend zu BusyMax zurück.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Verbinden'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Verbindung abbrechen'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Das Konto wurde lokal entfernt, aber das Nextcloud-App-Passwort konnte nicht widerrufen werden.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Kalender- und Aufgabendaten werden für die Offline-Nutzung lokal zwischengespeichert.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Verbinden Sie dieses Konto erneut, um die Synchronisierung fortzusetzen.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Dieses Konto ist vorübergehend nicht verfügbar.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Die Serverberechtigungen wurden geändert. Ausstehende Bearbeitungen sind pausiert.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Dieser Server oder dieses Anbieterprofil wird nicht unterstützt.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Kalender und Aufgabenlisten'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Kalendertermine'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Aufgaben'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Schreibgeschützt'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Lokal ausstehend'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Durch Konflikt blockiert'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bis zur erneuten Verbindung blockiert'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Vorgang fehlgeschlagen'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Serverversion behalten'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Lokale Änderung prüfen und erneut anwenden'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Als neues Element duplizieren'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Verbindungsstatus'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Verbunden'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Verbindung wird hergestellt…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Abgemeldet'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Letzte erfolgreiche Synchronisierung: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Noch nicht synchronisiert'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Kalender und Aufgabenlisten aktualisieren'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Serveradresse: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Terminkalender'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Aufgabenliste'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Termine und Aufgaben'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Beschreibbar'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Freigegeben'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Zuletzt synchronisiert: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Synchronisierungsproblem: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Synchronisierungskonflikte'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Server geändert: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Lokale Änderung: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => + 'Der Konflikt konnte nicht gelöst werden.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Bereich des wiederkehrenden Termins'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Gesamte Serie'; @override - String get singleOccurrence => 'Dieses Ereignis'; + String get singleOccurrence => 'Dieser Termin'; @override - String get thisAndFollowingEvents => 'Dieses und folgende Ereignisse'; + String get thisAndFollowingEvents => 'Dieser und die folgenden Termine'; @override String get thisAndFutureUnavailable => @@ -2065,19 +2071,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get thisAndFutureMoveUnavailable => - 'Dieses und die folgenden Ereignisse können nicht sicher verschoben werden. Wählen Sie dieses Ereignis oder die gesamte Serie.'; + 'Dieser Termin und die folgenden Termine können nicht sicher verschoben werden. Wählen Sie diesen Termin oder die gesamte Serie.'; @override String get entireSeriesMoveUnavailable => - 'Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen dieses Ereignis.'; + 'Die Wiederholungsregel ist lokal nicht verfügbar. Verschieben Sie stattdessen diesen Termin.'; @override String get copyEventAndDeleteOriginal => - 'Ereignis kopieren und Original löschen?'; + 'Termin kopieren und Original löschen?'; @override String copyEventMoveWarning(String source, String destination) { - return 'BusyMax kann dieses Ereignis nicht direkt von $source nach $destination verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Ereignis-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.'; + return 'BusyMax kann diesen Termin nicht direkt von $source nach $destination verschieben. Zuerst wird die Kopie erstellt; das Original wird erst nach erfolgreichem Kopieren gelöscht. Termin-IDs ändern sich; Antwortstatus von Teilnehmern können zurückgesetzt und Einladungen oder Absagen versendet werden; Konferenzlinks, Anhänge, Erinnerungen, anbieterspezifische Felder und Wiederholungsausnahmen werden möglicherweise nicht übernommen.'; } @override @@ -2085,7 +2091,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Wählen Sie, ob diese Änderung für die gesamte Serie, nur diesen Termin oder diesen und die folgenden Termine gilt.'; @override String get taskDueBeforeStart => @@ -2093,7 +2099,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get taskStartDueTimeModeMismatch => - 'Lege für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder mache die Aufgabe ganztägig.'; + 'Legen Sie für Beginn und Fälligkeit jeweils eine Uhrzeit fest oder machen Sie die Aufgabe ganztägig.'; @override String deleteCalendarConfirmation(String title) { @@ -2119,182 +2125,184 @@ class AppLocalizationsDe extends AppLocalizations { @override String get calendarCannotRemove => - 'Dieser Kalender kann in diesem Konto weder gelöscht noch entfernt werden.'; + 'Dieser Kalender kann nicht gelöscht oder aus diesem Konto entfernt werden.'; @override String get calendarPendingChangesPreventRemoval => 'Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Kalenderabonnements'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Fügen Sie schreibgeschützte Kalender hinzu, die über eine sichere WebCal-URL aktualisiert werden.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Kalenderabonnement hinzufügen'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Lokaler Name'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'Abonnement-URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Geben Sie eine HTTPS- oder webcal-URL ein. BusyMax speichert die vollständige URL sicher.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Geben Sie eine gültige HTTPS- oder webcal-URL ohne Benutzerinformationen oder Fragment ein.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Lokale Farbe'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + 'Verwenden Sie eine sechsstellige Farbe wie #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Geben Sie eine sechsstellige Hexadezimalfarbe ein.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Aktualisierungshäufigkeit'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Automatisch'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Stündlich'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Alle sechs Stunden'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Täglich'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Quelle: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Geben Sie eine gültige URL ein, um ihre sichere Quelle anzuzeigen.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Schreibgeschütztes Abonnement'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Noch nicht aktualisiert'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Letzte erfolgreiche Aktualisierung: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Nächste Aktualisierung: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Aktuell'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Aktualisierungsproblem: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Jetzt aktualisieren'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Abbestellen'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '„$name“ abbestellen?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Dadurch werden das lokale Abonnement und die zwischengespeicherten Termine entfernt. Der veröffentlichte Kalender wird nicht geändert.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Abonnement hinzufügen'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Kalenderabonnement fehlgeschlagen: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Abonnements'; @override - String get calendarImport => 'Kalender importieren'; + String get calendarImport => 'Kalenderimport'; @override String get calendarImportDescription => - 'Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie dann den beschreibbaren Zielkalender.'; + 'Wählen Sie eine Datei aus, prüfen Sie ihre Termine und wählen Sie anschließend den beschreibbaren Kalender aus, der sie aufnehmen soll.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => '‎.ics-Datei importieren'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Kalendertermine importieren'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Importierbare Terminserien: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Ungültige Termine: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Absichtlich ausgelassen: $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'Kein beschreibbarer Zielkalender verfügbar.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Zielkalender'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Termine importieren'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Import abgeschlossen'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Importiert oder in die Warteschlange gestellt: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Übersprungene Duplikate: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Nicht unterstützte Wiederholungsserien: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Kalenderdatei konnte nicht importiert werden: $error'; } @override - String get networkOffline => 'Offline'; + String get networkOffline => 'Ohne Verbindung'; @override String get networkOfflineDescription => diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 0a32ab7b..45c77304 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1090,7 +1090,7 @@ class AppLocalizationsEn extends AppLocalizations { String get taskStatusNeedsAction => 'Needs action'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'In progress'; @override String get taskStatusCompleted => 'Completed'; @@ -1296,7 +1296,7 @@ class AppLocalizationsEn extends AppLocalizations { String get repeatYearly => 'Yearly'; @override - String get repeatEvery => 'Repeat every'; + String get repeatEvery => 'Interval'; @override String get repeatOn => 'Repeat on'; @@ -2067,7 +2067,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose which events this change applies to.'; + 'Choose whether this change applies to the entire series, only this occurrence, or this and following events.'; @override String get taskDueBeforeStart => 'Due must not be before start.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 52a7972b..cfd1e7c6 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -17,7 +17,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Conecta cuentas de Google, Microsoft, Calendario de Apple iCloud o Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Añade todas las cuentas que quieras usar. BusyMax sincroniza los calendarios, eventos, listas de tareas y tareas compatibles de cada cuenta.'; @override String get onboardingPreferencesStepTitle => 'Elegir ajustes del sistema'; @@ -127,7 +127,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String calendarColorOption(int number) { - return 'Color $number'; + return 'Opción de color $number'; } @override @@ -221,68 +221,68 @@ class AppLocalizationsEs extends AppLocalizations { String get trayOpenBusyMax => 'Abrir BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Mostrar BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Evento nuevo…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Tarea nueva…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Hoy'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Todo el día'; @override - String get trayNow => 'Now'; + String get trayNow => 'Ahora'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Evento del calendario'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Evento sin título'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Nada más hoy'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count tareas vencen hoy', + one: '1 tarea vence hoy', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Abrir la agenda de hoy'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Sincronizar ahora'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Sincronizando…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'No conectado'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Aún no sincronizado'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Sincronizado ahora mismo'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Sincronizado hace $count minutos', + one: 'Sincronizado hace 1 minuto', ); return '$_temp0'; } @@ -292,8 +292,8 @@ class AppLocalizationsEs extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Sincronizado hace $count horas', + one: 'Sincronizado hace 1 hora', ); return '$_temp0'; } @@ -303,17 +303,17 @@ class AppLocalizationsEs extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Sincronizado hace $count días', + one: 'Sincronizado hace 1 día', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Configuración'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Salir de BusyMax'; @override String get agendaLoadMoreOverdue => 'Cargar más tareas vencidas'; @@ -334,7 +334,7 @@ class AppLocalizationsEs extends AppLocalizations { String get viewYear => 'Año'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vista de agenda'; @override String get scheduleSettings => 'Agenda'; @@ -395,130 +395,130 @@ class AppLocalizationsEs extends AppLocalizations { String get guests => 'Invitados'; @override - String get noGuests => 'Sin invitados'; + String get noGuests => 'No hay invitados'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obligatorio'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Opcional'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Reunión'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Añadir Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Añadir reunión de Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Reunión en línea añadida'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Solicitar respuestas'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Pide a los invitados que respondan a la invitación.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Ocultar la lista de invitados'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Los invitados no pueden ver quién más ha sido invitado.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Permitir nuevas propuestas de horario'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Los invitados pueden sugerir otra hora para la reunión.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '¿Notificar a los invitados?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Esta reunión tiene invitados. ¿Enviar invitaciones o actualizaciones del evento al guardarla?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Esta reunión tiene invitados. ¿Enviar una cancelación al eliminarla?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Enviar actualizaciones'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Enviar cancelación'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'No enviar'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '¿Guardar la reunión?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft enviará invitaciones o actualizaciones del evento a los invitados.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '¿Eliminar la reunión?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft enviará una cancelación a los invitados.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organizador'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Tu respuesta'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Respuestas de los invitados'; @override - String get respond => 'Respond'; + String get respond => 'Responder'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Aceptar'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provisional'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Rechazar'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Unirse a la reunión'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Aceptada'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provisional'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Rechazada'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Esperando respuesta'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Sin respuesta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organizador'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'No se pudo enviar tu respuesta: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'No se pudo abrir el enlace de la reunión.'; @override String get description => 'Descripción'; @@ -623,10 +623,10 @@ class AppLocalizationsEs extends AppLocalizations { String get visibilityConfidential => 'Confidencial'; @override - String get sensitivityNormal => 'Normal'; + String get sensitivityNormal => 'Habitual'; @override - String get sensitivityPersonal => 'Personal'; + String get sensitivityPersonal => 'Personalizada'; @override String get tasks => 'Tareas'; @@ -652,7 +652,7 @@ class AppLocalizationsEs extends AppLocalizations { String get keyboardShortcuts => 'Atajos de teclado'; @override - String get shortcutGroupGeneral => 'General'; + String get shortcutGroupGeneral => 'Atajos generales'; @override String get shortcutKeyboardShortcutsDescription => @@ -866,12 +866,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String removeAccountTitle(String account) { - return '¿Eliminar $account de BusyMax?'; + return '¿Quitar $account de BusyMax?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Esto elimina de este dispositivo las tareas, calendarios, eventos, recordatorios y cambios sin conexión pendientes almacenados en caché. Se perderán los cambios no sincronizados. No se eliminan las copias de calendarios, eventos, listas de tareas ni tareas del proveedor.'; @override String get revokeGoogleAccess => @@ -893,21 +893,21 @@ class AppLocalizationsEs extends AppLocalizations { 'La cuenta se eliminó de este dispositivo, pero BusyMax no pudo revocar su acceso a tu cuenta de Google. Puedes revocarlo desde tu cuenta de Google.'; @override - String get newTaskList => 'Nueva lista de tareas'; + String get newTaskList => 'Lista de tareas nueva'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'No se pudo crear la lista de tareas: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'No se pudo cambiar el nombre de la lista de tareas: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'No se pudo eliminar la lista de tareas: $error'; } @override @@ -933,15 +933,15 @@ class AppLocalizationsEs extends AppLocalizations { String get deleteList => 'Eliminar lista'; @override - String get unshare => 'No compartir'; + String get unshare => 'Dejar de compartir'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Esta lista de tareas es de solo lectura y no se puede cambiar su nombre.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'No se puede eliminar esta lista de tareas con tus permisos actuales.'; @override String get builtInMicrosoftList => 'Integrada'; @@ -952,17 +952,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '¿Eliminar \"$title\" de Google Tasks?'; + return '¿Eliminar «$title» de Google Tasks?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '¿Eliminar «$title» y todas sus tareas?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '¿Dejar de compartir «$title» con esta cuenta?'; } @override @@ -1087,75 +1087,78 @@ class AppLocalizationsEs extends AppLocalizations { String get openStatus => 'Abierta'; @override - String get doneStatus => 'Completada'; + String get doneStatus => 'Hecha'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Estado'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Sin estado'; @override String get taskStatusNeedsAction => 'Necesita una acción'; @override - String get taskStatusInProcess => 'En proceso'; + String get taskStatusInProcess => 'En curso'; @override String get taskStatusCompleted => 'Completada'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Cancelada'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent% completada'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Fecha de finalización'; @override String get priority => 'Prioridad'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Sin prioridad'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Prioridad $priority · Alta'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Prioridad $priority · Media'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Prioridad $priority · Baja'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL de la tarea'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => + 'Introduce una URL absoluta, incluido su esquema.'; @override - String get classification => 'Classification'; + String get classification => 'Clasificación'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Al compartirla, mostrar la tarea completa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Al compartirla, mostrar solo la disponibilidad'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Al compartirla, ocultar esta tarea'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Fijar tarea'; @override String get notes => 'Notas'; @@ -1194,82 +1197,85 @@ class AppLocalizationsEs extends AppLocalizations { String get addReminder => 'Añadir recordatorio'; @override - String get reminders => 'Reminders'; + String get reminders => 'Recordatorios'; @override - String get noReminders => 'Sin recordatorio'; + String get noReminders => 'Sin recordatorios'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Editar recordatorio'; @override - String get beforeTaskStarts => 'Antes de empezar la tarea'; + String get beforeTaskStarts => 'Antes de que empiece la tarea'; @override - String get beforeTaskDue => 'Antes de terminar la tarea'; + String get beforeTaskDue => 'Antes del vencimiento de la tarea'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Después de que empiece la tarea'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Después del vencimiento de la tarea'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => + 'En relación con la fecha de inicio de la tarea'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => + 'En relación con la fecha de vencimiento de la tarea'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Hora del día'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'En una fecha y hora'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Cantidad'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unidad'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Segundos'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minutos'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Horas'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Días'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Semanas'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Al comenzar la tarea'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Al vencer la tarea'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Este tipo de recordatorio se conserva, pero no se puede editar su hora.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => + '¿Conservar los recordatorios relacionados?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Esta fecha tiene $count recordatorios relacionados. ¿Quieres conservarlos en su fecha y hora actuales?'; } @override String get discardRelatedReminders => 'Descartar recordatorios'; @override - String get keepRelatedReminders => 'Mantener recordatorios'; + String get keepRelatedReminders => 'Conservar recordatorios'; @override String get addGuest => 'Añadir invitado'; @@ -1305,101 +1311,101 @@ class AppLocalizationsEs extends AppLocalizations { String get repeatYearly => 'Anual'; @override - String get repeatEvery => 'Repetir cada'; + String get repeatEvery => 'Intervalo'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Repetir en'; @override String get repeatEnd => 'Finalizar repetición'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Nunca'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'En una fecha'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Después de un número de repeticiones'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Repeticiones'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Días del mes'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Meses'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Posición del día de la semana'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Días específicos'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Primero'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Segundo'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Tercero'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Cuarto'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Quinto'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Penúltimo'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Último'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Día'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Día laborable'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Día de fin de semana'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Cada $count días'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Cada $count semanas'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Cada $count meses'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Cada $count años'; } @override String repeatOnDaysSummary(String days) { - return 'dentro de $days'; + return 'los $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'en día $days'; + return 'el día $days'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'el $ordinal $days'; } @override @@ -1414,12 +1420,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String repeatUntilSummary(String date) { - return 'hasta el $date'; + return 'hasta $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Esta regla de repetición usa opciones que este editor no modifica.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1433,7 +1439,7 @@ class AppLocalizationsEs extends AppLocalizations { String get importanceLow => 'Baja'; @override - String get importanceNormal => 'Normal'; + String get importanceNormal => 'Media'; @override String get importanceHigh => 'Alta'; @@ -1482,11 +1488,11 @@ class AppLocalizationsEs extends AppLocalizations { String get duplicateTask => 'Duplicar tarea'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tarea duplicada.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'No se pudo duplicar la tarea: $error'; } @override @@ -1611,7 +1617,7 @@ class AppLocalizationsEs extends AppLocalizations { String get notificationDetailPrivate => 'Privado'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Estándar'; @override String get quietHours => 'Horario de silencio'; @@ -1763,7 +1769,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'Se bloqueó un cambio local pendiente. $summary'; + return 'Se ha bloqueado un cambio local pendiente. $summary'; } @override @@ -1799,7 +1805,7 @@ class AppLocalizationsEs extends AppLocalizations { String get notificationSnoozeAction => 'Posponer 10 minutos'; @override - String get notificationDismissAction => 'Descartar'; + String get notificationDismissAction => 'Cerrar'; @override String get notificationDetailsHidden => @@ -1852,17 +1858,17 @@ class AppLocalizationsEs extends AppLocalizations { String get searchLocations => 'Buscar ubicaciones'; @override - String get noLocationsFound => 'No se encontraron ubicaciones'; + String get noLocationsFound => 'No se han encontrado ubicaciones'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Este campo es obligatorio.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Conecta calendarios y tareas de uno de estos proveedores.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendario de Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1871,188 +1877,190 @@ class AppLocalizationsEs extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Tareas de Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Añadir cuenta del Calendario de Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Añadir cuenta de Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Conectando con Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Esperando la autorización de Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => + 'Conectar el Calendario de Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Correo de la cuenta de Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Contraseña específica de la app'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Crea una contraseña específica de la app después de activar la autenticación de dos factores en tu cuenta de Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Restablecer la contraseña de tu cuenta de Apple revoca las contraseñas específicas de las apps.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Conectar Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Servidor de Nextcloud o dirección CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Introduce la URL de tu servidor Nextcloud o pega la dirección CalDAV principal copiada de Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax abrirá el navegador. Aprueba allí el acceso y vuelve a BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Conectar'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Cancelar conexión'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'La cuenta se quitó localmente, pero no se pudo revocar la contraseña de app de Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Los datos del calendario y las tareas se almacenan localmente para usarlos sin conexión.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Vuelve a conectar esta cuenta para reanudar la sincronización.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Esta cuenta no está disponible temporalmente.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Han cambiado los permisos del servidor. Las ediciones pendientes están en pausa.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Este servidor o perfil de proveedor no es compatible.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendarios y listas de tareas'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Eventos del calendario'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tareas'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Solo lectura'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Pendiente localmente'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloqueado por un conflicto'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloqueado hasta volver a conectar'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'La operación ha fallado'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Conservar la versión del servidor'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Revisar y volver a aplicar el cambio local'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Duplicar como elemento nuevo'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Estado de conexión'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Conectado'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Conectando…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Sesión cerrada'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Última sincronización correcta: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Aún no se ha sincronizado'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Actualizar calendarios y listas de tareas'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Servidor: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendario de eventos'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Lista de tareas'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Eventos y tareas'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Editable'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Compartido'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Última sincronización: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problema de sincronización: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflictos de sincronización'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Cambio en el servidor: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Edición local: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'No se pudo resolver el conflicto.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Ámbito del evento recurrente'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Serie completa'; @override String get singleOccurrence => 'Este evento'; @@ -2085,7 +2093,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Elige si este cambio se aplica a toda la serie, solo a este evento o a este y los siguientes eventos.'; @override String get taskDueBeforeStart => @@ -2126,171 +2134,173 @@ class AppLocalizationsEs extends AppLocalizations { 'Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Suscripciones de calendarios'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Añade calendarios de solo lectura que se actualicen desde una URL WebCal segura.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Añadir suscripción de calendario'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Nombre local'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'URL de la suscripción'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Introduce una URL HTTPS o webcal. BusyMax guarda la URL completa en un almacenamiento seguro.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Introduce una URL HTTPS o webcal válida sin información de usuario ni fragmento.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Color local'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + 'Usa un color de seis dígitos, como #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Introduce un color hexadecimal de seis dígitos.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Frecuencia de actualización'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Automática'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Cada hora'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Cada seis horas'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Diaria'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Origen: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Introduce una URL válida para previsualizar su origen seguro.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Suscripción de solo lectura'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Aún no se ha actualizado'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Última actualización correcta: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Próxima actualización: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Actualizada'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Problema de actualización: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Actualizar ahora'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Cancelar suscripción'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '¿Cancelar la suscripción a «$name»?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Esto elimina la suscripción local y sus eventos almacenados en caché. El calendario publicado no cambia.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Añadir suscripción'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'La suscripción al calendario ha fallado: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Suscripciones'; @override - String get calendarImport => 'Importar calendario'; + String get calendarImport => 'Importación de calendario'; @override String get calendarImportDescription => - 'Selecciona un archivo, revisa sus eventos y elige el calendario editable que los recibirá.'; + 'Selecciona un archivo, revisa sus eventos y elige el calendario editable que debe recibirlos.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Importar archivo .ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Importar eventos del calendario'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Conjuntos de eventos importables: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Eventos no válidos: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Omitidos intencionadamente: $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'No hay ningún calendario de destino editable disponible.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Calendario de destino'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Importar eventos'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Importación completada'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Importados o en cola: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Duplicados omitidos: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Conjuntos de repeticiones no compatibles: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'No se pudo importar el archivo de calendario: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index cef60c67..daa0b0c0 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -17,7 +17,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Ühenda Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Lisa kõik kontod, mida soovid kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.'; @override String get onboardingPreferencesStepTitle => 'Süsteemiseadete valimine'; @@ -219,68 +219,68 @@ class AppLocalizationsEt extends AppLocalizations { String get trayOpenBusyMax => 'Ava BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Näita BusyMaxi'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Uus sündmus…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Uus ülesanne…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Täna'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Kogu päev'; @override - String get trayNow => 'Now'; + String get trayNow => 'Praegu'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Kalendrisündmus'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Pealkirjata sündmus'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Täna pole rohkem midagi'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: 'Täna on tähtajaks $count ülesannet', + one: 'Täna on tähtajaks 1 ülesanne', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Ava tänane päevakava'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Sünkrooni kohe'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Sünkroonimine…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Pole ühendatud'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Veel sünkroonimata'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Viimati sünkrooniti just nüüd'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Viimati sünkrooniti $count minutit tagasi', + one: 'Viimati sünkrooniti 1 minut tagasi', ); return '$_temp0'; } @@ -290,8 +290,8 @@ class AppLocalizationsEt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Viimati sünkrooniti $count tundi tagasi', + one: 'Viimati sünkrooniti 1 tund tagasi', ); return '$_temp0'; } @@ -301,17 +301,17 @@ class AppLocalizationsEt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Viimati sünkrooniti $count päeva tagasi', + one: 'Viimati sünkrooniti 1 päev tagasi', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Seaded'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Välju BusyMaxist'; @override String get agendaLoadMoreOverdue => 'Laadi veel tähtaja ületanud ülesandeid'; @@ -396,127 +396,125 @@ class AppLocalizationsEt extends AppLocalizations { String get noGuests => 'Külalisi pole'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Nõutud'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Valikuline'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Koosolek'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Lisa Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Lisa Microsoft Teamsi koosolek'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Veebikoosolek lisatud'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Küsi vastuseid'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => 'Palu külalistel kutsele vastata.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Peida külaliste loend'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => 'Külalised ei näe, keda veel kutsuti.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Luba uusi ajapakkumisi'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Külalised saavad soovitada muud koosolekuaega.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Kas teavitada külalisi?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Sellel koosolekul on külalised. Kas saata salvestamisel kutsed või sündmuse uuendused?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Sellel koosolekul on külalised. Kas saata kustutamisel tühistus?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Saada uuendused'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Saada tühistus'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Ära saada'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Kas salvestada koosolek?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft saadab külalistele kutsed või sündmuse uuendused.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Kas kustutada koosolek?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft saadab külalistele tühistuse.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Korraldaja'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Sinu vastus'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Külaliste vastused'; @override - String get respond => 'Respond'; + String get respond => 'Vasta'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Nõustu'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Esialgne'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Keeldu'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Liitu koosolekuga'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Nõustutud'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Esialgne'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Keeldutud'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Vastust oodatakse'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Vastus puudub'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Korraldaja'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Sinu vastust ei saanud saata: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Koosolekulinki ei saanud avada.'; @override String get description => 'Kirjeldus'; @@ -866,7 +864,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'See kustutab sellest seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja ootel võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Teenusepakkuja kalendrite, sündmuste, ülesandeloendite ja ülesannete koopiaid ei kustutata.'; @override String get revokeGoogleAccess => @@ -892,17 +890,17 @@ class AppLocalizationsEt extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Ülesandeloendi loomine ebaõnnestus: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Ülesandeloendi ümbernimetamine ebaõnnestus: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Ülesandeloendi kustutamine ebaõnnestus: $error'; } @override @@ -929,15 +927,15 @@ class AppLocalizationsEt extends AppLocalizations { String get deleteList => 'Kustuta loend'; @override - String get unshare => 'Unshare'; + String get unshare => 'Lõpeta jagamine'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'See ülesandeloend on kirjutuskaitstud ja seda ei saa ümber nimetada.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Seda ülesandeloendit ei saa praeguste õigustega kustutada.'; @override String get builtInMicrosoftList => 'Sisseehitatud'; @@ -953,12 +951,12 @@ class AppLocalizationsEt extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Kas kustutada „$title” ja kõik selle ülesanded?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Kas lõpetada „$title” jagamine selle kontoga?'; } @override @@ -1083,75 +1081,75 @@ class AppLocalizationsEt extends AppLocalizations { String get openStatus => 'Pooleli'; @override - String get doneStatus => 'Valmis'; + String get doneStatus => 'Tehtud'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Olek'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Olek puudub'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'Vajab tegevust'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'Töös'; @override - String get taskStatusCompleted => 'Completed'; + String get taskStatusCompleted => 'Lõpetatud'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Tühistatud'; @override String completionPercent(int percent) { - return '$percent% completed'; + return 'Lõpetatud $percent%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Lõpetamise kuupäev'; @override - String get priority => 'Priority'; + String get priority => 'Prioriteet'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Prioriteet puudub'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Prioriteet $priority · kõrge'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Prioriteet $priority · keskmine'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Prioriteet $priority · madal'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'Ülesande URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Sisesta absoluutne URL koos skeemiga.'; @override - String get classification => 'Classification'; + String get classification => 'Klassifikatsioon'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'Jagamisel näita kogu ülesannet'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'Jagamisel näita ainult hõivatust'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Jagamisel peida see ülesanne'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Kinnita ülesanne'; @override String get notes => 'Märkmed'; @@ -1190,82 +1188,82 @@ class AppLocalizationsEt extends AppLocalizations { String get addReminder => 'Lisa meeldetuletus'; @override - String get reminders => 'Reminders'; + String get reminders => 'Meeldetuletused'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Meeldetuletusi pole'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Muuda meeldetuletust'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Enne ülesande algust'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Enne ülesande tähtaega'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Pärast ülesande algust'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Pärast ülesande tähtaega'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Ülesande alguskuupäeva suhtes'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Ülesande tähtaja suhtes'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Kellaaeg'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Kuupäeval ja kellaajal'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Kogus'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Ühik'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Sekundit'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minutit'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Tundi'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Päeva'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Nädalat'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Ülesande alguses'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Ülesande tähtajal'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'See meeldetuletuse tüüp säilitatakse, kuid selle aega ei saa muuta.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Kas säilitada seotud meeldetuletused?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Sellel kuupäeval on $count seotud meeldetuletust. Kas säilitada need praegusel kuupäeval ja kellaajal?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Hülga meeldetuletused'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Säilita meeldetuletused'; @override String get addGuest => 'Lisa külaline'; @@ -1301,121 +1299,121 @@ class AppLocalizationsEt extends AppLocalizations { String get repeatYearly => 'Iga aasta'; @override - String get repeatEvery => 'Repeat every'; + String get repeatEvery => 'Kordusintervall'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Kordub'; @override - String get repeatEnd => 'End repeat'; + String get repeatEnd => 'Lõpeta kordus'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Mitte kunagi'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'Kuupäeval'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Pärast korduste arvu täitumist'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Korduste arv'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Kuu päevad'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Kuud'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Nädalapäeva järjekord'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Kindlad päevad'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Esimene'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Teine'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Kolmas'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Neljas'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Viies'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Eelviimane'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Viimane'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Päev'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Tööpäev'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Nädalavahetuse päev'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Iga $count päeva järel'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Iga $count nädala järel'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Iga $count kuu järel'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Iga $count aasta järel'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'päevadel $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return 'kuu $days. päeval'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$ordinal $days'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'kuudel $months'; } @override String repeatTimesSummary(int count) { - return '$count times'; + return '$count korda'; } @override String repeatUntilSummary(String date) { - return 'until $date'; + return 'kuni kuupäevani $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'See kordusreegel kasutab valikuid, mida see redaktor ei muuda.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1475,21 +1473,21 @@ class AppLocalizationsEt extends AppLocalizations { String get subtasks => 'Alamülesanded'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Loo ülesandest koopia'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Ülesanne dubleeriti.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Ülesande dubleerimine ebaõnnestus: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => 'Peida alamülesanded'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'Peida lõpetatud alamülesanded'; @override String get moveToTop => 'Teisalda kõige üles'; @@ -1788,7 +1786,7 @@ class AppLocalizationsEt extends AppLocalizations { String get notificationSnoozeAction => 'Lükka 10 minutit edasi'; @override - String get notificationDismissAction => 'Loobu'; + String get notificationDismissAction => 'Sulge'; @override String get notificationDetailsHidden => @@ -1844,14 +1842,14 @@ class AppLocalizationsEt extends AppLocalizations { String get noLocationsFound => 'Asukohti ei leitud'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'See väli on kohustuslik.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Ühenda kalendrid ja ülesanded ühega neist teenusepakkujatest.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloudi kalender'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1860,188 +1858,188 @@ class AppLocalizationsEt extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloudi ülesanded'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Lisa Apple iCloud Calendari konto'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Lisa Nextcloudi konto'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloudiga ühendamine…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloudi autoriseerimise ootel…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Ühenda Apple iCloud Calendar'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple’i konto e-post'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Rakendusepõhine parool'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Loo rakendusepõhine parool pärast Apple’i konto kaheastmelise autentimise lubamist.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple’i konto parooli lähtestamine tühistab rakendusepõhised paroolid.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Ühenda Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloudi server või CalDAV-aadress'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Sisesta Nextcloudi serveri URL või kleebi Nextcloudist kopeeritud peamine CalDAV-aadress.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax avab brauseri. Kinnita juurdepääs seal ja naase seejärel BusyMaxi.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Ühenda'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Tühista ühendus'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Konto eemaldati kohalikult, kuid Nextcloudi rakenduse parooli ei saanud tühistada.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Kalendri- ja ülesandeandmed salvestatakse võrguühenduseta kasutamiseks kohapeal.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Sünkroonimise jätkamiseks ühenda see konto uuesti.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'See konto pole ajutiselt saadaval.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Serveriõigused muutusid. Ootel muudatused on peatatud.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Seda serverit või teenusepakkuja profiili ei toetata.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Kalendrid ja ülesandeloendid'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Kalendrisündmused'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Ülesanded'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Kirjutuskaitstud'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Kohapeal ootel'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Konflikti tõttu blokeeritud'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Blokeeritud kuni taasühendamiseni'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Toiming nurjus'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Säilita serveri versioon'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Vaata kohalik muudatus üle ja rakenda uuesti'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Dubleeri uue üksusena'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Ühenduse olek'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Ühendatud'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Ühendamine…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Välja logitud'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Viimane õnnestunud sünkroonimine: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Veel sünkroonimata'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Värskenda kalendreid ja ülesandeloendeid'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Serveri aadress: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Sündmuste kalender'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Ülesandeloend'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Sündmused ja ülesanded'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Kirjutatav'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Jagatud'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Viimati sünkroonitud: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Sünkroonimisprobleem: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Sünkroonimiskonfliktid'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Server muutis: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Kohalik muudatus: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Konflikti ei saanud lahendada.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Korduva sündmuse ulatus'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Kogu sari'; @override String get singleOccurrence => 'See sündmus'; @@ -2074,7 +2072,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Vali, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.'; @override String get taskDueBeforeStart => 'Tähtaeg ei tohi olla enne algust.'; @@ -2107,178 +2105,179 @@ class AppLocalizationsEt extends AppLocalizations { @override String get calendarCannotRemove => - 'Seda kalendrit ei saa sellelt kontolt kustutada ega eemaldada.'; + 'Seda kalendrit ei saa sellest kontost kustutada ega eemaldada.'; @override String get calendarPendingChangesPreventRemoval => 'Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Kalendritellimused'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Lisa kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Lisa kalendritellimus'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Kohalik nimi'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'Tellimuse URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Sisesta HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Sisesta kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Kohalik värv'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + 'Kasuta kuuekohalist värvi, näiteks #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Sisesta kuuekohaline kuueteistkümnendsüsteemi värv.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Värskendussagedus'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Automaatne'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Iga tunni järel'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Iga kuue tunni järel'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Iga päev'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Allikas: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Turvalise allika eelvaateks sisesta kehtiv URL.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Kirjutuskaitstud tellimus'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Veel värskendamata'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Viimane edukas värskendus: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Järgmine värskendus: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Ajaga kaasas'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Värskendusprobleem: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Värskenda kohe'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Tühista tellimus'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'Kas tühistada „$name” tellimus?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'See eemaldab kohaliku tellimuse ja selle vahemällu salvestatud sündmused. Avaldatud kalendrit ei muudeta.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Lisa tellimus'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Kalendritellimus nurjus: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Tellimused'; @override - String get calendarImport => 'Kalendri importimine'; + String get calendarImport => 'Kalendri import'; @override String get calendarImportDescription => - 'Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need imporditakse.'; + 'Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need lisada.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Impordi .ics-fail'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Impordi kalendrisündmused'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Imporditavad sündmusekomplektid: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Sobimatud sündmused: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Tahtlikult välja jäetud: $fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => 'Sobivat kirjutatavat sihtkalendrit pole.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Sihtkalender'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Impordi sündmused'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Import lõpetatud'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Imporditud või järjekorda lisatud: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Vahele jäetud duplikaadid: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Toetamata korduskomplektid: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Kalendrifaili ei saanud importida: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 3fbd3bbb..5a8dcbea 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -17,7 +17,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'حساب‌های Google، Microsoft، Apple iCloud Calendar یا Nextcloud را متصل کنید.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'همهٔ حساب‌هایی را که می‌خواهید استفاده کنید اضافه کنید. BusyMax تقویم‌ها، رویدادها، فهرست‌های کار و کارهای پشتیبانی‌شده را از هر حساب همگام می‌کند.'; @override String get onboardingPreferencesStepTitle => 'انتخاب تنظیمات سیستم'; @@ -222,68 +222,68 @@ class AppLocalizationsFa extends AppLocalizations { String get trayOpenBusyMax => 'باز کردن BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'نمایش BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'رویداد جدید…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'کار جدید…'; @override - String get trayToday => 'Today'; + String get trayToday => 'امروز'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'تمام‌روز'; @override - String get trayNow => 'Now'; + String get trayNow => 'اکنون'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'رویداد تقویم'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'رویداد بدون عنوان'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'امروز مورد دیگری نیست'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count کار امروز سررسید دارند', + one: '۱ کار امروز سررسید دارد', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'باز کردن برنامهٔ امروز'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'همگام‌سازی اکنون'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'در حال همگام‌سازی…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'متصل نیست'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'هنوز همگام نشده'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'همین الان همگام شد'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: '$count دقیقه پیش همگام شد', + one: '۱ دقیقه پیش همگام شد', ); return '$_temp0'; } @@ -293,8 +293,8 @@ class AppLocalizationsFa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: '$count ساعت پیش همگام شد', + one: '۱ ساعت پیش همگام شد', ); return '$_temp0'; } @@ -304,17 +304,17 @@ class AppLocalizationsFa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: '$count روز پیش همگام شد', + one: '۱ روز پیش همگام شد', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'تنظیمات'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'خروج از BusyMax'; @override String get agendaLoadMoreOverdue => 'بارگیری کارهای عقب‌افتادهٔ بیشتر'; @@ -396,130 +396,130 @@ class AppLocalizationsFa extends AppLocalizations { String get guests => 'مهمانان'; @override - String get noGuests => 'بدون مهمان'; + String get noGuests => 'مهمانی وجود ندارد'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'ضروری'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'اختیاری'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'جلسه'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'افزودن Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'افزودن جلسهٔ Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'جلسهٔ آنلاین افزوده شد'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'درخواست پاسخ'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'از مهمانان بخواهید به دعوت‌نامه پاسخ دهند.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'پنهان کردن فهرست مهمانان'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'مهمانان نمی‌توانند ببینند چه افراد دیگری دعوت شده‌اند.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'اجازهٔ پیشنهاد زمان‌های جدید'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'مهمانان می‌توانند زمان دیگری برای جلسه پیشنهاد کنند.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'مهمانان اطلاع داده شوند؟'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'این جلسه مهمان دارد. هنگام ذخیره، دعوت‌نامه یا به‌روزرسانی رویداد برای آن‌ها ارسال شود؟'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'این جلسه مهمان دارد. هنگام حذف، لغو جلسه ارسال شود؟'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'ارسال به‌روزرسانی‌ها'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'ارسال لغو'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'ارسال نکن'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'جلسه ذخیره شود؟'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft دعوت‌نامه یا به‌روزرسانی رویداد را برای مهمانان ارسال می‌کند.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'جلسه حذف شود؟'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft لغو جلسه را برای مهمانان ارسال می‌کند.'; @override - String get organizer => 'Organizer'; + String get organizer => 'برگزارکننده'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'پاسخ شما'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'پاسخ مهمانان'; @override - String get respond => 'Respond'; + String get respond => 'پاسخ دادن'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'پذیرفتن'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'موقت'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'رد کردن'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'پیوستن به جلسه'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'پذیرفته‌شده'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'موقت'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'ردشده'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'در انتظار پاسخ'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'بی‌پاسخ'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'برگزارکننده'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'ارسال پاسخ شما ممکن نیست: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'باز کردن پیوند جلسه ممکن نیست.'; @override String get description => 'توضیحات'; @@ -880,12 +880,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'حذف ⁨$account⁩ از BusyMax؟'; + return '$account از BusyMax حذف شود؟'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار را که در این دستگاه ذخیره شده‌اند حذف می‌کند. تغییرات همگام‌نشده از بین می‌روند. نسخه‌های تقویم‌ها، رویدادها، فهرست‌های کار و کارها نزد ارائه‌دهنده حذف نمی‌شوند.'; @override String get revokeGoogleAccess => @@ -910,17 +910,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'ایجاد فهرست کار ممکن نیست: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'تغییر نام فهرست کار ممکن نیست: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'حذف فهرست کار ممکن نیست: $error'; } @override @@ -949,11 +949,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'این فهرست کار فقط‌خواندنی است و نمی‌توان نام آن را تغییر داد.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'این فهرست کار با مجوزهای فعلی شما قابل حذف نیست.'; @override String get builtInMicrosoftList => 'داخلی'; @@ -964,17 +964,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '«⁨$title⁩» از Google Tasks حذف شود؟'; + return '‏«$title» از Google Tasks حذف شود؟'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '‏«$title» و همهٔ کارهای آن حذف شوند؟'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'اشتراک‌گذاری «$title» از این حساب لغو شود؟'; } @override @@ -1102,13 +1102,13 @@ class AppLocalizationsFa extends AppLocalizations { String get doneStatus => 'انجام‌شده'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'وضعیت'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'بدون وضعیت'; @override - String get taskStatusNeedsAction => 'نیاز به اقدام'; + String get taskStatusNeedsAction => 'نیازمند اقدام'; @override String get taskStatusInProcess => 'در حال انجام'; @@ -1117,57 +1117,58 @@ class AppLocalizationsFa extends AppLocalizations { String get taskStatusCompleted => 'انجام‌شده'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'لغوشده'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent٪ تکمیل‌شده'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'تاریخ تکمیل'; @override String get priority => 'اولویت'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'بدون اولویت'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'اولویت $priority · زیاد'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'اولویت $priority · متوسط'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'اولویت $priority · کم'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL کار'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'یک URL مطلق شامل طرح آن وارد کنید.'; @override - String get classification => 'Classification'; + String get classification => 'دسته‌بندی'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'هنگام اشتراک‌گذاری، کل کار نشان داده شود'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'هنگام اشتراک‌گذاری، فقط مشغول بودن نشان داده شود'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'هنگام اشتراک‌گذاری، این کار پنهان شود'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'سنجاق کردن کار'; @override String get notes => 'یادداشت‌ها'; @@ -1206,82 +1207,82 @@ class AppLocalizationsFa extends AppLocalizations { String get addReminder => 'افزودن یادآور'; @override - String get reminders => 'Reminders'; + String get reminders => 'یادآورها'; @override - String get noReminders => 'بدون یادآوری'; + String get noReminders => 'بدون یادآور'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'ویرایش یادآور'; @override - String get beforeTaskStarts => 'قبل از شروع کار'; + String get beforeTaskStarts => 'پیش از شروع کار'; @override - String get beforeTaskDue => 'قبل از موعد انجام کار'; + String get beforeTaskDue => 'پیش از سررسید کار'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'پس از شروع کار'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'پس از سررسید کار'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'نسبت به تاریخ شروع کار'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'نسبت به تاریخ سررسید کار'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'زمان روز'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'در تاریخ و زمان مشخص'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'مقدار'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'واحد'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'ثانیه'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'دقیقه'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'ساعت'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'روز'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'هفته'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'هنگام شروع کار'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'هنگام سررسید کار'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'نوع این یادآور حفظ می‌شود، اما زمان آن قابل ویرایش نیست.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'یادآورهای مرتبط نگه داشته شوند؟'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'این تاریخ $count یادآور مرتبط دارد. آن‌ها در تاریخ و زمان فعلی نگه داشته شوند؟'; } @override - String get discardRelatedReminders => 'حذف یادآوری‌ها'; + String get discardRelatedReminders => 'دور انداختن یادآورها'; @override - String get keepRelatedReminders => 'نگه‌داشتن یادآوری‌ها'; + String get keepRelatedReminders => 'نگه‌داشتن یادآورها'; @override String get addGuest => 'افزودن مهمان'; @@ -1317,86 +1318,86 @@ class AppLocalizationsFa extends AppLocalizations { String get repeatYearly => 'سالانه'; @override - String get repeatEvery => 'تکرار هر'; + String get repeatEvery => 'فاصلهٔ تکرار'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'تکرار در'; @override String get repeatEnd => 'پایان تکرار'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'هرگز'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'در تاریخ'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'پس از تعداد مشخصی تکرار'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'تعداد تکرار'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'روزهای ماه'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'ماه‌ها'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'جایگاه روز هفته'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'روزهای مشخص'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'اول'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'دوم'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'سوم'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'چهارم'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'پنجم'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'یکی مانده به آخر'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'آخر'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'روز'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'روز هفته'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'روز آخر هفته'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'هر $count روز'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'هر $count هفته'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'هر $count ماه'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'هر $count سال'; } @override @@ -1406,12 +1407,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return 'در روز $days'; + return 'در روز $days ماه'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'در $ordinal $days'; } @override @@ -1431,7 +1432,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'این قانون تکرار از گزینه‌هایی استفاده می‌کند که این ویرایشگر تغییر نمی‌دهد.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1491,21 +1492,21 @@ class AppLocalizationsFa extends AppLocalizations { String get subtasks => 'زیرکارها'; @override - String get duplicateTask => 'تکراری‌سازی وظیفه'; + String get duplicateTask => 'تکرار کار'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'کار تکرار شد.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'تکرار کار ممکن نیست: $error'; } @override - String get hideSubtasks => 'مخفی‌سازی زیروظیفه‌ها'; + String get hideSubtasks => 'پنهان کردن زیروظیفه‌ها'; @override - String get hideClosedSubtasks => 'مخفی‌سازی زیروظیفه‌های بسته‌شده'; + String get hideClosedSubtasks => 'پنهان کردن زیروظیفه‌های بسته‌شده'; @override String get moveToTop => 'انتقال به بالاترین جایگاه'; @@ -1772,7 +1773,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'یک تغییر محلی در انتظار مسدود شد. ⁨$summary⁩'; + return 'یک تغییر محلی در انتظار مسدود شد. $summary'; } @override @@ -1813,7 +1814,7 @@ class AppLocalizationsFa extends AppLocalizations { String get notificationSnoozeAction => 'تعویق ۱۰ دقیقه‌ای'; @override - String get notificationDismissAction => 'رد کردن'; + String get notificationDismissAction => 'بستن'; @override String get notificationDetailsHidden => @@ -1878,14 +1879,14 @@ class AppLocalizationsFa extends AppLocalizations { String get noLocationsFound => 'مکانی پیدا نشد'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'این فیلد الزامی است.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'تقویم‌ها و کارها را از یکی از این ارائه‌دهندگان متصل کنید.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'تقویم Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1894,188 +1895,187 @@ class AppLocalizationsFa extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'کارهای Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'افزودن حساب تقویم Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'افزودن حساب Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'در حال اتصال به Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'در انتظار تأیید Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'اتصال تقویم Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'ایمیل حساب Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'گذرواژهٔ اختصاصی برنامه'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'پس از فعال کردن احراز هویت دومرحله‌ای برای حساب Apple، یک گذرواژهٔ اختصاصی برنامه بسازید.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'بازنشانی گذرواژهٔ حساب Apple، گذرواژه‌های اختصاصی برنامه را باطل می‌کند.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'اتصال Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'سرور Nextcloud یا نشانی CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'URL سرور Nextcloud خود را وارد کنید یا نشانی اصلی CalDAV را که از Nextcloud کپی کرده‌اید بچسبانید.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax مرورگر شما را باز می‌کند. آنجا دسترسی را تأیید کنید و سپس به BusyMax برگردید.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'اتصال'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'لغو اتصال'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'حساب به‌صورت محلی حذف شد، اما ابطال گذرواژهٔ برنامهٔ Nextcloud ممکن نیست.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'داده‌های تقویم و کار برای استفادهٔ آفلاین به‌صورت محلی ذخیره می‌شوند.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'این حساب موقتاً در دسترس نیست.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'مجوزهای سرور تغییر کرده‌اند. ویرایش‌های در انتظار متوقف شده‌اند.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'این سرور یا نمایهٔ ارائه‌دهنده پشتیبانی نمی‌شود.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'تقویم‌ها و فهرست‌های کار'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'رویدادهای تقویم'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'کارها'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'فقط‌خواندنی'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'در انتظار محلی'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'مسدودشده به‌دلیل تعارض'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'تا اتصال دوباره مسدود است'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'عملیات ناموفق بود'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'نگه‌داشتن نسخهٔ سرور'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'بررسی و اعمال دوبارهٔ تغییر محلی'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'تکرار به‌عنوان مورد جدید'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'وضعیت اتصال'; @override - String get davConnected => 'Connected'; + String get davConnected => 'متصل'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'در حال اتصال…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'خارج‌شده'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'آخرین همگام‌سازی موفق: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'هنوز همگام نشده'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'تازه‌سازی تقویم‌ها و فهرست‌های کار'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'سرور: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'تقویم رویدادها'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'فهرست کار'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'رویدادها و کارها'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'قابل نوشتن'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'اشتراکی'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'آخرین همگام‌سازی: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'مشکل همگام‌سازی: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'تعارض‌های همگام‌سازی'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'تغییر سرور: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'ویرایش محلی: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'حل تعارض ممکن نیست.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'دامنهٔ رویداد تکرارشونده'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'کل مجموعه'; @override String get singleOccurrence => 'این رویداد'; @@ -2108,7 +2108,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'انتخاب کنید این تغییر برای کل مجموعه، فقط این رویداد، یا این رویداد و رویدادهای بعدی اعمال شود.'; @override String get taskDueBeforeStart => 'زمان سررسید نباید پیش از زمان شروع باشد.'; @@ -2141,178 +2141,177 @@ class AppLocalizationsFa extends AppLocalizations { @override String get calendarCannotRemove => - 'این تقویم را نمی‌توان از این حساب حذف کرد.'; + 'این تقویم را نمی‌توان از این حساب حذف یا جدا کرد.'; @override String get calendarPendingChangesPreventRemoval => - 'پیش از حذف یا برداشتن این تقویم، صبر کنید تا تغییرات در انتظار آن همگام‌سازی شوند.'; + 'پیش از حذف یا جدا کردن این تقویم، صبر کنید تغییرات در انتظار آن همگام شوند.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'اشتراک‌های تقویم'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'تقویم‌های فقط‌خواندنی را اضافه کنید که از یک URL امن WebCal تازه می‌شوند.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'افزودن اشتراک تقویم'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'نام محلی'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'URL اشتراک'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'یک URL از نوع HTTPS یا webcal وارد کنید. BusyMax URL کامل را در فضای ذخیره‌سازی امن نگه می‌دارد.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'یک URL معتبر HTTPS یا webcal بدون اطلاعات کاربر یا قطعه وارد کنید.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'رنگ محلی'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => 'یک رنگ شش‌رقمی مانند #3584E4 وارد کنید.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => 'یک رنگ هگزادسیمال شش‌رقمی وارد کنید.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'دفعات تازه‌سازی'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'خودکار'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'ساعتی'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'هر شش ساعت'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'روزانه'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'مبدأ: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'برای پیش‌نمایش مبدأ امن، یک URL معتبر وارد کنید.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'اشتراک فقط‌خواندنی'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'هنوز تازه نشده'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'آخرین تازه‌سازی موفق: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'تازه‌سازی بعدی: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'به‌روز'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'مشکل تازه‌سازی: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'تازه‌سازی اکنون'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'لغو اشتراک'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'اشتراک «$name» لغو شود؟'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'این کار اشتراک محلی و رویدادهای ذخیره‌شدهٔ آن را حذف می‌کند. تقویم منتشرشده تغییر نمی‌کند.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'افزودن اشتراک'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'اشتراک تقویم ناموفق بود: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'اشتراک‌ها'; @override - String get calendarImport => 'وارد کردن تقویم'; + String get calendarImport => 'درون‌ریزی تقویم'; @override String get calendarImportDescription => - 'یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل‌نوشتن مقصد را برگزینید.'; + 'یک فایل انتخاب کنید، رویدادهای آن را بررسی کنید و سپس تقویم قابل نوشتنی را که باید آن‌ها را دریافت کند انتخاب کنید.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'درون‌ریزی فایل .ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'درون‌ریزی رویدادهای تقویم'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'مجموعه رویدادهای قابل درون‌ریزی: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'رویدادهای نامعتبر: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'عمداً حذف‌شده: $fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => 'تقویم مقصد قابل نوشتنی موجود نیست.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'تقویم مقصد'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'درون‌ریزی رویدادها'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'درون‌ریزی کامل شد'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'درون‌ریزی‌شده یا در صف: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'تکراری‌ها رد شدند: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'مجموعه‌های تکرار پشتیبانی‌نشده: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'درون‌ریزی فایل تقویم ممکن نیست: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 0d76f426..a3f88a00 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -17,7 +17,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Yhdistä Google-, Microsoft-, Apple iCloud Calendar- tai Nextcloud-tilit.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Lisää kaikki tilit, joita haluat käyttää. BusyMax synkronoi tuetut kalenterit, tapahtumat, tehtäväluettelot ja tehtävät jokaiselta tililtä.'; @override String get onboardingPreferencesStepTitle => 'Valitse järjestelmäasetukset'; @@ -221,68 +221,68 @@ class AppLocalizationsFi extends AppLocalizations { String get trayOpenBusyMax => 'Avaa BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Näytä BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Uusi tapahtuma…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Uusi tehtävä…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Tänään'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Koko päivä'; @override - String get trayNow => 'Now'; + String get trayNow => 'Nyt'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Kalenteritapahtuma'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Nimetön tapahtuma'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Ei muuta tänään'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count tehtävää erääntyy tänään', + one: '1 tehtävä erääntyy tänään', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Avaa tämän päivän agenda'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Synkronoi nyt'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Synkronoidaan…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Ei yhteyttä'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Ei vielä synkronoitu'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Synkronoitu juuri nyt'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Synkronoitu $count minuuttia sitten', + one: 'Synkronoitu minuutti sitten', ); return '$_temp0'; } @@ -292,8 +292,8 @@ class AppLocalizationsFi extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Synkronoitu $count tuntia sitten', + one: 'Synkronoitu tunti sitten', ); return '$_temp0'; } @@ -303,17 +303,17 @@ class AppLocalizationsFi extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Synkronoitu $count päivää sitten', + one: 'Synkronoitu päivä sitten', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Asetukset'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Lopeta BusyMax'; @override String get agendaLoadMoreOverdue => 'Lataa lisää myöhässä olevia tehtäviä'; @@ -334,7 +334,7 @@ class AppLocalizationsFi extends AppLocalizations { String get viewYear => 'Vuosi'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Agendanäkymä'; @override String get scheduleSettings => 'Aikataulu'; @@ -398,127 +398,127 @@ class AppLocalizationsFi extends AppLocalizations { String get noGuests => 'Ei vieraita'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Pakollinen'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Valinnainen'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Kokous'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Lisää Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Lisää Microsoft Teams -kokous'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Verkkokokous lisätty'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Pyydä vastauksia'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Pyydä vieraita vastaamaan kutsuun.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Piilota vierasluettelo'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Vieraat eivät näe, keitä muita on kutsuttu.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Salli uudet aikaehdotukset'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Vieraat voivat ehdottaa toista kokousaikaa.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Ilmoitetaanko vieraille?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Tässä kokouksessa on vieraita. Lähetetäänkö kutsut tai tapahtumapäivitykset tallennettaessa?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Tässä kokouksessa on vieraita. Lähetetäänkö peruutus, kun kokous poistetaan?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Lähetä päivitykset'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Lähetä peruutus'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Älä lähetä'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Tallennetaanko kokous?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft lähettää vieraille kutsut tai tapahtumapäivitykset.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Poistetaanko kokous?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft lähettää vieraille peruutuksen.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Järjestäjä'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Vastauksesi'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Vieraiden vastaukset'; @override - String get respond => 'Respond'; + String get respond => 'Vastaa'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Hyväksy'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Alustava'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Hylkää'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Liity kokoukseen'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Hyväksytty'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Alustava'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Hylätty'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Vastausta odotetaan'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Ei vastausta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Järjestäjä'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Vastauksesi lähetys epäonnistui: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Kokouslinkkiä ei voitu avata.'; @override String get description => 'Kuvaus'; @@ -870,7 +870,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Tämä poistaa laitteelta välimuistiin tallennetut tehtävät, kalenterit, tapahtumat, muistutukset ja odottavat offline-muutokset. Synkronoimattomat muutokset menetetään. Palveluntarjoajan kalenteri-, tapahtuma-, tehtäväluettelo- ja tehtäväkopioita ei poisteta.'; @override String get revokeGoogleAccess => @@ -896,17 +896,17 @@ class AppLocalizationsFi extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Tehtäväluettelon luominen epäonnistui: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Tehtäväluettelon uudelleennimeäminen epäonnistui: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Tehtäväluettelon poistaminen epäonnistui: $error'; } @override @@ -937,11 +937,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Tämä tehtäväluettelo on vain luku -tilassa, eikä sitä voi nimetä uudelleen.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Tätä tehtäväluetteloa ei voi poistaa nykyisillä käyttöoikeuksillasi.'; @override String get builtInMicrosoftList => 'Sisäänrakennettu'; @@ -952,17 +952,17 @@ class AppLocalizationsFi extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Poistetaanko \"$title\" Google Tasksista?'; + return 'Poistetaanko ”$title” Google Tasksista?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Poistetaanko ”$title” ja kaikki sen tehtävät?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Lopetetaanko kohteen ”$title” jakaminen tämän tilin kanssa?'; } @override @@ -1090,10 +1090,10 @@ class AppLocalizationsFi extends AppLocalizations { String get doneStatus => 'Valmis'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Tila'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Ei tilaa'; @override String get taskStatusNeedsAction => 'Vaatii toimenpiteitä'; @@ -1102,60 +1102,60 @@ class AppLocalizationsFi extends AppLocalizations { String get taskStatusInProcess => 'Käsittelyssä'; @override - String get taskStatusCompleted => 'Valmiina'; + String get taskStatusCompleted => 'Valmis'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Peruutettu'; @override String completionPercent(int percent) { - return '$percent% completed'; + return 'Valmis $percent %'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Valmistumispäivä'; @override String get priority => 'Tärkeys'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Ei tärkeyttä'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Tärkeys $priority · korkea'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Tärkeys $priority · keskitaso'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Tärkeys $priority · matala'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'Tehtävän URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Anna absoluuttinen URL-osoite skeemoineen.'; @override - String get classification => 'Classification'; + String get classification => 'Luokittelu'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'Näytä koko tehtävä jaettaessa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'Näytä jaettaessa vain varattu-aika'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Piilota tämä tehtävä jaettaessa'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Kiinnitä tehtävä'; @override String get notes => 'Muistiinpanot'; @@ -1194,82 +1194,82 @@ class AppLocalizationsFi extends AppLocalizations { String get addReminder => 'Lisää muistutus'; @override - String get reminders => 'Reminders'; + String get reminders => 'Muistutukset'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Ei muistutuksia'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Muokkaa muistutusta'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Ennen tehtävän alkamista'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Ennen tehtävän määräaikaa'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Tehtävän alkamisen jälkeen'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Tehtävän määräajan jälkeen'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Suhteessa tehtävän aloituspäivään'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Suhteessa tehtävän määräpäivään'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Kellonaika'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Tiettynä päivänä ja kellonaikana'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Määrä'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Yksikkö'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Sekunnit'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minuutit'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Tunnit'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Päivät'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Viikot'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Tehtävän alkaessa'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Tehtävän määräaikana'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Tämä muistutustyyppi säilytetään, mutta sen aikaa ei voi muokata.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Säilytetäänkö liittyvät muistutukset?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Tällä päivämäärällä on $count liittyvää muistutusta. Säilytetäänkö ne nykyisessä päivämäärässä ja kellonajassa?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Hylkää muistutukset'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Säilytä muistutukset'; @override String get addGuest => 'Lisää vieras'; @@ -1305,106 +1305,106 @@ class AppLocalizationsFi extends AppLocalizations { String get repeatYearly => 'Vuosittain'; @override - String get repeatEvery => 'Toista joka'; + String get repeatEvery => 'Toistoväli'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Toistopäivät'; @override String get repeatEnd => 'Lopeta toisto'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Ei koskaan'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'Päivämääränä'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Toistokertojen jälkeen'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Toistokerrat'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Kuukauden päivät'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Kuukaudet'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Viikonpäivän järjestys'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Tietyt päivät'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Ensimmäinen'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Toinen'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Kolmas'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Neljäs'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Viides'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Toiseksi viimeinen'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Viimeinen'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Päivä'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Arkipäivä'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Viikonlopun päivä'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Joka $count. päivä'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Joka $count. viikko'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Joka $count. kuukausi'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Joka $count. vuosi'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'päivinä $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'päivänä $days'; + return 'kuukauden päivinä $days'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$ordinal $days'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'kuukausina $months'; } @override @@ -1419,7 +1419,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Tämä toistosääntö käyttää asetuksia, joita tämä muokkain ei muuta.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1479,14 +1479,14 @@ class AppLocalizationsFi extends AppLocalizations { String get subtasks => 'Alitehtävät'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Monista tehtävä'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tehtävä monistettiin.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Tehtävän monistaminen epäonnistui: $error'; } @override @@ -1794,7 +1794,7 @@ class AppLocalizationsFi extends AppLocalizations { String get notificationSnoozeAction => 'Torkuta 10 minuuttia'; @override - String get notificationDismissAction => 'Hylkää'; + String get notificationDismissAction => 'Sulje'; @override String get notificationDetailsHidden => @@ -1850,14 +1850,14 @@ class AppLocalizationsFi extends AppLocalizations { String get noLocationsFound => 'Sijainteja ei löytynyt'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Tämä kenttä on pakollinen.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Yhdistä kalenterit ja tehtävät johonkin näistä palveluntarjoajista.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud -kalenteri'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1866,188 +1866,190 @@ class AppLocalizationsFi extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud-tehtävät'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Lisää Apple iCloud -kalenteritili'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Lisää Nextcloud-tili'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Yhdistetään Apple iCloudiin…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Odotetaan Nextcloud-valtuutusta…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Yhdistä Apple iCloud -kalenteri'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple-tilin sähköposti'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Sovelluskohtainen salasana'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Luo sovelluskohtainen salasana, kun olet ottanut kaksivaiheisen todennuksen käyttöön Apple-tililläsi.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple-tilin salasanan vaihtaminen mitätöi sovelluskohtaiset salasanat.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Yhdistä Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud-palvelin tai CalDAV-osoite'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Anna Nextcloud-palvelimesi URL-osoite tai liitä Nextcloudista kopioitu ensisijainen CalDAV-osoite.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax avaa selaimesi. Hyväksy käyttö siellä ja palaa sitten BusyMaxiin.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Yhdistä'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Peruuta yhteys'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Tili poistettiin paikallisesti, mutta Nextcloudin sovellussalasanaa ei voitu kumota.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Kalenteri- ja tehtävätiedot tallennetaan paikallisesti offline-käyttöä varten.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Yhdistä tämä tili uudelleen jatkaaksesi synkronointia.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Tämä tili ei ole tilapäisesti käytettävissä.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Palvelimen käyttöoikeudet muuttuivat. Odottavat muokkaukset on keskeytetty.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Tätä palvelinta tai palveluntarjoajan profiilia ei tueta.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Kalenterit ja tehtäväluettelot'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Kalenteritapahtumat'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tehtävät'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Vain luku'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Paikallisesti odottava'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Ristiriidan estämä'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => + 'Estetty, kunnes yhteys muodostetaan uudelleen'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Toiminto epäonnistui'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Säilytä palvelinversio'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Tarkista ja ota paikallinen muutos uudelleen käyttöön'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Monista uudeksi kohteeksi'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Yhteyden tila'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Yhdistetty'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Yhdistetään…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Kirjautunut ulos'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Viimeisin onnistunut synkronointi: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Ei vielä synkronoitu'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Päivitä kalenterit ja tehtäväluettelot'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Palvelin: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Tapahtumakalenteri'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Tehtäväluettelo'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Tapahtumat ja tehtävät'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Muokattava'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Jaettu'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Viimeksi synkronoitu: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Synkronointiongelma: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Synkronointiristiriidat'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Palvelin muuttui: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Paikallinen muokkaus: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Ristiriitaa ei voitu ratkaista.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Toistuvan tapahtuman laajuus'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Koko sarja'; @override String get singleOccurrence => 'Tämä tapahtuma'; @@ -2080,7 +2082,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Valitse, koskeeko tämä muutos koko sarjaa, vain tätä tapahtumaa vai tätä ja seuraavia tapahtumia.'; @override String get taskDueBeforeStart => 'Määräaika ei saa olla ennen alkamisaikaa.'; @@ -2113,178 +2115,180 @@ class AppLocalizationsFi extends AppLocalizations { @override String get calendarCannotRemove => - 'Tätä kalenteria ei voi poistaa tältä tililtä.'; + 'Tätä kalenteria ei voi poistaa tai irrottaa tästä tilistä.'; @override String get calendarPendingChangesPreventRemoval => - 'Odota, että tämän kalenterin odottavat muutokset on synkronoitu, ennen kuin poistat kalenterin.'; + 'Odota tämän kalenterin odottavien muutosten synkronointia ennen poistamista tai irrottamista.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Kalenteritilaukset'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Lisää vain luku -kalentereita, jotka päivittyvät suojatusta WebCal-URL-osoitteesta.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Lisää kalenteritilaus'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Paikallinen nimi'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'Tilauksen URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Anna HTTPS- tai webcal-URL. BusyMax säilyttää täydellisen URL-osoitteen suojatussa tallennustilassa.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Anna kelvollinen HTTPS- tai webcal-URL ilman käyttäjätietoja tai fragmenttia.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Paikallinen väri'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + 'Käytä kuusinumeroista väriä, kuten #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Anna kuusinumeroinen heksadesimaaliväri.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Päivitystiheys'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Automaattinen'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Tunneittain'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Kuuden tunnin välein'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Päivittäin'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Lähde: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Anna kelvollinen URL esikatsellaksesi sen suojattua alkuperää.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Vain luku -tilaus'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Ei vielä päivitetty'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Viimeisin onnistunut päivitys: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Seuraava päivitys: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Ajan tasalla'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Päivitysongelma: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Päivitä nyt'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Peruuta tilaus'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'Perutaanko tilaus kalenterista ”$name”?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Tämä poistaa paikallisen tilauksen ja sen välimuistissa olevat tapahtumat. Julkaistua kalenteria ei muuteta.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Lisää tilaus'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Kalenteritilaus epäonnistui: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Tilaukset'; @override String get calendarImport => 'Kalenterin tuonti'; @override String get calendarImportDescription => - 'Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten kirjoitettava kohdekalenteri.'; + 'Valitse tiedosto, tarkista sen tapahtumat ja valitse sitten muokattava kalenteri, joka vastaanottaa ne.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Tuo .ics-tiedosto'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Tuo kalenteritapahtumat'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Tuotavat tapahtumajoukot: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Virheelliset tapahtumat: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Jätetty tarkoituksella pois: $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'Muokattavaa kohdekalenteria ei ole saatavilla.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Kohdekalenteri'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Tuo tapahtumat'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Tuonti valmis'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Tuotu tai jonossa: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Ohitetut kaksoiskappaleet: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Tukemattomat toistojoukot: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Kalenteritiedostoa ei voitu tuoda: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index b80cfa1e..e6b5c911 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -17,7 +17,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Connectez les comptes Google, Microsoft, Calendrier Apple iCloud ou Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Ajoutez tous les comptes que vous souhaitez utiliser. BusyMax synchronise les calendriers, événements, listes de tâches et tâches pris en charge de chaque compte.'; @override String get onboardingPreferencesStepTitle => 'Choisir les paramètres système'; @@ -86,7 +86,7 @@ class AppLocalizationsFr extends AppLocalizations { String get exit => 'Quitter'; @override - String get options => 'Options'; + String get options => 'Choix'; @override String get hide => 'Masquer'; @@ -220,68 +220,68 @@ class AppLocalizationsFr extends AppLocalizations { String get trayOpenBusyMax => 'Ouvrir BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Afficher BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Nouvel événement…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Nouvelle tâche…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Aujourd’hui'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Toute la journée'; @override - String get trayNow => 'Now'; + String get trayNow => 'Maintenant'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Événement du calendrier'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Événement sans titre'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Rien d’autre aujourd’hui'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count tâches arrivent à échéance aujourd’hui', + one: '1 tâche arrive à échéance aujourd’hui', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Ouvrir l’agenda du jour'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Synchroniser maintenant'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Synchronisation…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Non connecté'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Pas encore synchronisé'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Synchronisé à l’instant'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Synchronisé il y a $count minutes', + one: 'Synchronisé il y a 1 minute', ); return '$_temp0'; } @@ -291,8 +291,8 @@ class AppLocalizationsFr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Synchronisé il y a $count heures', + one: 'Synchronisé il y a 1 heure', ); return '$_temp0'; } @@ -302,17 +302,17 @@ class AppLocalizationsFr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Synchronisé il y a $count jours', + one: 'Synchronisé il y a 1 jour', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Paramètres'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Quitter BusyMax'; @override String get agendaLoadMoreOverdue => 'Charger plus de tâches en retard'; @@ -333,7 +333,7 @@ class AppLocalizationsFr extends AppLocalizations { String get viewYear => 'Année'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vue agenda'; @override String get scheduleSettings => 'Planning'; @@ -397,130 +397,131 @@ class AppLocalizationsFr extends AppLocalizations { String get noGuests => 'Aucun invité'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obligatoire'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Facultatif'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Réunion'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Ajouter Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Ajouter une réunion Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Réunion en ligne ajoutée'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Demander des réponses'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Demandez aux invités de répondre à l’invitation.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Masquer la liste des invités'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Les invités ne peuvent pas voir qui d’autre a été invité.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => + 'Autoriser les nouvelles propositions d’horaire'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Les invités peuvent proposer une autre heure de réunion.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Notifier les invités ?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Cette réunion comporte des invités. Envoyer les invitations ou les mises à jour de l’événement lors de l’enregistrement ?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Cette réunion comporte des invités. Envoyer une annulation lors de sa suppression ?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Envoyer les mises à jour'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Envoyer l’annulation'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Ne pas envoyer'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Enregistrer la réunion ?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft enverra les invitations ou les mises à jour de l’événement aux invités.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Supprimer la réunion ?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft enverra une annulation aux invités.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organisateur'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Votre réponse'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Réponses des invités'; @override - String get respond => 'Respond'; + String get respond => 'Répondre'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Accepter'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provisoire'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Refuser'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Rejoindre la réunion'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Acceptée'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provisoire'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Refusée'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Réponse attendue'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Sans réponse'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organisateur'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Impossible d’envoyer votre réponse : $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Impossible d’ouvrir le lien de la réunion.'; @override - String get description => 'Description'; + String get description => 'Détails'; @override String get availabilityShowAs => 'Disponibilité / Afficher comme'; @@ -642,7 +643,7 @@ class AppLocalizationsFr extends AppLocalizations { String get taskLists => 'Listes de tâches'; @override - String get navigation => 'Navigation'; + String get navigation => 'Déplacement'; @override String get mainMenu => 'Menu principal'; @@ -658,7 +659,7 @@ class AppLocalizationsFr extends AppLocalizations { 'Afficher cette liste de raccourcis'; @override - String get shortcutGroupNavigation => 'Navigation'; + String get shortcutGroupNavigation => 'Déplacement'; @override String get shortcutNextPeriod => 'Période suivante'; @@ -869,7 +870,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Cette action supprime de cet appareil les tâches, calendriers, événements, rappels et modifications hors ligne en attente mis en cache. Les modifications non synchronisées seront perdues. Les copies des calendriers, événements, listes de tâches et tâches conservées chez le fournisseur ne sont pas supprimées.'; @override String get revokeGoogleAccess => @@ -895,17 +896,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Impossible de créer la liste de tâches : $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Impossible de renommer la liste de tâches : $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Impossible de supprimer la liste de tâches : $error'; } @override @@ -935,11 +936,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Cette liste de tâches est en lecture seule et ne peut pas être renommée.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Cette liste de tâches ne peut pas être supprimée avec vos autorisations actuelles.'; @override String get builtInMicrosoftList => 'Intégrée'; @@ -950,17 +951,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Supprimer « $title » de Google Tasks ?'; + return 'Supprimer « $title » de Google Tasks ?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Supprimer « $title » et toutes ses tâches ?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Ne plus partager « $title » avec ce compte ?'; } @override @@ -1088,75 +1089,78 @@ class AppLocalizationsFr extends AppLocalizations { String get doneStatus => 'Terminée'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'État'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Aucun état'; @override - String get taskStatusNeedsAction => 'Nécessite une action'; + String get taskStatusNeedsAction => 'Action requise'; @override String get taskStatusInProcess => 'En cours'; @override - String get taskStatusCompleted => 'Terminé'; + String get taskStatusCompleted => 'Terminée'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Annulée'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent % terminée'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Date d’achèvement'; @override String get priority => 'Priorité'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Aucune priorité'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Priorité $priority · élevée'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Priorité $priority · moyenne'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Priorité $priority · faible'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL de la tâche'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Saisissez une URL absolue, avec son schéma.'; @override - String get classification => 'Classification'; + String get classification => 'Catégorisation'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Lorsqu’elle est partagée, afficher la tâche complète'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Lorsqu’elle est partagée, afficher uniquement l’état occupé'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => + 'Lorsqu’elle est partagée, masquer cette tâche'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Épingler la tâche'; @override - String get notes => 'Notes'; + String get notes => 'Remarques'; @override String get dueDate => 'Date d’échéance'; @@ -1192,82 +1196,84 @@ class AppLocalizationsFr extends AppLocalizations { String get addReminder => 'Ajouter un rappel'; @override - String get reminders => 'Reminders'; + String get reminders => 'Rappels'; @override String get noReminders => 'Aucun rappel'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Modifier le rappel'; @override String get beforeTaskStarts => 'Avant le début de la tâche'; @override - String get beforeTaskDue => 'Avant l\'échéance de la tâche'; + String get beforeTaskDue => 'Avant l’échéance de la tâche'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Après le début de la tâche'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Après l’échéance de la tâche'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => + 'Par rapport à la date de début de la tâche'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => + 'Par rapport à la date d’échéance de la tâche'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Heure de la journée'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'À une date et une heure'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Quantité'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unité'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Secondes'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'min'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Heures'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Jours'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Semaines'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Au début de la tâche'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'À l’échéance de la tâche'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Ce type de rappel est conservé, mais son heure ne peut pas être modifiée.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Conserver les rappels associés ?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Cette date comporte $count rappels associés. Les conserver à leur date et heure actuelles ?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Supprimer les rappels'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Conserver les rappels'; @override String get addGuest => 'Ajouter un invité'; @@ -1303,106 +1309,106 @@ class AppLocalizationsFr extends AppLocalizations { String get repeatYearly => 'Annuel'; @override - String get repeatEvery => 'Répéter chaque'; + String get repeatEvery => 'Intervalle'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Répéter le'; @override - String get repeatEnd => 'Arrêter la répétition'; + String get repeatEnd => 'Terminer la répétition'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Jamais'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'À une date'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Après un certain nombre de répétitions'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Nombre d’occurrences'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Jours du mois'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Mois'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Position du jour de la semaine'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Jours précis'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Premier'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Deuxième'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Troisième'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Quatrième'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Cinquième'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Avant-dernier'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Dernier'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Jour'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Jour de semaine'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Jour du week-end'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Tous les $count jours'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Toutes les $count semaines'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Tous les $count mois'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Toutes les $count années'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'les $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'le $days'; + return 'le $days du mois'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'le $ordinal $days'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'en $months'; } @override @@ -1412,12 +1418,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String repeatUntilSummary(String date) { - return 'jusqu\'au $date'; + return 'jusqu’au $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Cette règle de récurrence utilise des options que cet éditeur ne modifie pas.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1425,7 +1431,7 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get importance => 'Importance'; + String get importance => 'Niveau d’importance'; @override String get importanceLow => 'Faible'; @@ -1455,7 +1461,7 @@ class AppLocalizationsFr extends AppLocalizations { String get organizationSection => 'Organisation'; @override - String get actionsSection => 'Actions'; + String get actionsSection => 'Opérations'; @override String get advancedSection => 'Avancé'; @@ -1477,21 +1483,21 @@ class AppLocalizationsFr extends AppLocalizations { String get subtasks => 'Sous-tâches'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Dupliquer la tâche'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tâche dupliquée.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Impossible de dupliquer la tâche : $error'; } @override String get hideSubtasks => 'Masquer les sous-tâches'; @override - String get hideClosedSubtasks => 'Masquer les sous-tâches fermées'; + String get hideClosedSubtasks => 'Masquer les sous-tâches terminées'; @override String get moveToTop => 'Déplacer tout en haut'; @@ -1523,7 +1529,7 @@ class AppLocalizationsFr extends AppLocalizations { String get parent => 'Tâche parente'; @override - String get position => 'Position'; + String get position => 'Ordre'; @override String get webLink => 'Lien web'; @@ -1608,7 +1614,7 @@ class AppLocalizationsFr extends AppLocalizations { String get notificationDetailPrivate => 'Privé'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Standard'; @override String get quietHours => 'Période de silence'; @@ -1624,7 +1630,7 @@ class AppLocalizationsFr extends AppLocalizations { String get quietHoursEnd => 'Fin de la période de silence'; @override - String get notifications => 'Notifications'; + String get notifications => 'Alertes'; @override String get appearance => 'Apparence'; @@ -1664,7 +1670,7 @@ class AppLocalizationsFr extends AppLocalizations { String get developerDiagnostics => 'Diagnostics développeur'; @override - String get diagnostics => 'Diagnostics'; + String get diagnostics => 'Informations de diagnostic'; @override String get apiInspectorDisabled => 'Afficher l’inspecteur API'; @@ -1796,7 +1802,7 @@ class AppLocalizationsFr extends AppLocalizations { String get notificationSnoozeAction => 'Rappeler dans 10 minutes'; @override - String get notificationDismissAction => 'Ignorer'; + String get notificationDismissAction => 'Fermer'; @override String get notificationDetailsHidden => @@ -1853,14 +1859,14 @@ class AppLocalizationsFr extends AppLocalizations { String get noLocationsFound => 'Aucun lieu trouvé'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Ce champ est obligatoire.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Connectez les calendriers et les tâches à l’un de ces fournisseurs.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendrier Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1869,188 +1875,191 @@ class AppLocalizationsFr extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Tâches Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Ajouter un compte Calendrier Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Ajouter un compte Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Connexion à Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'En attente de l’autorisation Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Connecter le Calendrier Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-mail du compte Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Mot de passe spécifique à l’app'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Créez un mot de passe spécifique à l’app après avoir activé l’authentification à deux facteurs de votre compte Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'La réinitialisation du mot de passe de votre compte Apple révoque les mots de passe spécifiques aux apps.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Connecter Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Serveur Nextcloud ou adresse CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Saisissez l’URL de votre serveur Nextcloud ou collez l’adresse CalDAV principale copiée depuis Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax va ouvrir votre navigateur. Autorisez-y l’accès, puis revenez dans BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Connecter'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Annuler la connexion'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Le compte a été supprimé localement, mais le mot de passe d’app Nextcloud n’a pas pu être révoqué.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Les données des calendriers et des tâches sont mises en cache localement pour une utilisation hors connexion.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Reconnectez ce compte pour reprendre la synchronisation.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Ce compte est temporairement indisponible.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Les autorisations du serveur ont changé. Les modifications en attente sont suspendues.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Ce serveur ou profil de fournisseur n’est pas pris en charge.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendriers et listes de tâches'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Événements du calendrier'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tâches'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Lecture seule'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'En attente localement'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloqué par un conflit'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloqué jusqu’à la reconnexion'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Échec de l’opération'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Conserver la version du serveur'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Examiner et réappliquer la modification locale'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Dupliquer comme nouvel élément'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'État de la connexion'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Connecté'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Connexion…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Déconnecté'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Dernière synchronisation réussie : $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Pas encore synchronisé'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => + 'Actualiser les calendriers et les listes de tâches'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Serveur : $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendrier d’événements'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Liste de tâches'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Événements et tâches'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Modifiable'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Partagé'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Dernière synchronisation : $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problème de synchronisation : $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflits de synchronisation'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Modification du serveur : $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Modification locale : $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Impossible de résoudre le conflit.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Portée de l’événement récurrent'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Série entière'; @override String get singleOccurrence => 'Cet événement'; @@ -2084,7 +2093,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Choisissez si cette modification s’applique à toute la série, à cet événement uniquement ou à cet événement et aux suivants.'; @override String get taskDueBeforeStart => 'L’échéance ne peut pas précéder le début.'; @@ -2117,178 +2126,180 @@ class AppLocalizationsFr extends AppLocalizations { @override String get calendarCannotRemove => - 'Cet agenda ne peut pas être supprimé ni retiré de ce compte.'; + 'Ce calendrier ne peut pas être supprimé ou retiré de ce compte.'; @override String get calendarPendingChangesPreventRemoval => - 'Attendez la fin de la synchronisation des modifications en attente de cet agenda avant de le supprimer ou de le retirer.'; + 'Attendez que les modifications en attente de ce calendrier aient fini de se synchroniser avant de le supprimer ou de le retirer.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Abonnements aux calendriers'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Ajoutez des calendriers en lecture seule qui se mettent à jour depuis une URL WebCal sécurisée.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Ajouter un abonnement de calendrier'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Nom local'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'URL de l’abonnement'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Saisissez une URL HTTPS ou webcal. BusyMax conserve l’URL complète dans un stockage sécurisé.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Saisissez une URL HTTPS ou webcal valide, sans informations utilisateur ni fragment.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Couleur locale'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + 'Utilisez une couleur à six chiffres, comme #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Saisissez une couleur hexadécimale à six chiffres.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Fréquence d’actualisation'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Automatique'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Toutes les heures'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Toutes les six heures'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Tous les jours'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Source : $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Saisissez une URL valide pour prévisualiser son origine sécurisée.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Abonnement en lecture seule'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Pas encore actualisé'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Dernière actualisation réussie : $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Prochaine actualisation : $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'À jour'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Problème d’actualisation : $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Actualiser maintenant'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Se désabonner'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'Se désabonner de « $name » ?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Cette action supprime l’abonnement local et ses événements mis en cache. Le calendrier publié n’est pas modifié.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Ajouter un abonnement'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Échec de l’abonnement au calendrier : $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Abonnements'; @override - String get calendarImport => 'Importer un calendrier'; + String get calendarImport => 'Importation de calendrier'; @override String get calendarImportDescription => - 'Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui les recevra.'; + 'Sélectionnez un fichier, vérifiez ses événements, puis choisissez le calendrier modifiable qui doit les recevoir.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Importer un fichier .ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Importer des événements du calendrier'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Ensembles d’événements importables : $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Événements non valides : $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Exclus intentionnellement : $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'Aucun calendrier de destination modifiable n’est disponible.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Calendrier de destination'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Importer les événements'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Importation terminée'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Importés ou mis en file d’attente : $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Doublons ignorés : $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Ensembles de récurrence non pris en charge : $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Impossible d’importer le fichier de calendrier : $error'; } @override diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index b7d698e1..52d44e0d 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -17,7 +17,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Google, Microsoft, Apple iCloud Calendar या Nextcloud खाते कनेक्ट करें।'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'वे सभी खाते जोड़ें जिनका आप उपयोग करना चाहते हैं। BusyMax हर खाते से समर्थित कैलेंडर, इवेंट, कार्य सूचियाँ और कार्य सिंक करता है।'; @override String get onboardingPreferencesStepTitle => 'सिस्टम सेटिंग्स चुनें'; @@ -221,68 +221,68 @@ class AppLocalizationsHi extends AppLocalizations { String get trayOpenBusyMax => 'BusyMax खोलें'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'BusyMax दिखाएँ'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'नया इवेंट…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'नया कार्य…'; @override - String get trayToday => 'Today'; + String get trayToday => 'आज'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'पूरा दिन'; @override - String get trayNow => 'Now'; + String get trayNow => 'अभी'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'कैलेंडर इवेंट'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'बिना शीर्षक का इवेंट'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'आज और कुछ नहीं'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: 'आज $count कार्यों की नियत तारीख है', + one: 'आज 1 कार्य की नियत तारीख है', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'आज का एजेंडा खोलें'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'अभी सिंक करें'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'सिंक हो रहा है…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'कनेक्ट नहीं है'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'अभी तक सिंक नहीं हुआ'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'अभी-अभी सिंक हुआ'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: '$count मिनट पहले सिंक हुआ', + one: '1 मिनट पहले सिंक हुआ', ); return '$_temp0'; } @@ -292,8 +292,8 @@ class AppLocalizationsHi extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: '$count घंटे पहले सिंक हुआ', + one: '1 घंटे पहले सिंक हुआ', ); return '$_temp0'; } @@ -303,17 +303,17 @@ class AppLocalizationsHi extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: '$count दिन पहले सिंक हुआ', + one: '1 दिन पहले सिंक हुआ', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'सेटिंग्स'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'BusyMax बंद करें'; @override String get agendaLoadMoreOverdue => @@ -399,127 +399,127 @@ class AppLocalizationsHi extends AppLocalizations { String get noGuests => 'कोई अतिथि नहीं'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'ज़रूरी'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'वैकल्पिक'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'मीटिंग'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet जोड़ें'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft Teams मीटिंग जोड़ें'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'ऑनलाइन मीटिंग जोड़ी गई'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'जवाब माँगें'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'अतिथियों से आमंत्रण का जवाब देने को कहें।'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'अतिथि सूची छिपाएँ'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'अतिथि यह नहीं देख सकते कि और किसे आमंत्रित किया गया है।'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'नए समय प्रस्तावों की अनुमति दें'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'अतिथि मीटिंग का अलग समय सुझा सकते हैं।'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'अतिथियों को सूचित करें?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'इस मीटिंग में अतिथि हैं। सहेजते समय आमंत्रण या इवेंट अपडेट भेजें?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'इस मीटिंग में अतिथि हैं। हटाते समय रद्दीकरण भेजें?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'अपडेट भेजें'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'रद्दीकरण भेजें'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'न भेजें'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'मीटिंग सहेजें?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft अतिथियों को आमंत्रण या इवेंट अपडेट भेजेगा।'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'मीटिंग हटाएँ?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft अतिथियों को रद्दीकरण भेजेगा।'; @override - String get organizer => 'Organizer'; + String get organizer => 'आयोजक'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'आपका जवाब'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'अतिथियों के जवाब'; @override - String get respond => 'Respond'; + String get respond => 'जवाब दें'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'स्वीकार करें'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'अनिश्चित'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'अस्वीकार करें'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'मीटिंग में शामिल हों'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'स्वीकृत'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'अनिश्चित'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'अस्वीकृत'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'जवाब की प्रतीक्षा है'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'जवाब नहीं दिया'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'आयोजक'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'आपका जवाब भेजा नहीं जा सका: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'मीटिंग लिंक खोला नहीं जा सका।'; @override String get description => 'विवरण'; @@ -864,12 +864,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'BusyMax से $account हटाएँ?'; + return '$account को BusyMax से हटाएँ?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'इससे इस डिवाइस से कैश किए गए कार्य, कैलेंडर, इवेंट, रिमाइंडर और लंबित ऑफ़लाइन बदलाव हट जाएँगे। सिंक न किए गए बदलाव खो जाएँगे। प्रदाता की कैलेंडर, इवेंट, कार्य सूची और कार्य प्रतियाँ हटाई नहीं जाएँगी।'; @override String get revokeGoogleAccess => @@ -895,17 +895,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'कार्य सूची बनाई नहीं जा सकी: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'कार्य सूची का नाम बदला नहीं जा सका: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'कार्य सूची हटाई नहीं जा सकी: $error'; } @override @@ -931,15 +931,15 @@ class AppLocalizationsHi extends AppLocalizations { String get deleteList => 'सूची मिटाएँ'; @override - String get unshare => 'Unshare'; + String get unshare => 'साझा करना बंद करें'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'यह कार्य सूची केवल-पठन है और इसका नाम नहीं बदला जा सकता।'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'आपकी मौजूदा अनुमतियों से यह कार्य सूची हटाई नहीं जा सकती।'; @override String get builtInMicrosoftList => 'अंतर्निहित'; @@ -950,17 +950,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Google Tasks से “$title” मिटाएँ?'; + return 'Google Tasks से “$title” हटाएँ?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '“$title” और इसके सभी कार्य हटाएँ?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'इस खाते से “$title” साझा करना बंद करें?'; } @override @@ -1088,72 +1088,72 @@ class AppLocalizationsHi extends AppLocalizations { String get doneStatus => 'पूर्ण'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'स्थिति'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'कोई स्थिति नहीं'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'कार्रवाई आवश्यक'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'प्रगति में'; @override - String get taskStatusCompleted => 'Completed'; + String get taskStatusCompleted => 'पूर्ण'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'रद्द'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent% पूर्ण'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'पूर्ण होने की तारीख'; @override - String get priority => 'Priority'; + String get priority => 'प्राथमिकता'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'कोई प्राथमिकता नहीं'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'प्राथमिकता $priority · उच्च'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'प्राथमिकता $priority · मध्यम'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'प्राथमिकता $priority · निम्न'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'कार्य URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'स्कीम सहित पूर्ण URL दर्ज करें।'; @override - String get classification => 'Classification'; + String get classification => 'वर्गीकरण'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'साझा होने पर पूरा कार्य दिखाएँ'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => 'साझा होने पर केवल व्यस्तता दिखाएँ'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'साझा होने पर यह कार्य छिपाएँ'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'कार्य पिन करें'; @override String get notes => 'नोट्स'; @@ -1192,82 +1192,82 @@ class AppLocalizationsHi extends AppLocalizations { String get addReminder => 'रिमाइंडर जोड़ें'; @override - String get reminders => 'Reminders'; + String get reminders => 'रिमाइंडर'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'कोई रिमाइंडर नहीं'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'रिमाइंडर संपादित करें'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'कार्य शुरू होने से पहले'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'कार्य की नियत तारीख से पहले'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'कार्य शुरू होने के बाद'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'कार्य की नियत तारीख के बाद'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'कार्य शुरू होने की तारीख के सापेक्ष'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'कार्य की नियत तारीख के सापेक्ष'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'दिन का समय'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'तारीख और समय पर'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'मात्रा'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'इकाई'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'सेकंड'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'मिनट'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'घंटे'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'दिन'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'सप्ताह'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'कार्य शुरू होने पर'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'कार्य की नियत तारीख पर'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'यह रिमाइंडर प्रकार सुरक्षित है, लेकिन इसका समय संपादित नहीं किया जा सकता।'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'संबंधित रिमाइंडर रखें?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'इस तारीख पर $count संबंधित रिमाइंडर हैं। इन्हें उनकी मौजूदा तारीख और समय पर रखें?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'रिमाइंडर हटाएँ'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'रिमाइंडर रखें'; @override String get addGuest => 'अतिथि जोड़ें'; @@ -1303,121 +1303,121 @@ class AppLocalizationsHi extends AppLocalizations { String get repeatYearly => 'हर वर्ष'; @override - String get repeatEvery => 'Repeat every'; + String get repeatEvery => 'दोहराव अंतराल'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'दोहराएँ'; @override - String get repeatEnd => 'End repeat'; + String get repeatEnd => 'दोहराव समाप्त करें'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'कभी नहीं'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'तारीख तक'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'कई बार दोहराने के बाद'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'दोहराव की संख्या'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'महीने के दिन'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'महीने'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'सप्ताह के दिन की स्थिति'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'विशिष्ट दिन'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'पहला'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'दूसरा'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'तीसरा'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'चौथा'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'पाँचवाँ'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'अंतिम से दूसरा'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'अंतिम'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'दिन'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'सप्ताह का दिन'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'सप्ताहांत का दिन'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'हर $count दिन'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'हर $count सप्ताह'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'हर $count महीने'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'हर $count साल'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days को'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return 'महीने के $daysवें दिन'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$days के $ordinal दिन'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months में'; } @override String repeatTimesSummary(int count) { - return '$count times'; + return '$count बार'; } @override String repeatUntilSummary(String date) { - return 'until $date'; + return '$date तक'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'यह दोहराव नियम ऐसे विकल्पों का उपयोग करता है जिन्हें यह संपादक नहीं बदलता।'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1477,21 +1477,21 @@ class AppLocalizationsHi extends AppLocalizations { String get subtasks => 'उपकार्य'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'कार्य की प्रतिलिपि बनाएँ'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'कार्य की प्रतिलिपि बन गई।'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'कार्य की प्रतिलिपि नहीं बन सकी: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => 'उपकार्य छिपाएँ'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'बंद उपकार्य छिपाएँ'; @override String get moveToTop => 'सबसे ऊपर ले जाएँ'; @@ -1752,7 +1752,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'एक लंबित स्थानीय बदलाव अवरुद्ध हो गया। $summary'; + return 'लंबित स्थानीय बदलाव अवरुद्ध किया गया। $summary'; } @override @@ -1788,7 +1788,7 @@ class AppLocalizationsHi extends AppLocalizations { String get notificationSnoozeAction => '10 मिनट के लिए स्नूज़ करें'; @override - String get notificationDismissAction => 'खारिज करें'; + String get notificationDismissAction => 'बंद करें'; @override String get notificationDetailsHidden => @@ -1844,14 +1844,14 @@ class AppLocalizationsHi extends AppLocalizations { String get noLocationsFound => 'कोई स्थान नहीं मिला'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'यह फ़ील्ड आवश्यक है।'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'इनमें से किसी प्रदाता से कैलेंडर और कार्य कनेक्ट करें।'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud कैलेंडर'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1860,188 +1860,188 @@ class AppLocalizationsHi extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud कार्य'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple iCloud Calendar खाता जोड़ें'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud खाता जोड़ें'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloud से कनेक्ट हो रहा है…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloud प्राधिकरण की प्रतीक्षा है…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple iCloud Calendar कनेक्ट करें'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple खाते का ईमेल'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'ऐप-विशिष्ट पासवर्ड'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Apple खाते के लिए दो-चरणीय प्रमाणीकरण चालू करने के बाद ऐप-विशिष्ट पासवर्ड बनाएँ।'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple खाते का पासवर्ड रीसेट करने से ऐप-विशिष्ट पासवर्ड रद्द हो जाते हैं।'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud कनेक्ट करें'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud सर्वर या CalDAV पता'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nextcloud सर्वर URL दर्ज करें या Nextcloud से कॉपी किया गया प्राथमिक CalDAV पता चिपकाएँ।'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax आपका ब्राउज़र खोलेगा। वहाँ पहुँच की अनुमति दें और फिर BusyMax पर लौटें।'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'कनेक्ट करें'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'कनेक्शन रद्द करें'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'खाता स्थानीय रूप से हटाया गया, लेकिन Nextcloud ऐप पासवर्ड रद्द नहीं किया जा सका।'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'ऑफ़लाइन उपयोग के लिए कैलेंडर और कार्य डेटा स्थानीय रूप से कैश किया जाता है।'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'सिंक फिर से शुरू करने के लिए इस खाते को दोबारा कनेक्ट करें।'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'यह खाता अस्थायी रूप से उपलब्ध नहीं है।'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'सर्वर अनुमतियाँ बदल गई हैं। लंबित संपादन रोक दिए गए हैं।'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'यह सर्वर या प्रदाता प्रोफ़ाइल समर्थित नहीं है।'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'कैलेंडर और कार्य सूचियाँ'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'कैलेंडर इवेंट'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'कार्य'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'केवल-पठन'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'स्थानीय रूप से लंबित'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'विरोध के कारण अवरुद्ध'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'दोबारा कनेक्ट होने तक अवरुद्ध'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'कार्रवाई विफल'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'सर्वर संस्करण रखें'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'स्थानीय बदलाव की समीक्षा कर फिर लागू करें'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'नए आइटम के रूप में कॉपी बनाएँ'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'कनेक्शन की स्थिति'; @override - String get davConnected => 'Connected'; + String get davConnected => 'कनेक्टेड'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'कनेक्ट हो रहा है…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'साइन आउट किया गया'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'अंतिम सफल सिंक: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'अभी तक सिंक नहीं हुआ'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'कैलेंडर और कार्य सूचियाँ रीफ़्रेश करें'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'सर्वर: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'इवेंट कैलेंडर'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'कार्य सूची'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'इवेंट और कार्य'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'लिखने योग्य'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'साझा'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'अंतिम सिंक: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'सिंक समस्या: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'सिंक विरोध'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'सर्वर में बदलाव: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'स्थानीय संपादन: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'विरोध का समाधान नहीं हो सका।'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'दोहराए जाने वाले इवेंट का दायरा'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'पूरी शृंखला'; @override String get singleOccurrence => 'यह इवेंट'; @@ -2074,7 +2074,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'चुनें कि यह बदलाव पूरी शृंखला, केवल इस इवेंट या इस और इसके बाद के इवेंट पर लागू हो।'; @override String get taskDueBeforeStart => 'नियत समय प्रारंभ समय से पहले नहीं हो सकता।'; @@ -2107,178 +2107,180 @@ class AppLocalizationsHi extends AppLocalizations { @override String get calendarCannotRemove => - 'इस कैलेंडर को इस खाते से मिटाया या हटाया नहीं जा सकता।'; + 'इस कैलेंडर को इस खाते से हटाया या मिटाया नहीं जा सकता।'; @override String get calendarPendingChangesPreventRemoval => - 'इस कैलेंडर को मिटाने या हटाने से पहले इसके लंबित बदलावों का सिंक पूरा होने दें।'; + 'कैलेंडर हटाने से पहले उसके लंबित बदलावों का सिंक पूरा होने की प्रतीक्षा करें।'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'कैलेंडर सदस्यताएँ'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'सुरक्षित WebCal URL से रीफ़्रेश होने वाले केवल-पठन कैलेंडर जोड़ें।'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'कैलेंडर सदस्यता जोड़ें'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'स्थानीय नाम'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'सदस्यता URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'HTTPS या webcal URL दर्ज करें। BusyMax पूरा URL सुरक्षित संग्रहण में रखता है।'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'उपयोगकर्ता जानकारी या फ़्रैगमेंट के बिना मान्य HTTPS या webcal URL दर्ज करें।'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'स्थानीय रंग'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + '#3584E4 जैसे छह अंकों वाले रंग का उपयोग करें।'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'छह अंकों वाला हेक्साडेसिमल रंग दर्ज करें।'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'रीफ़्रेश आवृत्ति'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'स्वचालित'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'हर घंटे'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'हर छह घंटे'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'हर दिन'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'स्रोत: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'सुरक्षित स्रोत का पूर्वावलोकन करने के लिए मान्य URL दर्ज करें।'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'केवल-पठन सदस्यता'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'अभी तक रीफ़्रेश नहीं हुआ'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'अंतिम सफल रीफ़्रेश: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'अगला रीफ़्रेश: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'अप टू डेट'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'रीफ़्रेश समस्या: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'अभी रीफ़्रेश करें'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'सदस्यता रद्द करें'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '“$name” की सदस्यता रद्द करें?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'इससे स्थानीय सदस्यता और कैश किए गए इवेंट हट जाएँगे। प्रकाशित कैलेंडर नहीं बदलेगा।'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'सदस्यता जोड़ें'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'कैलेंडर सदस्यता विफल: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'सदस्यताएँ'; @override String get calendarImport => 'कैलेंडर आयात'; @override String get calendarImportDescription => - 'फ़ाइल चुनें, उसके इवेंट की समीक्षा करें, फिर वह लिखने योग्य कैलेंडर चुनें जिसमें उन्हें आयात करना है।'; + 'फ़ाइल चुनें, उसके इवेंट की समीक्षा करें और फिर वह लिखने योग्य कैलेंडर चुनें जिसे उन्हें प्राप्त करना चाहिए।'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => '.ics फ़ाइल आयात करें'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'कैलेंडर इवेंट आयात करें'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'आयात योग्य इवेंट सेट: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'अमान्य इवेंट: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'जानबूझकर छोड़े गए: $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'कोई लिखने योग्य गंतव्य कैलेंडर उपलब्ध नहीं है।'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'गंतव्य कैलेंडर'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'इवेंट आयात करें'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'आयात पूरा हुआ'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'आयातित या कतार में: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'डुप्लिकेट छोड़े गए: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'असमर्थित दोहराव सेट: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'कैलेंडर फ़ाइल आयात नहीं की जा सकी: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 516a0c06..4ffcf354 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -17,7 +17,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Collega gli account Google, Microsoft, Calendario Apple iCloud o Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Aggiungi tutti gli account che vuoi usare. BusyMax sincronizza calendari, eventi, elenchi di attività e attività supportati da ogni account.'; @override String get onboardingPreferencesStepTitle => @@ -222,68 +222,68 @@ class AppLocalizationsIt extends AppLocalizations { String get trayOpenBusyMax => 'Apri BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Mostra BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Nuovo evento…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Nuova attività…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Oggi'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Tutto il giorno'; @override - String get trayNow => 'Now'; + String get trayNow => 'Adesso'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Evento del calendario'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Evento senza titolo'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Nient’altro oggi'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count attività scadono oggi', + one: '1 attività scade oggi', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Apri l’agenda di oggi'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Sincronizza ora'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Sincronizzazione…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Non connesso'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Non ancora sincronizzato'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Sincronizzato proprio ora'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Sincronizzato $count minuti fa', + one: 'Sincronizzato 1 minuto fa', ); return '$_temp0'; } @@ -293,8 +293,8 @@ class AppLocalizationsIt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Sincronizzato $count ore fa', + one: 'Sincronizzato 1 ora fa', ); return '$_temp0'; } @@ -304,17 +304,17 @@ class AppLocalizationsIt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Sincronizzato $count giorni fa', + one: 'Sincronizzato 1 giorno fa', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Impostazioni'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Esci da BusyMax'; @override String get agendaLoadMoreOverdue => 'Carica altre attività scadute'; @@ -335,7 +335,7 @@ class AppLocalizationsIt extends AppLocalizations { String get viewYear => 'Anno'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vista agenda'; @override String get scheduleSettings => 'Agenda'; @@ -399,127 +399,127 @@ class AppLocalizationsIt extends AppLocalizations { String get noGuests => 'Nessun invitato'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obbligatorio'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Facoltativo'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Riunione'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Aggiungi Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Aggiungi riunione Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Riunione online aggiunta'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Richiedi risposte'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Chiedi agli invitati di rispondere all’invito.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Nascondi elenco invitati'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Gli invitati non possono vedere chi altro è stato invitato.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Consenti nuove proposte di orario'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Gli invitati possono suggerire un altro orario per la riunione.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Notificare gli invitati?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Questa riunione ha degli invitati. Inviare inviti o aggiornamenti dell’evento al salvataggio?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Questa riunione ha degli invitati. Inviare una cancellazione quando viene eliminata?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Invia aggiornamenti'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Invia cancellazione'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Non inviare'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Salvare la riunione?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft invierà inviti o aggiornamenti dell’evento agli invitati.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Eliminare la riunione?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft invierà una cancellazione agli invitati.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organizzatore'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'La tua risposta'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Risposte degli invitati'; @override - String get respond => 'Respond'; + String get respond => 'Rispondi'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Accetta'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provvisorio'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Rifiuta'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Partecipa alla riunione'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Accettata'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provvisoria'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Rifiutata'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'In attesa di risposta'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Nessuna risposta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organizzatore'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Impossibile inviare la risposta: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Impossibile aprire il link della riunione.'; @override String get description => 'Descrizione'; @@ -871,7 +871,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Questa operazione elimina da questo dispositivo attività, calendari, eventi, promemoria e modifiche offline in sospeso memorizzati nella cache. Le modifiche non sincronizzate andranno perse. Le copie del provider non verranno eliminate.'; @override String get revokeGoogleAccess => @@ -897,17 +897,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Impossibile creare l’elenco di attività: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Impossibile rinominare l’elenco di attività: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Impossibile eliminare l’elenco di attività: $error'; } @override @@ -938,11 +938,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Questo elenco di attività è di sola lettura e non può essere rinominato.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Questo elenco di attività non può essere eliminato con le autorizzazioni attuali.'; @override String get builtInMicrosoftList => 'Integrato'; @@ -953,17 +953,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Eliminare «$title» da Google Tasks?'; + return 'Eliminare “$title” da Google Tasks?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Eliminare “$title” e tutte le sue attività?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Rimuovere la condivisione di “$title” da questo account?'; } @override @@ -1091,10 +1091,10 @@ class AppLocalizationsIt extends AppLocalizations { String get doneStatus => 'Completata'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Stato'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Nessuno stato'; @override String get taskStatusNeedsAction => 'Richiede azione'; @@ -1103,60 +1103,63 @@ class AppLocalizationsIt extends AppLocalizations { String get taskStatusInProcess => 'In corso'; @override - String get taskStatusCompleted => 'Completato'; + String get taskStatusCompleted => 'Completata'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Annullata'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent% completata'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Data di completamento'; @override String get priority => 'Priorità'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Nessuna priorità'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Priorità $priority · alta'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Priorità $priority · media'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Priorità $priority · bassa'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL dell’attività'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Inserisci un URL assoluto, incluso lo schema.'; @override - String get classification => 'Classification'; + String get classification => 'Classificazione'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Quando condivisa, mostra l’attività completa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Quando condivisa, mostra solo lo stato occupato'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => + 'Quando condivisa, nascondi questa attività'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Fissa attività'; @override String get notes => 'Note'; @@ -1195,82 +1198,84 @@ class AppLocalizationsIt extends AppLocalizations { String get addReminder => 'Aggiungi promemoria'; @override - String get reminders => 'Reminders'; + String get reminders => 'Promemoria'; @override String get noReminders => 'Nessun promemoria'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Modifica promemoria'; @override - String get beforeTaskStarts => 'Prima dell\'inizio dell\'attività'; + String get beforeTaskStarts => 'Prima dell’inizio dell’attività'; @override - String get beforeTaskDue => 'Prima della scadenza del compito'; + String get beforeTaskDue => 'Prima della scadenza dell’attività'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Dopo l’inizio dell’attività'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Dopo la scadenza dell’attività'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => + 'Rispetto alla data di inizio dell’attività'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => + 'Rispetto alla data di scadenza dell’attività'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Ora del giorno'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'A una data e un’ora'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Quantità'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unità'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Secondi'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minuti'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Ore'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Giorni'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Settimane'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'All’inizio dell’attività'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Alla scadenza dell’attività'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Questo tipo di promemoria viene conservato, ma non è possibile modificarne l’ora.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Conservare i promemoria correlati?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Questa data ha $count promemoria correlati. Conservarli alla data e all’ora attuali?'; } @override - String get discardRelatedReminders => 'Scarta i promemoria'; + String get discardRelatedReminders => 'Scarta promemoria'; @override - String get keepRelatedReminders => 'Mantieni i promemoria'; + String get keepRelatedReminders => 'Conserva promemoria'; @override String get addGuest => 'Aggiungi invitato'; @@ -1306,91 +1311,91 @@ class AppLocalizationsIt extends AppLocalizations { String get repeatYearly => 'Ogni anno'; @override - String get repeatEvery => 'Ripeti ogni'; + String get repeatEvery => 'Intervallo'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Ripeti il'; @override String get repeatEnd => 'Termina ripetizione'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Mai'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'In data'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Dopo un numero di occorrenze'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Occorrenze'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Giorni del mese'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Mesi'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Posizione del giorno della settimana'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Giorni specifici'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Primo'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Secondo'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Terzo'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Quarto'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Quinto'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Penultimo'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Ultimo'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Giorno'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Giorno feriale'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Giorno del fine settimana'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Ogni $count giorni'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Ogni $count settimane'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Ogni $count mesi'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Ogni $count anni'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'il $days'; } @override @@ -1400,12 +1405,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'il $ordinal $days'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'nei mesi $months'; } @override @@ -1420,7 +1425,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Questa regola di ricorrenza usa opzioni che questo editor non modifica.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1480,21 +1485,21 @@ class AppLocalizationsIt extends AppLocalizations { String get subtasks => 'Sottoattività'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Duplica attività'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Attività duplicata.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Impossibile duplicare l’attività: $error'; } @override String get hideSubtasks => 'Nascondi sottoattività'; @override - String get hideClosedSubtasks => 'Nascondi sotto-attività chiuse'; + String get hideClosedSubtasks => 'Nascondi sottoattività chiuse'; @override String get moveToTop => 'Sposta in cima'; @@ -1544,7 +1549,7 @@ class AppLocalizationsIt extends AppLocalizations { String get synced => 'Sincronizzato'; @override - String get account => 'Account'; + String get account => 'Profilo'; @override String get sync => 'Sincronizzazione'; @@ -1657,7 +1662,7 @@ class AppLocalizationsIt extends AppLocalizations { String get currentLocale => 'Impostazioni locali correnti'; @override - String get privacy => 'Privacy'; + String get privacy => 'Riservatezza'; @override String get redactTaskContentInDiagnostics => @@ -1799,7 +1804,7 @@ class AppLocalizationsIt extends AppLocalizations { String get notificationSnoozeAction => 'Posticipa di 10 minuti'; @override - String get notificationDismissAction => 'Ignora'; + String get notificationDismissAction => 'Chiudi'; @override String get notificationDetailsHidden => @@ -1850,20 +1855,20 @@ class AppLocalizationsIt extends AppLocalizations { String get selectTimeZone => 'Seleziona fuso orario'; @override - String get searchLocations => 'Cerca luoghi'; + String get searchLocations => 'Cerca località'; @override - String get noLocationsFound => 'Nessun luogo trovato'; + String get noLocationsFound => 'Nessuna località trovata'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Questo campo è obbligatorio.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Collega calendari e attività a uno di questi provider.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendario Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1872,188 +1877,189 @@ class AppLocalizationsIt extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Attività Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Aggiungi account Calendario Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Aggiungi account Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Connessione ad Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'In attesa dell’autorizzazione Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Collega Calendario Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-mail dell’account Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Password specifica per l’app'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Crea una password specifica per l’app dopo aver attivato l’autenticazione a due fattori per il tuo account Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'La reimpostazione della password dell’account Apple revoca le password specifiche per le app.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Collega Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Server Nextcloud o indirizzo CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Inserisci l’URL del server Nextcloud o incolla l’indirizzo CalDAV principale copiato da Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax aprirà il browser. Approva l’accesso e torna a BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Collega'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Annulla connessione'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'L’account è stato rimosso localmente, ma non è stato possibile revocare la password dell’app Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'I dati di calendari e attività vengono memorizzati localmente per l’uso offline.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Ricollega questo account per riprendere la sincronizzazione.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Questo account è temporaneamente non disponibile.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Le autorizzazioni del server sono cambiate. Le modifiche in sospeso sono in pausa.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Questo server o profilo provider non è supportato.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendari ed elenchi di attività'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Eventi del calendario'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Attività'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Sola lettura'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'In sospeso localmente'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloccato da un conflitto'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloccato fino alla riconnessione'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Operazione non riuscita'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Mantieni la versione del server'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Esamina e riapplica la modifica locale'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Duplica come nuovo elemento'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Stato della connessione'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Connesso'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Connessione…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Disconnesso'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Ultima sincronizzazione riuscita: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Non ancora sincronizzato'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Aggiorna calendari ed elenchi di attività'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Indirizzo server: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendario eventi'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Elenco di attività'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Eventi e attività'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Scrivibile'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Condiviso'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Ultima sincronizzazione: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problema di sincronizzazione: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflitti di sincronizzazione'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Modifica del server: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Modifica locale: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Impossibile risolvere il conflitto.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Ambito dell’evento ricorrente'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Intera serie'; @override String get singleOccurrence => 'Questo evento'; @@ -2086,14 +2092,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Scegli se questa modifica si applica all’intera serie, solo a questo evento oppure a questo e agli eventi successivi.'; @override String get taskDueBeforeStart => 'La scadenza non può precedere l\'inizio.'; @override String get taskStartDueTimeModeMismatch => - 'Imposta un orario sia per l\'inizio sia per la scadenza, oppure rendi l\'attività valida per l\'intera giornata.'; + 'Imposta un orario sia per l’inizio sia per la scadenza, oppure rendi l’attività valida per l’intera giornata.'; @override String deleteCalendarConfirmation(String title) { @@ -2119,182 +2125,183 @@ class AppLocalizationsIt extends AppLocalizations { @override String get calendarCannotRemove => - 'Questo calendario non può essere eliminato o rimosso da questo account.'; + 'Non è possibile eliminare o rimuovere questo calendario da questo account.'; @override String get calendarPendingChangesPreventRemoval => - 'Attendi che le modifiche in sospeso di questo calendario siano sincronizzate prima di eliminarlo o rimuoverlo.'; + 'Attendi il completamento della sincronizzazione delle modifiche in sospeso del calendario prima di eliminarlo o rimuoverlo.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Abbonamenti ai calendari'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Aggiungi calendari di sola lettura che si aggiornano da un URL WebCal sicuro.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Aggiungi abbonamento al calendario'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Nome locale'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'URL abbonamento'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Inserisci un URL HTTPS o webcal. BusyMax conserva l’URL completo in un archivio sicuro.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Inserisci un URL HTTPS o webcal valido senza informazioni utente o frammento.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Colore locale'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => 'Usa un colore a sei cifre come #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Inserisci un colore esadecimale di sei cifre.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Frequenza di aggiornamento'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Automatica'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Ogni ora'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Ogni sei ore'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Ogni giorno'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Origine: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Inserisci un URL valido per visualizzarne l’origine sicura.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Abbonamento di sola lettura'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Non ancora aggiornato'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Ultimo aggiornamento riuscito: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Prossimo aggiornamento: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Aggiornato'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Problema di aggiornamento: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Aggiorna ora'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Annulla abbonamento'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'Annullare l’abbonamento a “$name”?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Rimuove l’abbonamento locale e gli eventi memorizzati nella cache. Il calendario pubblicato non cambia.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Aggiungi abbonamento'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Abbonamento al calendario non riuscito: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Abbonamenti'; @override - String get calendarImport => 'Importa calendario'; + String get calendarImport => 'Importazione calendario'; @override String get calendarImportDescription => - 'Seleziona un file, controlla gli eventi e scegli il calendario scrivibile in cui importarli.'; + 'Seleziona un file, rivedi gli eventi e scegli il calendario scrivibile che deve riceverli.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Importa file .ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Importa eventi del calendario'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Insiemi di eventi importabili: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Eventi non validi: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Ommessi intenzionalmente: $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'Nessun calendario di destinazione scrivibile disponibile.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Calendario di destinazione'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Importa eventi'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Importazione completata'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Importati o accodati: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Duplicati ignorati: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Insiemi di ricorrenze non supportati: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Impossibile importare il file del calendario: $error'; } @override - String get networkOffline => 'Offline'; + String get networkOffline => 'Senza connessione'; @override String get networkOfflineDescription => diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 7dc388c0..0fed01eb 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -17,7 +17,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Google、Microsoft、Apple iCloud Calendar、または Nextcloud のアカウントを接続します。'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '使用するすべてのアカウントを追加します。BusyMax は各アカウントの対応するカレンダー、予定、タスクリスト、タスクを同期します。'; @override String get onboardingPreferencesStepTitle => 'システム設定を選択'; @@ -215,68 +215,68 @@ class AppLocalizationsJa extends AppLocalizations { String get trayOpenBusyMax => 'BusyMax を開く'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'BusyMax を表示'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => '新しい予定…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => '新しいタスク…'; @override - String get trayToday => 'Today'; + String get trayToday => '今日'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => '終日'; @override - String get trayNow => 'Now'; + String get trayNow => '今'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'カレンダーの予定'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => '無題の予定'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => '今日はこれ以上ありません'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '今日が期限のタスクは $count 件', + one: '今日が期限のタスクは 1 件', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => '今日の予定表を開く'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => '今すぐ同期'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => '同期中…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => '未接続'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'まだ同期されていません'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'たった今同期'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: '$count 分前に同期', + one: '1 分前に同期', ); return '$_temp0'; } @@ -286,8 +286,8 @@ class AppLocalizationsJa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: '$count 時間前に同期', + one: '1 時間前に同期', ); return '$_temp0'; } @@ -297,17 +297,17 @@ class AppLocalizationsJa extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: '$count 日前に同期', + one: '1 日前に同期', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => '設定'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'BusyMax を終了'; @override String get agendaLoadMoreOverdue => '期限切れのタスクをさらに読み込む'; @@ -392,127 +392,122 @@ class AppLocalizationsJa extends AppLocalizations { String get noGuests => 'ゲストなし'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '必須'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '任意'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '会議'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet を追加'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft Teams 会議を追加'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'オンライン会議を追加しました'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '返信をリクエスト'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => 'ゲストに招待への返信を依頼します。'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'ゲストリストを非表示'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => 'ゲストには他の招待者が表示されません。'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '新しい日時の提案を許可'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => 'ゲストは別の会議時間を提案できます。'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'ゲストに通知しますか?'; @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String get notifyGuestsSaveMessage => 'この会議にはゲストがいます。保存時に招待または予定の更新を送信しますか?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => 'この会議にはゲストがいます。削除時にキャンセルを送信しますか?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '更新を送信'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'キャンセルを送信'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '送信しない'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '会議を保存しますか?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft からゲストに招待または予定の更新が送信されます。'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '会議を削除しますか?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft からゲストにキャンセルが送信されます。'; @override - String get organizer => 'Organizer'; + String get organizer => '主催者'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '自分の返信'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'ゲストの返信'; @override - String get respond => 'Respond'; + String get respond => '返信'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '承諾'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '仮承諾'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '辞退'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '会議に参加'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '承諾済み'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '仮承諾'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '辞退済み'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '返信待ち'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '未返信'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '主催者'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '返信を送信できませんでした: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '会議リンクを開けませんでした。'; @override String get description => '説明'; @@ -850,12 +845,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'BusyMax から $account を削除しますか?'; + return '$account を BusyMax から削除しますか?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'このデバイスから、キャッシュされたタスク、カレンダー、予定、リマインダー、保留中のオフライン変更を削除します。同期されていない変更は失われます。プロバイダー側のカレンダー、予定、タスクリスト、タスクのコピーは削除されません。'; @override String get revokeGoogleAccess => 'この Google アカウントへの BusyMax のアクセス権も取り消す'; @@ -878,17 +873,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'タスクリストを作成できませんでした: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'タスクリストの名前を変更できませんでした: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'タスクリストを削除できませんでした: $error'; } @override @@ -916,12 +911,10 @@ class AppLocalizationsJa extends AppLocalizations { String get unshare => '共有を解除'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get readOnlyTaskListCannotRename => 'このタスクリストは読み取り専用のため、名前を変更できません。'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '現在の権限ではこのタスクリストを削除できません。'; @override String get builtInMicrosoftList => '組み込み'; @@ -937,12 +930,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '「$title」とそのすべてのタスクを削除しますか?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'このアカウントとの「$title」の共有を解除しますか?'; } @override @@ -1067,13 +1060,13 @@ class AppLocalizationsJa extends AppLocalizations { String get doneStatus => '完了'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'ステータス'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'ステータスなし'; @override - String get taskStatusNeedsAction => 'アクションが必要'; + String get taskStatusNeedsAction => '要対応'; @override String get taskStatusInProcess => '進行中'; @@ -1082,57 +1075,57 @@ class AppLocalizationsJa extends AppLocalizations { String get taskStatusCompleted => '完了'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'キャンセル済み'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent% 完了'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '完了日'; @override String get priority => '優先度'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '優先度なし'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return '優先度 $priority · 高'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return '優先度 $priority · 中'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return '優先度 $priority · 低'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'タスク URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'スキームを含む絶対 URL を入力してください。'; @override - String get classification => 'Classification'; + String get classification => '分類'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '共有時にタスク全体を表示'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '共有時に空き状況のみ表示'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '共有時にこのタスクを非表示'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'タスクをピン留め'; @override String get notes => 'メモ'; @@ -1171,82 +1164,81 @@ class AppLocalizationsJa extends AppLocalizations { String get addReminder => 'リマインダーを追加'; @override - String get reminders => 'Reminders'; + String get reminders => 'リマインダー'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'リマインダーなし'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'リマインダーを編集'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'タスクの開始前'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'タスクの期限前'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'タスクの開始後'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'タスクの期限後'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'タスクの開始日に対して'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'タスクの期限日に対して'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '時刻'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '日時を指定'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '数値'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '単位'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '秒'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '分'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '時間'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '日'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '週'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'タスクの開始時'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'タスクの期限時刻'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => 'このリマインダーの種類は保持されますが、時刻は編集できません。'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '関連するリマインダーを保持しますか?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'この日付には関連するリマインダーが $count 件あります。現在の日付と時刻を保持しますか?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'リマインダーを破棄'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'リマインダーを保持'; @override String get addGuest => 'ゲストを追加'; @@ -1282,121 +1274,121 @@ class AppLocalizationsJa extends AppLocalizations { String get repeatYearly => '毎年'; @override - String get repeatEvery => '毎日繰り返す'; + String get repeatEvery => '繰り返し間隔'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '繰り返す曜日'; @override - String get repeatEnd => '繰り返し終了'; + String get repeatEnd => '繰り返しを終了'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'なし'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '指定日まで'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '指定回数後'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '繰り返し回数'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '月の日'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '月'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '曜日の順番'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '特定の曜日'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '第 1'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '第 2'; @override - String get repeatThird => 'Third'; + String get repeatThird => '第 3'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '第 4'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '第 5'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '最後から 2 番目'; @override - String get repeatLast => 'Last'; + String get repeatLast => '最後'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '日'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '平日'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '週末'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return '$count 日ごと'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return '$count 週間ごと'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return '$count か月ごと'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return '$count 年ごと'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days 曜日'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return '毎月 $days 日'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$days の第 $ordinal'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months に'; } @override String repeatTimesSummary(int count) { - return '$count回'; + return '$count 回'; } @override String repeatUntilSummary(String date) { - return '$dateまで'; + return '$date まで'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'この繰り返しルールには、このエディターで変更されないオプションが含まれています。'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1456,21 +1448,21 @@ class AppLocalizationsJa extends AppLocalizations { String get subtasks => 'サブタスク'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'タスクを複製'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'タスクを複製しました。'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'タスクを複製できませんでした: $error'; } @override String get hideSubtasks => 'サブタスクを非表示'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => '完了したサブタスクを非表示'; @override String get moveToTop => '一番上に移動'; @@ -1818,14 +1810,13 @@ class AppLocalizationsJa extends AppLocalizations { String get noLocationsFound => '場所が見つかりません'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'このフィールドは必須です。'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '次のいずれかのプロバイダーからカレンダーとタスクを接続します。'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud カレンダー'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1834,188 +1825,184 @@ class AppLocalizationsJa extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud タスク'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple iCloud カレンダーアカウントを追加'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud アカウントを追加'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloud に接続中…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloud の認証を待機中…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple iCloud カレンダーを接続'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple アカウントのメールアドレス'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App 用パスワード'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Apple アカウントで 2 要素認証を有効にした後、App 用パスワードを作成してください。'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple アカウントのパスワードをリセットすると、App 用パスワードが無効になります。'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud を接続'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud サーバーまたは CalDAV アドレス'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nextcloud サーバーの URL を入力するか、Nextcloud からコピーしたプライマリ CalDAV アドレスを貼り付けてください。'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax がブラウザーを開きます。そこでアクセスを許可してから BusyMax に戻ってください。'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '接続'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '接続をキャンセル'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'アカウントはローカルから削除されましたが、Nextcloud の App パスワードを無効にできませんでした。'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'オフラインで使用できるよう、カレンダーとタスクのデータはローカルにキャッシュされます。'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '同期を再開するには、このアカウントを再接続してください。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'このアカウントは一時的に利用できません。'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => 'サーバーの権限が変更されました。保留中の編集は一時停止されています。'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => 'このサーバーまたはプロバイダー プロファイルはサポートされていません。'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'カレンダーとタスクリスト'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'カレンダーの予定'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'タスク'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '読み取り専用'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'ローカルで保留中'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '競合によりブロック'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '再接続するまでブロック'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '操作に失敗'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'サーバーのバージョンを保持'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'ローカル変更を確認して再適用'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '新しいアイテムとして複製'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '接続状態'; @override - String get davConnected => 'Connected'; + String get davConnected => '接続済み'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '接続中…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'サインアウト済み'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '最後に成功した同期: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'まだ同期されていません'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'カレンダーとタスクリストを更新'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'サーバー: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '予定カレンダー'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'タスクリスト'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '予定とタスク'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '書き込み可能'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '共有'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '最後の同期: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '同期の問題: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '同期の競合'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'サーバーでの変更: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'ローカル編集: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '競合を解決できませんでした。'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => '繰り返し予定の範囲'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => '予定全体'; @override String get singleOccurrence => 'この予定'; @@ -2047,7 +2034,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'この変更を予定全体、この予定のみ、この予定とこれ以降の予定のどれに適用するか選択してください。'; @override String get taskDueBeforeStart => '期限を開始より前に設定することはできません。'; @@ -2078,178 +2065,177 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get calendarCannotRemove => 'このカレンダーは、このアカウントから削除できません。'; + String get calendarCannotRemove => 'このカレンダーはこのアカウントから削除または解除できません。'; @override String get calendarPendingChangesPreventRemoval => - 'このカレンダーを削除する前に、保留中の変更の同期が完了するまでお待ちください。'; + '削除または解除する前に、このカレンダーの保留中の変更が同期されるまで待ってください。'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'カレンダーの登録'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + '安全な WebCal URL から更新される読み取り専用カレンダーを追加します。'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'カレンダー登録を追加'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'ローカル名'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => '登録 URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'HTTPS または webcal URL を入力してください。BusyMax は完全な URL を安全なストレージに保存します。'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'ユーザー情報やフラグメントを含まない有効な HTTPS または webcal URL を入力してください。'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'ローカルの色'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => '#3584E4 のような 6 桁の色を使用してください。'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => '6 桁の 16 進数カラーを入力してください。'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => '更新頻度'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => '自動'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => '毎時'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => '6 時間ごと'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => '毎日'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'ソース: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + '安全なオリジンをプレビューするには有効な URL を入力してください。'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => '読み取り専用の登録'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'まだ更新されていません'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return '最後に成功した更新: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return '次回の更新: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => '最新'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return '更新の問題: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => '今すぐ更新'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => '登録を解除'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '「$name」の登録を解除しますか?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'ローカル登録とキャッシュされた予定を削除します。公開されているカレンダーは変更されません。'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => '登録を追加'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'カレンダー登録に失敗: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => '登録'; @override - String get calendarImport => 'カレンダーのインポート'; + String get calendarImport => 'カレンダーをインポート'; @override String get calendarImportDescription => - 'ファイルを選択して予定を確認し、インポート先の書き込み可能なカレンダーを選択します。'; + 'ファイルを選択して予定を確認し、受け入れ先の書き込み可能なカレンダーを選択します。'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => '.ics ファイルをインポート'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'カレンダーの予定をインポート'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'インポート可能な予定セット: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return '無効な予定: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return '意図的に省略: $fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => '書き込み可能な移行先カレンダーがありません。'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => '移行先カレンダー'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => '予定をインポート'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'インポート完了'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'インポート済みまたはキュー済み: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return '重複をスキップ: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'サポートされない繰り返しセット: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'カレンダーファイルをインポートできませんでした: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index f9a74523..74d5c98f 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -17,7 +17,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Google, Microsoft, Apple iCloud Calendar 또는 Nextcloud 계정을 연결하세요.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '사용할 모든 계정을 추가하세요. BusyMax는 각 계정의 지원되는 캘린더, 일정, 할 일 목록 및 할 일을 동기화합니다.'; @override String get onboardingPreferencesStepTitle => '시스템 설정 선택'; @@ -214,68 +214,68 @@ class AppLocalizationsKo extends AppLocalizations { String get trayOpenBusyMax => 'BusyMax 열기'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'BusyMax 표시'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => '새 일정…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => '새 할 일…'; @override - String get trayToday => 'Today'; + String get trayToday => '오늘'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => '종일'; @override - String get trayNow => 'Now'; + String get trayNow => '지금'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => '캘린더 일정'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => '제목 없는 일정'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => '오늘은 더 이상 없음'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '오늘 마감인 할 일 $count개', + one: '오늘 마감인 할 일 1개', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => '오늘 일정 열기'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => '지금 동기화'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => '동기화 중…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => '연결되지 않음'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => '아직 동기화되지 않음'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => '방금 동기화됨'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: '$count분 전에 동기화됨', + one: '1분 전에 동기화됨', ); return '$_temp0'; } @@ -285,8 +285,8 @@ class AppLocalizationsKo extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: '$count시간 전에 동기화됨', + one: '1시간 전에 동기화됨', ); return '$_temp0'; } @@ -296,17 +296,17 @@ class AppLocalizationsKo extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: '$count일 전에 동기화됨', + one: '1일 전에 동기화됨', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => '설정'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'BusyMax 종료'; @override String get agendaLoadMoreOverdue => '기한이 지난 할 일 더 불러오기'; @@ -388,130 +388,126 @@ class AppLocalizationsKo extends AppLocalizations { String get guests => '참석자'; @override - String get noGuests => '참석자 없음'; + String get noGuests => '게스트 없음'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '필수'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '선택사항'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '회의'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Google Meet 추가'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Microsoft Teams 회의 추가'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => '온라인 회의가 추가됨'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '응답 요청'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => '게스트에게 초대에 응답하도록 요청합니다.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => '게스트 목록 숨기기'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => '게스트는 다른 초대 대상자를 볼 수 없습니다.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '새 시간 제안 허용'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => '게스트가 다른 회의 시간을 제안할 수 있습니다.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '게스트에게 알릴까요?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + '이 회의에는 게스트가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => '이 회의에는 게스트가 있습니다. 삭제할 때 취소를 보낼까요?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '업데이트 보내기'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => '취소 보내기'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '보내지 않음'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '회의를 저장할까요?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft에서 게스트에게 초대 또는 일정 업데이트를 보냅니다.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '회의를 삭제할까요?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft에서 게스트에게 취소를 보냅니다.'; @override - String get organizer => 'Organizer'; + String get organizer => '주최자'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '내 응답'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => '게스트 응답'; @override - String get respond => 'Respond'; + String get respond => '응답'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '수락'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '미정'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '거절'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '회의 참가'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '수락됨'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '미정'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '거절됨'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '응답 대기 중'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '응답하지 않음'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '주최자'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '응답을 보낼 수 없습니다: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '회의 링크를 열 수 없습니다.'; @override String get description => '설명'; @@ -850,12 +846,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'BusyMax에서 $account 계정을 삭제할까요?'; + return '$account을(를) BusyMax에서 삭제할까요?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + '이 작업은 이 기기에서 캐시된 할 일, 캘린더, 일정, 알림 및 보류 중인 오프라인 변경사항을 삭제합니다. 동기화되지 않은 변경사항은 손실됩니다. 제공업체의 캘린더, 일정, 할 일 목록 및 할 일 사본은 삭제되지 않습니다.'; @override String get revokeGoogleAccess => '이 Google 계정에 대한 BusyMax의 액세스 권한도 취소'; @@ -878,17 +874,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return '할 일 목록을 만들 수 없습니다: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return '할 일 목록 이름을 바꿀 수 없습니다: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return '할 일 목록을 삭제할 수 없습니다: $error'; } @override @@ -917,11 +913,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + '이 할 일 목록은 읽기 전용이므로 이름을 바꿀 수 없습니다.'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '현재 권한으로는 이 할 일 목록을 삭제할 수 없습니다.'; @override String get builtInMicrosoftList => '기본 제공'; @@ -932,17 +927,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Google Tasks에서 “$title” 목록을 삭제할까요?'; + return 'Google Tasks에서 “$title”을(를) 삭제할까요?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '“$title” 및 모든 할 일을 삭제할까요?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '이 계정에서 “$title” 공유를 해제할까요?'; } @override @@ -1067,72 +1062,72 @@ class AppLocalizationsKo extends AppLocalizations { String get doneStatus => '완료'; @override - String get taskStatus => 'Status'; + String get taskStatus => '상태'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => '상태 없음'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => '조치 필요'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => '진행 중'; @override - String get taskStatusCompleted => '완료됨'; + String get taskStatusCompleted => '완료'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => '취소됨'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent% 완료'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '완료 날짜'; @override - String get priority => '우선 순위'; + String get priority => '우선순위'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '우선순위 없음'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return '우선순위 $priority · 높음'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return '우선순위 $priority · 중간'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return '우선순위 $priority · 낮음'; } @override - String get taskUrl => 'URL'; + String get taskUrl => '할 일 URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => '스킴을 포함한 절대 URL을 입력하세요.'; @override - String get classification => 'Classification'; + String get classification => '분류'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '공유할 때 전체 할 일 표시'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '공유할 때 바쁨 상태만 표시'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '공유할 때 이 할 일 숨기기'; @override - String get pinTask => 'Pin task'; + String get pinTask => '할 일 고정'; @override String get notes => '메모'; @@ -1168,85 +1163,84 @@ class AppLocalizationsKo extends AppLocalizations { String get reminder => '미리 알림'; @override - String get addReminder => '미리 알림 추가'; + String get addReminder => '알림 추가'; @override - String get reminders => 'Reminders'; + String get reminders => '알림'; @override - String get noReminders => 'No reminders'; + String get noReminders => '알림 없음'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => '알림 수정'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => '할 일 시작 전'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => '할 일 마감 전'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => '할 일 시작 후'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => '할 일 마감 후'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => '할 일 시작일 기준'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => '할 일 마감일 기준'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '시간'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '날짜 및 시간에'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '수량'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '단위'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '초'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '분'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '시간'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '일'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '주'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => '할 일 시작 시'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => '할 일 마감 시'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => '이 알림 유형은 유지되지만 시간을 수정할 수 없습니다.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '관련 알림을 유지할까요?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return '이 날짜에 관련 알림이 $count개 있습니다. 현재 날짜와 시간에 유지할까요?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => '알림 삭제'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => '알림 유지'; @override String get addGuest => '참석자 추가'; @@ -1282,106 +1276,106 @@ class AppLocalizationsKo extends AppLocalizations { String get repeatYearly => '매년'; @override - String get repeatEvery => '반복 주기'; + String get repeatEvery => '반복 간격'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '반복 요일'; @override String get repeatEnd => '반복 종료'; @override - String get repeatNever => 'Never'; + String get repeatNever => '안 함'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '날짜까지'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '지정한 횟수 후'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '반복 횟수'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '월의 날짜'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '월'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '요일 순서'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '특정 요일'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '첫 번째'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '두 번째'; @override - String get repeatThird => 'Third'; + String get repeatThird => '세 번째'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '네 번째'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '다섯 번째'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '끝에서 두 번째'; @override - String get repeatLast => 'Last'; + String get repeatLast => '마지막'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '요일'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '평일'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '주말'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return '$count일마다'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return '$count주마다'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return '$count개월마다'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return '$count년마다'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return '$days에'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return '매월 $days일'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$days $ordinal'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return '$months에'; } @override @@ -1391,12 +1385,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String repeatUntilSummary(String date) { - return 'until $date'; + return '$date까지'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + '이 반복 규칙에는 이 편집기에서 변경하지 않는 옵션이 사용됩니다.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1456,21 +1450,21 @@ class AppLocalizationsKo extends AppLocalizations { String get subtasks => '하위 할 일'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => '할 일 복제'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => '할 일이 복제되었습니다.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return '할 일을 복제할 수 없습니다: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => '하위 할 일 숨기기'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => '완료된 하위 할 일 숨기기'; @override String get moveToTop => '맨 위로 이동'; @@ -1727,7 +1721,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return '보류 중인 로컬 변경 사항이 차단되었습니다. $summary'; + return '보류 중인 로컬 변경사항이 차단되었습니다. $summary'; } @override @@ -1763,7 +1757,7 @@ class AppLocalizationsKo extends AppLocalizations { String get notificationSnoozeAction => '10분 후 다시 알림'; @override - String get notificationDismissAction => '해제'; + String get notificationDismissAction => '닫기'; @override String get notificationDetailsHidden => '개인정보 보호 설정에 따라 세부 정보가 숨겨졌습니다.'; @@ -1815,17 +1809,16 @@ class AppLocalizationsKo extends AppLocalizations { String get searchLocations => '위치 검색'; @override - String get noLocationsFound => '위치를 찾을 수 없습니다'; + String get noLocationsFound => '위치를 찾을 수 없음'; @override - String get requiredField => 'This field is required.'; + String get requiredField => '이 필드는 필수입니다.'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '다음 제공업체 중 하나에서 캘린더와 할 일을 연결하세요.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud 캘린더'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1834,188 +1827,183 @@ class AppLocalizationsKo extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud 할 일'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Apple iCloud 캘린더 계정 추가'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Nextcloud 계정 추가'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Apple iCloud에 연결 중…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Nextcloud 인증 대기 중…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Apple iCloud 캘린더 연결'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple 계정 이메일'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => '앱 전용 암호'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Apple 계정에서 이중 인증을 활성화한 후 앱 전용 암호를 생성하세요.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Apple 계정 암호를 재설정하면 앱 전용 암호가 취소됩니다.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Nextcloud 연결'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud 서버 또는 CalDAV 주소'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nextcloud 서버 URL을 입력하거나 Nextcloud에서 복사한 기본 CalDAV 주소를 붙여넣으세요.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax가 브라우저를 엽니다. 그곳에서 액세스를 승인한 다음 BusyMax로 돌아오세요.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '연결'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '연결 취소'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + '계정은 로컬에서 삭제되었지만 Nextcloud 앱 암호를 취소할 수 없습니다.'; @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String get davCachedOfflineNotice => '오프라인 사용을 위해 캘린더 및 할 일 데이터가 로컬에 캐시됩니다.'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '동기화를 재개하려면 이 계정을 다시 연결하세요.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => '이 계정은 일시적으로 사용할 수 없습니다.'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => '서버 권한이 변경되었습니다. 보류 중인 편집이 일시 중지되었습니다.'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => '이 서버 또는 제공업체 프로필은 지원되지 않습니다.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => '캘린더 및 할 일 목록'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => '캘린더 일정'; @override - String get taskContent => 'Tasks'; + String get taskContent => '할 일'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '읽기 전용'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => '로컬에서 보류 중'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '충돌로 차단됨'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '다시 연결할 때까지 차단됨'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '작업 실패'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => '서버 버전 유지'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => '로컬 변경사항 검토 후 다시 적용'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '새 항목으로 복제'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '연결 상태'; @override - String get davConnected => 'Connected'; + String get davConnected => '연결됨'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '연결 중…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => '로그아웃됨'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '마지막으로 성공한 동기화: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => '아직 동기화되지 않음'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => '캘린더 및 할 일 목록 새로 고침'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return '서버: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '일정 캘린더'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => '할 일 목록'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '일정 및 할 일'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '쓰기 가능'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '공유됨'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '마지막 동기화: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '동기화 문제: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '동기화 충돌'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return '서버 변경: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return '로컬 편집: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '충돌을 해결할 수 없습니다.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => '반복 일정 범위'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => '전체 시리즈'; @override String get singleOccurrence => '이 일정'; @@ -2047,7 +2035,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + '이 변경을 전체 시리즈, 이 일정만 또는 이 일정과 이후 일정에 적용할지 선택하세요.'; @override String get taskDueBeforeStart => '마감은 시작보다 빠를 수 없습니다.'; @@ -2083,174 +2071,173 @@ class AppLocalizationsKo extends AppLocalizations { @override String get calendarPendingChangesPreventRemoval => - '이 캘린더를 삭제하거나 제거하기 전에 보류 중인 변경 사항의 동기화가 완료될 때까지 기다리세요.'; + '삭제하거나 제거하기 전에 이 캘린더의 보류 중인 변경사항이 동기화될 때까지 기다리세요.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => '캘린더 구독'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + '보안 WebCal URL에서 새로 고침되는 읽기 전용 캘린더를 추가하세요.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => '캘린더 구독 추가'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => '로컬 이름'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => '구독 URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'HTTPS 또는 webcal URL을 입력하세요. BusyMax는 전체 URL을 안전한 저장소에 보관합니다.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + '사용자 정보나 프래그먼트가 없는 올바른 HTTPS 또는 webcal URL을 입력하세요.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => '로컬 색상'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => '#3584E4와 같은 6자리 색상을 사용하세요.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => '6자리 16진수 색상을 입력하세요.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => '새로 고침 빈도'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => '자동'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => '매시간'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => '6시간마다'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => '매일'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return '소스: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + '안전한 원본을 미리 보려면 올바른 URL을 입력하세요.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => '읽기 전용 구독'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => '아직 새로 고치지 않음'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return '마지막으로 성공한 새로 고침: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return '다음 새로 고침: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => '최신 상태'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return '새로 고침 문제: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => '지금 새로 고침'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => '구독 취소'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '“$name” 구독을 취소할까요?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + '로컬 구독과 캐시된 일정을 제거합니다. 게시된 캘린더는 변경되지 않습니다.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => '구독 추가'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return '캘린더 구독 실패: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => '구독'; @override String get calendarImport => '캘린더 가져오기'; @override String get calendarImportDescription => - '파일을 선택하고 일정을 검토한 다음 가져올 쓰기 가능한 캘린더를 선택하세요.'; + '파일을 선택하고 일정을 검토한 다음 이를 받을 쓰기 가능한 캘린더를 선택하세요.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => '.ics 파일 가져오기'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => '캘린더 일정 가져오기'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return '가져올 수 있는 일정 세트: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return '잘못된 일정: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return '의도적으로 생략됨: $fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => '쓰기 가능한 대상 캘린더가 없습니다.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => '대상 캘린더'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => '일정 가져오기'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => '가져오기 완료'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return '가져왔거나 대기열에 추가됨: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return '중복 건너뜀: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return '지원되지 않는 반복 세트: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return '캘린더 파일을 가져올 수 없습니다: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 165e1710..d494b22d 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -17,7 +17,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Ligue as contas Google, Microsoft, Calendário Apple iCloud ou Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Adicione todas as contas que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas suportados de cada conta.'; @override String get onboardingPreferencesStepTitle => 'Escolher definições do sistema'; @@ -132,7 +132,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get calendarManagementUnsupported => - 'Este provedor não oferece suporte ao gerenciamento de calendários no BusyMax.'; + 'Este fornecedor não suporta a gestão de calendários no BusyMax.'; @override String get primaryCalendarCannotDelete => @@ -222,68 +222,68 @@ class AppLocalizationsPt extends AppLocalizations { String get trayOpenBusyMax => 'Abrir o BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Mostrar o BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Novo evento…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Nova tarefa…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Hoje'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Todo o dia'; @override - String get trayNow => 'Now'; + String get trayNow => 'Agora'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Evento do calendário'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Evento sem título'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Nada mais hoje'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count tarefas terminam hoje', + one: '1 tarefa termina hoje', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Abrir a agenda de hoje'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Sincronizar agora'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'A sincronizar…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Não ligado'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Ainda não sincronizado'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Sincronizado agora mesmo'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Sincronizado há $count minutos', + one: 'Sincronizado há 1 minuto', ); return '$_temp0'; } @@ -293,8 +293,8 @@ class AppLocalizationsPt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Sincronizado há $count horas', + one: 'Sincronizado há 1 hora', ); return '$_temp0'; } @@ -304,17 +304,17 @@ class AppLocalizationsPt extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Sincronizado há $count dias', + one: 'Sincronizado há 1 dia', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Definições'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Sair do BusyMax'; @override String get agendaLoadMoreOverdue => 'Carregar mais tarefas em atraso'; @@ -335,7 +335,7 @@ class AppLocalizationsPt extends AppLocalizations { String get viewYear => 'Ano'; @override - String get viewAgenda => 'Agenda'; + String get viewAgenda => 'Vista de agenda'; @override String get scheduleSettings => 'Agenda'; @@ -399,127 +399,128 @@ class AppLocalizationsPt extends AppLocalizations { String get noGuests => 'Sem convidados'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Obrigatório'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Opcional'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Reunião'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Adicionar o Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Adicionar reunião do Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Reunião online adicionada'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Pedir respostas'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Peça aos convidados que respondam ao convite.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Ocultar lista de convidados'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Os convidados não podem ver quem mais foi convidado.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Permitir novas propostas de horário'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Os convidados podem sugerir uma hora diferente para a reunião.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Notificar convidados?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Esta reunião tem convidados. Enviar convites ou atualizações do evento ao guardá-la?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Esta reunião tem convidados. Enviar um cancelamento ao eliminá-la?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Enviar atualizações'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Enviar cancelamento'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Não enviar'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Guardar reunião?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'A Microsoft enviará convites ou atualizações do evento aos convidados.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Eliminar reunião?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'A Microsoft enviará um cancelamento aos convidados.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Organizador'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'A sua resposta'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Respostas dos convidados'; @override - String get respond => 'Respond'; + String get respond => 'Responder'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Aceitar'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Provisório'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Recusar'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Participar na reunião'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Aceite'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Provisório'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Recusado'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'A aguardar resposta'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Sem resposta'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Organizador'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Não foi possível enviar a sua resposta: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => + 'Não foi possível abrir a ligação da reunião.'; @override String get description => 'Descrição'; @@ -624,7 +625,7 @@ class AppLocalizationsPt extends AppLocalizations { String get visibilityConfidential => 'Confidencial'; @override - String get sensitivityNormal => 'Normal'; + String get sensitivityNormal => 'Habitual'; @override String get sensitivityPersonal => 'Pessoal'; @@ -871,7 +872,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Esta ação elimina deste dispositivo as tarefas, calendários, eventos, lembretes e alterações offline pendentes colocados em cache. As alterações não sincronizadas serão perdidas. As cópias dos calendários, eventos, listas de tarefas e tarefas no fornecedor não são eliminadas.'; @override String get revokeGoogleAccess => @@ -897,17 +898,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Não foi possível criar a lista de tarefas: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Não foi possível mudar o nome da lista de tarefas: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Não foi possível eliminar a lista de tarefas: $error'; } @override @@ -938,11 +939,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Esta lista de tarefas é só de leitura e não pode ter o nome alterado.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Esta lista de tarefas não pode ser eliminada com as permissões atuais.'; @override String get builtInMicrosoftList => 'Incorporada'; @@ -953,17 +954,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'Eliminar «$title» do Google Tasks?'; + return 'Eliminar “$title” do Google Tasks?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Eliminar “$title” e todas as suas tarefas?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Cancelar a partilha de “$title” desta conta?'; } @override @@ -1091,72 +1092,75 @@ class AppLocalizationsPt extends AppLocalizations { String get doneStatus => 'Concluída'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Estado'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Sem estado'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'Requer ação'; @override - String get taskStatusInProcess => 'Em andamento'; + String get taskStatusInProcess => 'Em curso'; @override String get taskStatusCompleted => 'Concluída'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Cancelada'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '$percent% concluída'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Data de conclusão'; @override String get priority => 'Prioridade'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Sem prioridade'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Prioridade $priority · Alta'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Prioridade $priority · Média'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Prioridade $priority · Baixa'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL da tarefa'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => + 'Introduza um URL absoluto, incluindo o esquema.'; @override - String get classification => 'Classification'; + String get classification => 'Classificação'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'Quando partilhada, mostrar a tarefa completa'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Quando partilhada, mostrar apenas ocupado'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Quando partilhada, ocultar esta tarefa'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Afixar tarefa'; @override String get notes => 'Notas'; @@ -1195,82 +1199,82 @@ class AppLocalizationsPt extends AppLocalizations { String get addReminder => 'Adicionar lembrete'; @override - String get reminders => 'Reminders'; + String get reminders => 'Lembretes'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Sem lembretes'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Editar lembrete'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Antes do início da tarefa'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Antes do prazo da tarefa'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Depois do início da tarefa'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Depois do prazo da tarefa'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Relativo à data de início da tarefa'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Relativo à data limite da tarefa'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Hora do dia'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Numa data e hora'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Quantidade'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Unidade'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Segundos'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Minutos'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Horas'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Dias'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Semanas'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'No início da tarefa'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'No prazo da tarefa'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Este tipo de lembrete é preservado, mas a hora não pode ser editada.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Manter lembretes relacionados?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Esta data tem $count lembretes relacionados. Mantê-los na data e hora atuais?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Eliminar lembretes'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Manter lembretes'; @override String get addGuest => 'Adicionar convidado'; @@ -1306,86 +1310,86 @@ class AppLocalizationsPt extends AppLocalizations { String get repeatYearly => 'Anualmente'; @override - String get repeatEvery => 'Repetir a cada'; + String get repeatEvery => 'Intervalo'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Repetir em'; @override - String get repeatEnd => 'End repeat'; + String get repeatEnd => 'Terminar repetição'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Nunca'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'Numa data'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Depois de um número de ocorrências'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Ocorrências'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Dias do mês'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Meses'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Posição do dia da semana'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Dias específicos'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Primeiro'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Segundo'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Terceiro'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Quarto'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Quinto'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Penúltimo'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Último'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Dia'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Dia útil'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Dia do fim de semana'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'A cada $count dias'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'A cada $count semanas'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'A cada $count meses'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'A cada $count anos'; } @override @@ -1395,17 +1399,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return 'no dia $days'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'na $ordinalª ocorrência de $days'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'em $months'; } @override @@ -1420,7 +1424,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Esta regra de recorrência utiliza opções que este editor não altera.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1434,7 +1438,7 @@ class AppLocalizationsPt extends AppLocalizations { String get importanceLow => 'Baixa'; @override - String get importanceNormal => 'Normal'; + String get importanceNormal => 'Média'; @override String get importanceHigh => 'Alta'; @@ -1480,21 +1484,21 @@ class AppLocalizationsPt extends AppLocalizations { String get subtasks => 'Subtarefas'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Duplicar tarefa'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Tarefa duplicada.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Não foi possível duplicar a tarefa: $error'; } @override String get hideSubtasks => 'Ocultar subtarefas'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'Ocultar subtarefas concluídas'; @override String get moveToTop => 'Mover para o início'; @@ -1610,7 +1614,7 @@ class AppLocalizationsPt extends AppLocalizations { String get notificationDetailPrivate => 'Privado'; @override - String get notificationDetailNormal => 'Normal'; + String get notificationDetailNormal => 'Predefinido'; @override String get quietHours => 'Período de silêncio'; @@ -1798,7 +1802,7 @@ class AppLocalizationsPt extends AppLocalizations { String get notificationSnoozeAction => 'Adiar 10 minutos'; @override - String get notificationDismissAction => 'Dispensar'; + String get notificationDismissAction => 'Fechar'; @override String get notificationDetailsHidden => @@ -1851,17 +1855,17 @@ class AppLocalizationsPt extends AppLocalizations { String get searchLocations => 'Pesquisar locais'; @override - String get noLocationsFound => 'Nenhum local encontrado'; + String get noLocationsFound => 'Não foram encontrados locais'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Este campo é obrigatório.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Ligue calendários e tarefas a um destes fornecedores.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Calendário Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1870,188 +1874,191 @@ class AppLocalizationsPt extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Tarefas do Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => + 'Adicionar conta do Calendário Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Adicionar conta Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'A ligar ao Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'A aguardar autorização do Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Ligar o Calendário Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'E-mail da conta Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => + 'Palavra-passe específica da aplicação'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Crie uma palavra-passe específica da aplicação depois de ativar a autenticação de dois fatores na sua conta Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'A reposição da palavra-passe da conta Apple revoga as palavras-passe específicas de aplicações.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Ligar ao Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Servidor Nextcloud ou endereço CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Introduza o URL do servidor Nextcloud ou cole o endereço CalDAV principal copiado do Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'O BusyMax abrirá o navegador. Aprove o acesso aí e volte ao BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Ligar'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Cancelar ligação'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'A conta foi removida localmente, mas não foi possível revogar a palavra-passe da aplicação Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Os dados de calendários e tarefas são colocados em cache localmente para utilização offline.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Volte a ligar esta conta para retomar a sincronização.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Esta conta está temporariamente indisponível.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'As permissões do servidor foram alteradas. As edições pendentes estão em pausa.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Este servidor ou perfil do fornecedor não é suportado.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Calendários e listas de tarefas'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Eventos do calendário'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Tarefas'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Só de leitura'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Pendente localmente'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bloqueado por conflito'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bloqueado até voltar a ligar'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Operação falhou'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Manter versão do servidor'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Rever e reaplicar alteração local'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Duplicar como novo item'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Estado da ligação'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Ligado'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'A ligar…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Sessão terminada'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Última sincronização bem-sucedida: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Ainda não sincronizado'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Atualizar calendários e listas de tarefas'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Servidor: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Calendário de eventos'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Lista de tarefas'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Eventos e tarefas'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Editável'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Partilhado'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Última sincronização: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Problema de sincronização: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Conflitos de sincronização'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Alteração no servidor: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Edição local: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => + 'Não foi possível resolver o conflito.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Âmbito do evento recorrente'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Série inteira'; @override String get singleOccurrence => 'Este evento'; @@ -2060,7 +2067,7 @@ class AppLocalizationsPt extends AppLocalizations { String get thisAndFollowingEvents => 'Este evento e os seguintes'; @override - String get thisAndFutureUnavailable => 'Não suportado por este provedor.'; + String get thisAndFutureUnavailable => 'Não suportado por este fornecedor.'; @override String get thisAndFutureMoveUnavailable => @@ -2072,19 +2079,19 @@ class AppLocalizationsPt extends AppLocalizations { @override String get copyEventAndDeleteOriginal => - 'Copiar o evento e excluir o original?'; + 'Copiar o evento e eliminar o original?'; @override String copyEventMoveWarning(String source, String destination) { - return 'O BusyMax não pode mover este evento diretamente de $source para $destination. Ele criará a cópia primeiro e excluirá o original somente depois que a cópia for concluída. Os IDs do evento mudarão; os status de resposta dos participantes poderão ser redefinidos e convites ou cancelamentos poderão ser enviados; e links de reunião, anexos, lembretes, campos específicos do provedor e exceções de recorrência poderão não ser transferidos.'; + return 'O BusyMax não pode mover este evento diretamente de $source para $destination. Criará primeiro a cópia e eliminará o original apenas depois de a cópia ser criada com êxito. Os IDs do evento mudarão; os estados das respostas dos participantes poderão ser repostos e poderão ser enviados convites ou cancelamentos; as ligações de reuniões, os anexos, os lembretes, os campos específicos do fornecedor e as exceções de recorrência poderão não ser transferidos.'; } @override - String get copyAndDelete => 'Copiar e excluir'; + String get copyAndDelete => 'Copiar e eliminar'; @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Escolha se esta alteração se aplica à série inteira, apenas a este evento ou a este e aos eventos seguintes.'; @override String get taskDueBeforeStart => 'O prazo não pode ser anterior ao início.'; @@ -2112,187 +2119,189 @@ class AppLocalizationsPt extends AppLocalizations { @override String removeCalendarConfirmation(String title) { - return 'Remover \"$title\" da sua lista do Google Agenda? A agenda compartilhada e os eventos dela não serão excluídos.'; + return 'Remover \"$title\" da sua lista do Google Calendar? O calendário partilhado e os respetivos eventos não serão eliminados.'; } @override String get calendarCannotRemove => - 'Não é possível excluir nem remover esta agenda desta conta.'; + 'Não é possível eliminar nem remover este calendário desta conta.'; @override String get calendarPendingChangesPreventRemoval => - 'Aguarde a sincronização das alterações pendentes desta agenda antes de excluí-la ou removê-la.'; + 'Aguarde que as alterações pendentes deste calendário terminem de sincronizar antes de o eliminar ou remover.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Subscrições de calendários'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Adicione calendários só de leitura que sejam atualizados a partir de um URL WebCal seguro.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Adicionar subscrição de calendário'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Nome local'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'URL da subscrição'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Introduza um URL HTTPS ou webcal. O BusyMax mantém o URL completo num armazenamento seguro.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Introduza um URL HTTPS ou webcal válido sem informações de utilizador ou fragmento.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Cor local'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + 'Utilize uma cor de seis dígitos, como #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Introduza uma cor hexadecimal de seis dígitos.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Frequência de atualização'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Automática'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'De hora a hora'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'A cada seis horas'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Diária'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Origem: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Introduza um URL válido para pré-visualizar a origem segura.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Subscrição só de leitura'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Ainda não atualizada'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Última atualização bem-sucedida: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Próxima atualização: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Atualizada'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Problema de atualização: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Atualizar agora'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Cancelar subscrição'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'Cancelar subscrição de “$name”?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Isto remove a subscrição local e os eventos colocados em cache. O calendário publicado não é alterado.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Adicionar subscrição'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Falha na subscrição do calendário: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Subscrições'; @override - String get calendarImport => 'Importar calendário'; + String get calendarImport => 'Importação de calendário'; @override String get calendarImportDescription => - 'Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os receberá.'; + 'Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os deve receber.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Importar ficheiro .ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Importar eventos do calendário'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Conjuntos de eventos importáveis: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Eventos inválidos: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Omitidos intencionalmente: $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'Não está disponível nenhum calendário de destino editável.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Calendário de destino'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Importar eventos'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Importação concluída'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Importados ou em fila: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Duplicados ignorados: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Conjuntos de recorrência não suportados: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Não foi possível importar o ficheiro de calendário: $error'; } @override - String get networkOffline => 'Offline'; + String get networkOffline => 'Sem ligação'; @override String get networkOfflineDescription => diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 6c01ae56..404a3165 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -17,7 +17,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Подключите аккаунты Google, Microsoft, Apple iCloud Calendar или Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Добавьте все нужные аккаунты. BusyMax синхронизирует поддерживаемые календари, события, списки задач и задачи из каждого аккаунта.'; @override String get onboardingPreferencesStepTitle => 'Выберите системные параметры'; @@ -218,68 +218,72 @@ class AppLocalizationsRu extends AppLocalizations { String get trayOpenBusyMax => 'Открыть BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Показать BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Новое событие…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Новая задача…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Сегодня'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Весь день'; @override - String get trayNow => 'Now'; + String get trayNow => 'Сейчас'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Событие календаря'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Событие без названия'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'На сегодня больше ничего'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: 'Сегодня истекает срок $count задачи', + many: 'Сегодня истекает срок $count задач', + few: 'Сегодня истекает срок $count задач', + one: 'Сегодня истекает срок 1 задачи', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Открыть расписание на сегодня'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Синхронизировать сейчас'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Синхронизация…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Не подключено'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Ещё не синхронизировано'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Синхронизировано только что'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Синхронизировано $count минуты назад', + many: 'Синхронизировано $count минут назад', + few: 'Синхронизировано $count минуты назад', + one: 'Синхронизировано минуту назад', ); return '$_temp0'; } @@ -289,8 +293,10 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Синхронизировано $count часа назад', + many: 'Синхронизировано $count часов назад', + few: 'Синхронизировано $count часа назад', + one: 'Синхронизировано час назад', ); return '$_temp0'; } @@ -300,17 +306,19 @@ class AppLocalizationsRu extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Синхронизировано $count дня назад', + many: 'Синхронизировано $count дней назад', + few: 'Синхронизировано $count дня назад', + one: 'Синхронизировано день назад', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Настройки'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Выйти из BusyMax'; @override String get agendaLoadMoreOverdue => 'Загрузить ещё просроченные задачи'; @@ -395,127 +403,126 @@ class AppLocalizationsRu extends AppLocalizations { String get noGuests => 'Нет гостей'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Обязательно'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Необязательно'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Встреча'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Добавить Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Добавить встречу Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Онлайн-встреча добавлена'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Запрашивать ответы'; @override String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + 'Попросите гостей ответить на приглашение.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Скрыть список гостей'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => 'Гости не видят других приглашённых.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Разрешить новые предложения времени'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Гости могут предложить другое время встречи.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Уведомить гостей?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'На встрече есть гости. Отправить приглашения или обновления события при сохранении?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'На встрече есть гости. Отправить отмену при удалении?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Отправить обновления'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Отправить отмену'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Не отправлять'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Сохранить встречу?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft отправит гостям приглашения или обновления события.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Удалить встречу?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft отправит гостям отмену.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Организатор'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Ваш ответ'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Ответы гостей'; @override - String get respond => 'Respond'; + String get respond => 'Ответить'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Принять'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Под вопросом'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Отклонить'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Присоединиться к встрече'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Принято'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Под вопросом'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Отклонено'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Ожидается ответ'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Нет ответа'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Организатор'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Не удалось отправить ответ: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Не удалось открыть ссылку на встречу.'; @override String get description => 'Описание'; @@ -874,7 +881,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'С устройства будут удалены кэшированные задачи, календари, события, напоминания и ожидающие автономные изменения. Несинхронизированные изменения будут потеряны. Копии календарей, событий, списков задач и задач у поставщика не удаляются.'; @override String get revokeGoogleAccess => @@ -900,17 +907,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Не удалось создать список задач: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Не удалось переименовать список задач: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Не удалось удалить список задач: $error'; } @override @@ -936,15 +943,15 @@ class AppLocalizationsRu extends AppLocalizations { String get deleteList => 'Удалить список'; @override - String get unshare => 'Закрыть доступ'; + String get unshare => 'Отменить общий доступ'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Этот список задач доступен только для чтения и не может быть переименован.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Этот список задач нельзя удалить с текущими разрешениями.'; @override String get builtInMicrosoftList => 'Встроенный'; @@ -960,12 +967,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Удалить «$title» и все его задачи?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Отменить общий доступ к «$title» для этого аккаунта?'; } @override @@ -1090,75 +1097,77 @@ class AppLocalizationsRu extends AppLocalizations { String get openStatus => 'Открыта'; @override - String get doneStatus => 'Выполнена'; + String get doneStatus => 'Выполнено'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Статус'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Нет статуса'; @override String get taskStatusNeedsAction => 'Требуется действие'; @override - String get taskStatusInProcess => 'Выполянется'; + String get taskStatusInProcess => 'Выполняется'; @override - String get taskStatusCompleted => 'Завершённые'; + String get taskStatusCompleted => 'Завершена'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Отменена'; @override String completionPercent(int percent) { - return '$percent% completed'; + return 'Выполнено на $percent%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Дата выполнения'; @override String get priority => 'Приоритет'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Без приоритета'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Приоритет $priority · высокий'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Приоритет $priority · средний'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Приоритет $priority · низкий'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL задачи'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Введите абсолютный URL вместе со схемой.'; @override - String get classification => 'Classification'; + String get classification => 'Классификация'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => + 'При общем доступе показывать задачу полностью'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'При общем доступе показывать только занятость'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'При общем доступе скрывать эту задачу'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Закрепить задачу'; @override String get notes => 'Заметки'; @@ -1197,13 +1206,13 @@ class AppLocalizationsRu extends AppLocalizations { String get addReminder => 'Добавить напоминание'; @override - String get reminders => 'Reminders'; + String get reminders => 'Напоминания'; @override - String get noReminders => 'Напоминаний нет'; + String get noReminders => 'Нет напоминаний'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Изменить напоминание'; @override String get beforeTaskStarts => 'До начала задачи'; @@ -1212,64 +1221,64 @@ class AppLocalizationsRu extends AppLocalizations { String get beforeTaskDue => 'До срока выполнения задачи'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'После начала задачи'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'После срока выполнения задачи'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Относительно даты начала задачи'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Относительно срока выполнения задачи'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Время суток'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'В указанную дату и время'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Количество'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Единица'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Секунды'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Минуты'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Часы'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Дни'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Недели'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'В момент начала задачи'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'В момент наступления срока задачи'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Тип этого напоминания сохраняется, но его время нельзя изменить.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Сохранить связанные напоминания?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'На эту дату приходится $count связанных напоминаний. Сохранить их текущие дату и время?'; } @override - String get discardRelatedReminders => 'Отменить напоминания'; + String get discardRelatedReminders => 'Удалить напоминания'; @override String get keepRelatedReminders => 'Сохранить напоминания'; @@ -1308,106 +1317,106 @@ class AppLocalizationsRu extends AppLocalizations { String get repeatYearly => 'Ежегодно'; @override - String get repeatEvery => 'Повторять каждые'; + String get repeatEvery => 'Интервал'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Повторять в'; @override - String get repeatEnd => 'Прекратить повтор'; + String get repeatEnd => 'Завершить повтор'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Никогда'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'В указанную дату'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'После указанного числа повторений'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Число повторений'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Дни месяца'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Месяцы'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Порядок дня недели'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Определённые дни'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Первый'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Второй'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Третий'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Четвёртый'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Пятый'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Предпоследний'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Последний'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'День'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Будний день'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Выходной день'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Каждые $count дн.'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Каждые $count нед.'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Каждые $count мес.'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Каждые $count г.'; } @override String repeatOnDaysSummary(String days) { - return 'в $days'; + return 'в дни $days'; } @override String repeatOnMonthDaysSummary(String days) { - return ' $days'; + return '$days-го числа месяца'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$ordinal $days'; } @override String repeatInMonthsSummary(String months) { - return 'в $months'; + return 'в месяцы $months'; } @override @@ -1422,7 +1431,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Это правило повторения использует параметры, которые этот редактор не изменяет.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1485,15 +1494,15 @@ class AppLocalizationsRu extends AppLocalizations { String get duplicateTask => 'Дублировать задачу'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Задача продублирована.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Не удалось продублировать задачу: $error'; } @override - String get hideSubtasks => 'Скрыть вложенные задачи'; + String get hideSubtasks => 'Скрыть подзадачи'; @override String get hideClosedSubtasks => 'Скрыть закрытые подзадачи'; @@ -1760,7 +1769,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'Локальное изменение не удалось синхронизировать. $summary'; + return 'Ожидающее локальное изменение заблокировано. $summary'; } @override @@ -1799,7 +1808,7 @@ class AppLocalizationsRu extends AppLocalizations { String get notificationSnoozeAction => 'Отложить на 10 минут'; @override - String get notificationDismissAction => 'Отклонить'; + String get notificationDismissAction => 'Закрыть'; @override String get notificationDetailsHidden => @@ -1851,20 +1860,20 @@ class AppLocalizationsRu extends AppLocalizations { String get selectTimeZone => 'Выберите часовой пояс'; @override - String get searchLocations => 'Поиск города'; + String get searchLocations => 'Поиск мест'; @override - String get noLocationsFound => 'Ничего не найдено'; + String get noLocationsFound => 'Места не найдены'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Это поле обязательно.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Подключите календари и задачи одного из следующих поставщиков.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Календарь Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1873,188 +1882,188 @@ class AppLocalizationsRu extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Задачи Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Добавить аккаунт Календаря Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Добавить аккаунт Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Подключение к Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Ожидание авторизации Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Подключить Календарь Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Электронная почта аккаунта Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Пароль для приложения'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Создайте пароль для приложения после включения двухфакторной аутентификации аккаунта Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Сброс пароля аккаунта Apple отзывает пароли для приложений.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Подключить Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Сервер Nextcloud или адрес CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Введите URL сервера Nextcloud или вставьте основной адрес CalDAV, скопированный из Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax откроет браузер. Разрешите доступ и вернитесь в BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Подключить'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Отменить подключение'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Аккаунт удалён локально, но пароль приложения Nextcloud не удалось отозвать.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Данные календаря и задач кэшируются локально для работы без подключения.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Подключите аккаунт заново, чтобы возобновить синхронизацию.'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => 'Этот аккаунт временно недоступен.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Разрешения сервера изменились. Ожидающие изменения приостановлены.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Этот сервер или профиль поставщика не поддерживается.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Календари и списки задач'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'События календаря'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Задачи'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Только чтение'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Ожидает локально'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Заблокировано из-за конфликта'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Заблокировано до повторного подключения'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Операция не выполнена'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Сохранить версию сервера'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => + 'Проверить и повторно применить локальное изменение'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Дублировать как новый элемент'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Состояние подключения'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Подключено'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Подключение…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Выполнен выход'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Последняя успешная синхронизация: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Ещё не синхронизировано'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Обновить календари и списки задач'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Сервер: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Календарь событий'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Список задач'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'События и задачи'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Доступно для записи'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Общий доступ'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Последняя синхронизация: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Проблема синхронизации: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Конфликты синхронизации'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Изменено на сервере: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Локальное изменение: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Не удалось разрешить конфликт.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Область повторяющегося события'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Вся серия'; @override String get singleOccurrence => 'Это событие'; @@ -2087,7 +2096,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Выберите, применять ли это изменение ко всей серии, только к этому событию или к этому и последующим событиям.'; @override String get taskDueBeforeStart => 'Срок не может быть раньше начала.'; @@ -2120,178 +2129,180 @@ class AppLocalizationsRu extends AppLocalizations { @override String get calendarCannotRemove => - 'Этот календарь нельзя удалить из этой учётной записи.'; + 'Этот календарь нельзя удалить или убрать из аккаунта.'; @override String get calendarPendingChangesPreventRemoval => - 'Дождитесь синхронизации ожидающих изменений этого календаря, прежде чем удалять его.'; + 'Дождитесь синхронизации ожидающих изменений этого календаря перед удалением или отключением.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Подписки на календари'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Добавляйте календари только для чтения, обновляемые по защищённому URL WebCal.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Добавить подписку на календарь'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Локальное имя'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'URL подписки'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Введите HTTPS- или webcal-URL. BusyMax хранит полный URL в защищённом хранилище.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Введите действительный HTTPS- или webcal-URL без данных пользователя или фрагмента.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Локальный цвет'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => + 'Используйте шестизначный цвет, например #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => + 'Введите шестизначный шестнадцатеричный цвет.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Частота обновления'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Автоматически'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Ежечасно'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Каждые шесть часов'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Ежедневно'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Источник: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Введите действительный URL для просмотра безопасного источника.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Подписка только для чтения'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Ещё не обновлялось'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Последнее успешное обновление: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Следующее обновление: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Актуально'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Проблема обновления: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Обновить сейчас'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Отменить подписку'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'Отменить подписку на «$name»?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Локальная подписка и кэшированные события будут удалены. Опубликованный календарь не изменится.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Добавить подписку'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Ошибка подписки на календарь: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Подписки'; @override String get calendarImport => 'Импорт календаря'; @override String get calendarImportDescription => - 'Выберите файл, проверьте события, затем выберите доступный для записи календарь назначения.'; + 'Выберите файл, просмотрите его события, а затем выберите доступный для записи календарь, который должен их принять.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Импортировать файл .ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Импортировать события календаря'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Наборы импортируемых событий: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Недопустимые события: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Намеренно пропущено: $fields'; } @override String get noWritableCalendars => - 'No writable destination calendar is available.'; + 'Нет доступного календаря назначения с правом записи.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Календарь назначения'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Импортировать события'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Импорт завершён'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Импортировано или поставлено в очередь: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Пропущено дубликатов: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Неподдерживаемые наборы повторений: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Не удалось импортировать файл календаря: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index fd2e2ab6..953ba7d2 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -17,7 +17,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + 'Kết nối tài khoản Google, Microsoft, Apple iCloud Calendar hoặc Nextcloud.'; @override String get googlePermissionsConsentNotice => @@ -41,7 +41,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + 'Thêm mọi tài khoản bạn muốn sử dụng. BusyMax đồng bộ lịch, sự kiện, danh sách công việc và công việc được hỗ trợ từ từng tài khoản.'; @override String get onboardingPreferencesStepTitle => 'Chọn cài đặt hệ thống'; @@ -219,68 +219,68 @@ class AppLocalizationsVi extends AppLocalizations { String get trayOpenBusyMax => 'Mở BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => 'Hiển thị BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => 'Sự kiện mới…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => 'Công việc mới…'; @override - String get trayToday => 'Today'; + String get trayToday => 'Hôm nay'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => 'Cả ngày'; @override - String get trayNow => 'Now'; + String get trayNow => 'Bây giờ'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => 'Sự kiện lịch'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => 'Sự kiện chưa có tiêu đề'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => 'Hôm nay không còn gì khác'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '$count công việc đến hạn hôm nay', + one: '1 công việc đến hạn hôm nay', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => 'Mở lịch hôm nay'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => 'Đồng bộ ngay'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => 'Đang đồng bộ…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => 'Chưa kết nối'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => 'Chưa đồng bộ'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => 'Vừa đồng bộ'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: 'Đồng bộ $count phút trước', + one: 'Đồng bộ 1 phút trước', ); return '$_temp0'; } @@ -290,8 +290,8 @@ class AppLocalizationsVi extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: 'Đồng bộ $count giờ trước', + one: 'Đồng bộ 1 giờ trước', ); return '$_temp0'; } @@ -301,17 +301,17 @@ class AppLocalizationsVi extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: 'Đồng bộ $count ngày trước', + one: 'Đồng bộ 1 ngày trước', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => 'Cài đặt'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => 'Thoát BusyMax'; @override String get agendaLoadMoreOverdue => 'Tải thêm công việc quá hạn'; @@ -393,130 +393,129 @@ class AppLocalizationsVi extends AppLocalizations { String get guests => 'Khách mời'; @override - String get noGuests => 'Không có khách mời'; + String get noGuests => 'Không có khách'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => 'Bắt buộc'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => 'Tùy chọn'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => 'Cuộc họp'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => 'Thêm Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => 'Thêm cuộc họp Microsoft Teams'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => 'Đã thêm cuộc họp trực tuyến'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => 'Yêu cầu phản hồi'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => 'Yêu cầu khách trả lời lời mời.'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => 'Ẩn danh sách khách'; @override String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + 'Khách không thể xem những người khác được mời.'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => 'Cho phép đề xuất thời gian mới'; @override String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + 'Khách có thể đề xuất thời gian họp khác.'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => 'Thông báo cho khách?'; @override String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + 'Cuộc họp này có khách. Gửi lời mời hoặc cập nhật sự kiện khi lưu?'; @override String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + 'Cuộc họp này có khách. Gửi thông báo hủy khi xóa?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => 'Gửi cập nhật'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => 'Gửi thông báo hủy'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => 'Không gửi'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => 'Lưu cuộc họp?'; @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + 'Microsoft sẽ gửi lời mời hoặc cập nhật sự kiện cho khách.'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => 'Xóa cuộc họp?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + 'Microsoft sẽ gửi thông báo hủy cho khách.'; @override - String get organizer => 'Organizer'; + String get organizer => 'Người tổ chức'; @override - String get yourResponse => 'Your response'; + String get yourResponse => 'Phản hồi của bạn'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => 'Phản hồi của khách'; @override - String get respond => 'Respond'; + String get respond => 'Phản hồi'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => 'Chấp nhận'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => 'Tạm thời'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => 'Từ chối'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => 'Tham gia cuộc họp'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => 'Đã chấp nhận'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => 'Tạm thời'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => 'Đã từ chối'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => 'Đang chờ phản hồi'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => 'Chưa phản hồi'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => 'Người tổ chức'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return 'Không thể gửi phản hồi: $error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => 'Không thể mở liên kết cuộc họp.'; @override String get description => 'Mô tả'; @@ -867,7 +866,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + 'Thao tác này xóa khỏi thiết bị các công việc, lịch, sự kiện, lời nhắc và thay đổi ngoại tuyến đang chờ được lưu trong bộ nhớ đệm. Các thay đổi chưa đồng bộ sẽ mất. Bản sao lịch, sự kiện, danh sách công việc và công việc ở nhà cung cấp không bị xóa.'; @override String get revokeGoogleAccess => @@ -893,17 +892,17 @@ class AppLocalizationsVi extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return 'Không thể tạo danh sách công việc: $error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return 'Không thể đổi tên danh sách công việc: $error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return 'Không thể xóa danh sách công việc: $error'; } @override @@ -929,15 +928,15 @@ class AppLocalizationsVi extends AppLocalizations { String get deleteList => 'Xóa danh sách'; @override - String get unshare => 'Bỏ chia sẽ'; + String get unshare => 'Ngừng chia sẻ'; @override String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + 'Danh sách công việc này ở chế độ chỉ đọc và không thể đổi tên.'; @override String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + 'Bạn không thể xóa danh sách công việc này với quyền hiện tại.'; @override String get builtInMicrosoftList => 'Tích hợp sẵn'; @@ -953,12 +952,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return 'Xóa “$title” và tất cả công việc trong đó?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return 'Ngừng chia sẻ “$title” khỏi tài khoản này?'; } @override @@ -1083,75 +1082,76 @@ class AppLocalizationsVi extends AppLocalizations { String get openStatus => 'Chưa hoàn thành'; @override - String get doneStatus => 'Đã hoàn thành'; + String get doneStatus => 'Đã xong'; @override - String get taskStatus => 'Status'; + String get taskStatus => 'Trạng thái'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => 'Không có trạng thái'; @override - String get taskStatusNeedsAction => 'Needs action'; + String get taskStatusNeedsAction => 'Cần xử lý'; @override - String get taskStatusInProcess => 'In process'; + String get taskStatusInProcess => 'Đang thực hiện'; @override - String get taskStatusCompleted => 'Hoàn thành'; + String get taskStatusCompleted => 'Đã hoàn thành'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => 'Đã hủy'; @override String completionPercent(int percent) { - return '$percent% completed'; + return 'Đã hoàn thành $percent%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => 'Ngày hoàn thành'; @override - String get priority => 'Priority'; + String get priority => 'Mức độ ưu tiên'; @override - String get priorityNone => 'No priority'; + String get priorityNone => 'Không có mức độ ưu tiên'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return 'Ưu tiên $priority · Cao'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return 'Ưu tiên $priority · Trung bình'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return 'Ưu tiên $priority · Thấp'; } @override - String get taskUrl => 'URL'; + String get taskUrl => 'URL công việc'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => 'Nhập URL tuyệt đối, bao gồm lược đồ.'; @override - String get classification => 'Classification'; + String get classification => 'Phân loại'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => 'Khi chia sẻ, hiển thị toàn bộ công việc'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => + 'Khi chia sẻ, chỉ hiển thị trạng thái bận'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => 'Khi chia sẻ, ẩn công việc này'; @override - String get pinTask => 'Pin task'; + String get pinTask => 'Ghim công việc'; @override String get notes => 'Ghi chú'; @@ -1190,82 +1190,82 @@ class AppLocalizationsVi extends AppLocalizations { String get addReminder => 'Thêm lời nhắc'; @override - String get reminders => 'Reminders'; + String get reminders => 'Lời nhắc'; @override - String get noReminders => 'No reminders'; + String get noReminders => 'Không có lời nhắc'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => 'Chỉnh sửa lời nhắc'; @override - String get beforeTaskStarts => 'Before the task starts'; + String get beforeTaskStarts => 'Trước khi công việc bắt đầu'; @override - String get beforeTaskDue => 'Before the task is due'; + String get beforeTaskDue => 'Trước hạn chót của công việc'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => 'Sau khi công việc bắt đầu'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => 'Sau hạn chót của công việc'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => 'Theo ngày bắt đầu công việc'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => 'Theo ngày đến hạn công việc'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => 'Thời gian trong ngày'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => 'Vào ngày và giờ cụ thể'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => 'Số lượng'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => 'Đơn vị'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => 'Giây'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => 'Phút'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => 'Giờ'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => 'Ngày'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => 'Tuần'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => 'Khi công việc bắt đầu'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => 'Khi công việc đến hạn'; @override String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + 'Loại lời nhắc này được giữ lại nhưng không thể chỉnh sửa thời gian.'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => 'Giữ lời nhắc liên quan?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return 'Ngày này có $count lời nhắc liên quan. Giữ chúng ở ngày và giờ hiện tại?'; } @override - String get discardRelatedReminders => 'Discard reminders'; + String get discardRelatedReminders => 'Xóa lời nhắc'; @override - String get keepRelatedReminders => 'Keep reminders'; + String get keepRelatedReminders => 'Giữ lời nhắc'; @override String get addGuest => 'Thêm khách mời'; @@ -1301,106 +1301,106 @@ class AppLocalizationsVi extends AppLocalizations { String get repeatYearly => 'Hằng năm'; @override - String get repeatEvery => 'Lặp lại mỗi'; + String get repeatEvery => 'Khoảng lặp lại'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => 'Lặp lại vào'; @override - String get repeatEnd => 'Kết thúc lập lại'; + String get repeatEnd => 'Kết thúc lặp lại'; @override - String get repeatNever => 'Never'; + String get repeatNever => 'Không bao giờ'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => 'Vào ngày'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => 'Sau một số lần xuất hiện'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => 'Số lần lặp'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => 'Ngày trong tháng'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => 'Tháng'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => 'Vị trí ngày trong tuần'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => 'Ngày cụ thể'; @override - String get repeatFirst => 'First'; + String get repeatFirst => 'Thứ nhất'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => 'Thứ hai'; @override - String get repeatThird => 'Third'; + String get repeatThird => 'Thứ ba'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => 'Thứ tư'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => 'Thứ năm'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => 'Áp chót'; @override - String get repeatLast => 'Last'; + String get repeatLast => 'Cuối cùng'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => 'Ngày'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => 'Ngày trong tuần'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => 'Ngày cuối tuần'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return 'Mỗi $count ngày'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return 'Mỗi $count tuần'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return 'Mỗi $count tháng'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return 'Mỗi $count năm'; } @override String repeatOnDaysSummary(String days) { - return 'on $days'; + return 'vào $days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'on day $days'; + return 'vào ngày $days trong tháng'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return 'vào $days thứ $ordinal'; } @override String repeatInMonthsSummary(String months) { - return 'in $months'; + return 'vào $months'; } @override @@ -1410,12 +1410,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String repeatUntilSummary(String date) { - return 'cho đến $date'; + return 'đến $date'; } @override String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + 'Quy tắc lặp lại này sử dụng các tùy chọn mà trình chỉnh sửa này không thay đổi.'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1475,21 +1475,21 @@ class AppLocalizationsVi extends AppLocalizations { String get subtasks => 'Công việc con'; @override - String get duplicateTask => 'Duplicate task'; + String get duplicateTask => 'Nhân bản công việc'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => 'Đã nhân bản công việc.'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return 'Không thể nhân bản công việc: $error'; } @override - String get hideSubtasks => 'Hide subtasks'; + String get hideSubtasks => 'Ẩn công việc phụ'; @override - String get hideClosedSubtasks => 'Hide closed subtasks'; + String get hideClosedSubtasks => 'Ẩn công việc phụ đã đóng'; @override String get moveToTop => 'Chuyển lên đầu'; @@ -1518,7 +1518,7 @@ class AppLocalizationsVi extends AppLocalizations { String get updated => 'Đã cập nhật'; @override - String get parent => 'Công việc cha'; + String get parent => 'Công việc chính'; @override String get position => 'Vị trí'; @@ -1788,7 +1788,7 @@ class AppLocalizationsVi extends AppLocalizations { String get notificationSnoozeAction => 'Báo lại sau 10 phút'; @override - String get notificationDismissAction => 'Bỏ qua'; + String get notificationDismissAction => 'Đóng'; @override String get notificationDetailsHidden => @@ -1844,14 +1844,14 @@ class AppLocalizationsVi extends AppLocalizations { String get noLocationsFound => 'Không tìm thấy địa điểm'; @override - String get requiredField => 'This field is required.'; + String get requiredField => 'Trường này là bắt buộc.'; @override String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + 'Kết nối lịch và công việc từ một trong các nhà cung cấp sau.'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Lịch Apple iCloud'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1860,188 +1860,188 @@ class AppLocalizationsVi extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Công việc Nextcloud'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => 'Thêm tài khoản Lịch Apple iCloud'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => 'Thêm tài khoản Nextcloud'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => 'Đang kết nối với Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => 'Đang chờ ủy quyền Nextcloud…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => 'Kết nối Lịch Apple iCloud'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Email tài khoản Apple'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'Mật khẩu dành riêng cho ứng dụng'; @override String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + 'Tạo mật khẩu dành riêng cho ứng dụng sau khi bật xác thực hai yếu tố cho tài khoản Apple.'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + 'Đặt lại mật khẩu tài khoản Apple sẽ thu hồi các mật khẩu dành riêng cho ứng dụng.'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => 'Kết nối Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Máy chủ Nextcloud hoặc địa chỉ CalDAV'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + 'Nhập URL máy chủ Nextcloud hoặc dán địa chỉ CalDAV chính được sao chép từ Nextcloud.'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax sẽ mở trình duyệt. Phê duyệt quyền truy cập ở đó rồi quay lại BusyMax.'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => 'Kết nối'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => 'Hủy kết nối'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + 'Tài khoản đã bị xóa cục bộ nhưng không thể thu hồi mật khẩu ứng dụng Nextcloud.'; @override String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + 'Dữ liệu lịch và công việc được lưu trong bộ nhớ đệm cục bộ để sử dụng ngoại tuyến.'; @override String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + 'Kết nối lại tài khoản này để tiếp tục đồng bộ.'; @override String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + 'Tài khoản này tạm thời không khả dụng.'; @override String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + 'Quyền máy chủ đã thay đổi. Các chỉnh sửa đang chờ bị tạm dừng.'; @override String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + 'Máy chủ hoặc hồ sơ nhà cung cấp này không được hỗ trợ.'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => 'Lịch và danh sách công việc'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => 'Sự kiện lịch'; @override - String get taskContent => 'Tasks'; + String get taskContent => 'Công việc'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => 'Chỉ đọc'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => 'Đang chờ cục bộ'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => 'Bị chặn do xung đột'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => 'Bị chặn cho đến khi kết nối lại'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => 'Thao tác không thành công'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => 'Giữ phiên bản máy chủ'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => 'Xem lại và áp dụng lại thay đổi cục bộ'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => 'Nhân bản thành mục mới'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => 'Trạng thái kết nối'; @override - String get davConnected => 'Connected'; + String get davConnected => 'Đã kết nối'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => 'Đang kết nối…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => 'Đã đăng xuất'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return 'Lần đồng bộ thành công gần nhất: $time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => 'Chưa đồng bộ'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => 'Làm mới lịch và danh sách công việc'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return 'Máy chủ: $host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => 'Lịch sự kiện'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => 'Danh sách công việc'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => 'Sự kiện và công việc'; @override - String get writableCollection => 'Writable'; + String get writableCollection => 'Có thể ghi'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => 'Đã chia sẻ'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return 'Đồng bộ lần cuối: $time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return 'Sự cố đồng bộ: $code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => 'Xung đột đồng bộ'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return 'Máy chủ đã thay đổi: $time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return 'Chỉnh sửa cục bộ: $summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => 'Không thể giải quyết xung đột.'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => 'Phạm vi sự kiện lặp lại'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => 'Toàn bộ chuỗi'; @override String get singleOccurrence => 'Sự kiện này'; @@ -2073,7 +2073,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + 'Chọn áp dụng thay đổi này cho toàn bộ chuỗi, chỉ sự kiện này hay sự kiện này và các sự kiện tiếp theo.'; @override String get taskDueBeforeStart => @@ -2106,178 +2106,178 @@ class AppLocalizationsVi extends AppLocalizations { } @override - String get calendarCannotRemove => 'Không thể xóa lịch này khỏi tài khoản.'; + String get calendarCannotRemove => + 'Không thể xóa hoặc gỡ lịch này khỏi tài khoản.'; @override String get calendarPendingChangesPreventRemoval => - 'Hãy đợi các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.'; + 'Hãy chờ các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => 'Gói đăng ký lịch'; @override String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + 'Thêm lịch chỉ đọc được làm mới từ URL WebCal bảo mật.'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => 'Thêm gói đăng ký lịch'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => 'Tên cục bộ'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => 'URL gói đăng ký'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + 'Nhập URL HTTPS hoặc webcal. BusyMax giữ URL đầy đủ trong bộ nhớ an toàn.'; @override String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + 'Nhập URL HTTPS hoặc webcal hợp lệ không có thông tin người dùng hoặc phân đoạn.'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => 'Màu cục bộ'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => 'Sử dụng màu sáu chữ số như #3584E4.'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => 'Nhập màu thập lục phân sáu chữ số.'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => 'Tần suất làm mới'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => 'Tự động'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => 'Hàng giờ'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => 'Mỗi sáu giờ'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => 'Hàng ngày'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return 'Nguồn: $origin'; } @override String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + 'Nhập URL hợp lệ để xem trước nguồn an toàn.'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => 'Gói đăng ký chỉ đọc'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => 'Chưa làm mới'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return 'Lần làm mới thành công gần nhất: $time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return 'Lần làm mới tiếp theo: $time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => 'Đã cập nhật'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return 'Sự cố làm mới: $code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => 'Làm mới ngay'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => 'Hủy đăng ký'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return 'Hủy đăng ký “$name”?'; } @override String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + 'Thao tác này xóa gói đăng ký cục bộ và các sự kiện đã lưu trong bộ nhớ đệm. Lịch đã xuất bản không thay đổi.'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => 'Thêm gói đăng ký'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return 'Gói đăng ký lịch không thành công: $error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => 'Gói đăng ký'; @override String get calendarImport => 'Nhập lịch'; @override String get calendarImportDescription => - 'Chọn một tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.'; + 'Chọn tệp, xem lại các sự kiện rồi chọn lịch có thể ghi để nhận chúng.'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => 'Nhập tệp .ics'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => 'Nhập sự kiện lịch'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return 'Bộ sự kiện có thể nhập: $count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return 'Sự kiện không hợp lệ: $count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return 'Cố ý bỏ qua: $fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => 'Không có lịch đích có thể ghi.'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => 'Lịch đích'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => 'Nhập sự kiện'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => 'Đã nhập xong'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return 'Đã nhập hoặc xếp hàng: $count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return 'Đã bỏ qua bản sao: $count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return 'Bộ lặp lại không được hỗ trợ: $count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return 'Không thể nhập tệp lịch: $error'; } @override diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 2d59ad34..91b80a20 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -17,7 +17,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + '连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。'; @override String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; @@ -40,7 +40,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。'; @override String get onboardingPreferencesStepTitle => '选择系统设置'; @@ -212,68 +212,68 @@ class AppLocalizationsZh extends AppLocalizations { String get trayOpenBusyMax => '打开 BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => '显示 BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => '新活动…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => '新任务…'; @override - String get trayToday => 'Today'; + String get trayToday => '今天'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => '全天'; @override - String get trayNow => 'Now'; + String get trayNow => '现在'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => '日历活动'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => '无标题活动'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => '今天没有其他内容'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '今天有 $count 个任务到期', + one: '今天有 1 个任务到期', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => '打开今天的日程'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => '立即同步'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => '正在同步…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => '未连接'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => '尚未同步'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => '刚刚同步'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: '$count 分钟前同步', + one: '1 分钟前同步', ); return '$_temp0'; } @@ -283,8 +283,8 @@ class AppLocalizationsZh extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: '$count 小时前同步', + one: '1 小时前同步', ); return '$_temp0'; } @@ -294,17 +294,17 @@ class AppLocalizationsZh extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: '$count 天前同步', + one: '1 天前同步', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => '设置'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => '退出 BusyMax'; @override String get agendaLoadMoreOverdue => '加载更多逾期任务'; @@ -386,130 +386,123 @@ class AppLocalizationsZh extends AppLocalizations { String get guests => '参与者'; @override - String get noGuests => '没有参与者'; + String get noGuests => '无参与者'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '必需'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '可选'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '会议'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => '添加 Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => '添加 Microsoft Teams 会议'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => '已添加在线会议'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '请求回复'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => '要求参与者回复邀请。'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => '隐藏参与者列表'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => '参与者无法查看其他受邀者。'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '允许提出新时间'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => '参与者可以建议其他会议时间。'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '通知参与者?'; @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或活动更新吗?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => '此会议有参与者。删除时发送取消通知吗?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '发送更新'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => '发送取消通知'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '不发送'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '保存会议?'; @override - String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或活动更新。'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '删除会议?'; @override - String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + String get microsoftNotifyGuestsDeleteMessage => 'Microsoft 将向参与者发送取消通知。'; @override - String get organizer => 'Organizer'; + String get organizer => '组织者'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '你的回复'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => '参与者回复'; @override - String get respond => 'Respond'; + String get respond => '回复'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '接受'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '暂定'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '拒绝'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '加入会议'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '已接受'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '暂定'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '已拒绝'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '等待回复'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '未回复'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '组织者'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '无法发送回复:$error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '无法打开会议链接。'; @override String get description => '说明'; @@ -847,7 +840,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + '这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。'; @override String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; @@ -866,21 +859,21 @@ class AppLocalizationsZh extends AppLocalizations { '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; @override - String get newTaskList => '新建任务列表'; + String get newTaskList => '新任务列表'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return '无法创建任务列表:$error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return '无法重命名任务列表:$error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return '无法删除任务列表:$error'; } @override @@ -908,12 +901,10 @@ class AppLocalizationsZh extends AppLocalizations { String get unshare => '取消共享'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get readOnlyTaskListCannotRename => '此任务列表为只读,无法重命名。'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '使用当前权限无法删除此任务列表。'; @override String get builtInMicrosoftList => '内置'; @@ -924,17 +915,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '从 Google Tasks 中删除“$title”?'; + return '要从 Google Tasks 中删除“$title”吗?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '要删除“$title”及其所有任务吗?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '要取消此账户对“$title”的共享吗?'; } @override @@ -1059,72 +1050,72 @@ class AppLocalizationsZh extends AppLocalizations { String get doneStatus => '已完成'; @override - String get taskStatus => 'Status'; + String get taskStatus => '状态'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => '无状态'; @override String get taskStatusNeedsAction => '需要操作'; @override - String get taskStatusInProcess => '处理中'; + String get taskStatusInProcess => '进行中'; @override - String get taskStatusCompleted => '完成'; + String get taskStatusCompleted => '已完成'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => '已取消'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '已完成 $percent%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '完成日期'; @override String get priority => '优先级'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '无优先级'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return '优先级 $priority · 高'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return '优先级 $priority · 中'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return '优先级 $priority · 低'; } @override - String get taskUrl => 'URL'; + String get taskUrl => '任务 URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => '请输入包含方案的绝对 URL。'; @override - String get classification => 'Classification'; + String get classification => '分类'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '共享时显示完整任务'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '共享时仅显示忙碌状态'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '共享时隐藏此任务'; @override - String get pinTask => 'Pin task'; + String get pinTask => '固定任务'; @override String get notes => '备注'; @@ -1163,75 +1154,74 @@ class AppLocalizationsZh extends AppLocalizations { String get addReminder => '添加提醒'; @override - String get reminders => 'Reminders'; + String get reminders => '提醒'; @override String get noReminders => '无提醒'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => '编辑提醒'; @override String get beforeTaskStarts => '任务开始前'; @override - String get beforeTaskDue => '任务截止前'; + String get beforeTaskDue => '任务到期前'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => '任务开始后'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => '任务到期后'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => '相对于任务开始日期'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => '相对于任务到期日期'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '时间'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '在指定日期和时间'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '数量'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '单位'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '秒'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '分钟'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '小时'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '天'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '周'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => '任务开始时'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => '任务到期时'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => '此提醒类型会保留,但无法编辑其时间。'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '保留相关提醒?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return '此日期有 $count 个相关提醒。要保留它们当前的日期和时间吗?'; } @override @@ -1274,86 +1264,86 @@ class AppLocalizationsZh extends AppLocalizations { String get repeatYearly => '每年'; @override - String get repeatEvery => '重复每'; + String get repeatEvery => '重复间隔'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '重复日期'; @override String get repeatEnd => '结束重复'; @override - String get repeatNever => 'Never'; + String get repeatNever => '从不'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '指定日期'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '指定次数后'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '重复次数'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '每月日期'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '月份'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '星期位置'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '特定日期'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '第一'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '第二'; @override - String get repeatThird => 'Third'; + String get repeatThird => '第三'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '第四'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '第五'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '倒数第二'; @override - String get repeatLast => 'Last'; + String get repeatLast => '最后'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '日期'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '工作日'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '周末'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return '每 $count 天'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return '每 $count 周'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return '每 $count 个月'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return '每 $count 年'; } @override @@ -1363,12 +1353,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return '在第 $days 天'; + return '每月 $days 日'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$days 的第 $ordinal 个'; } @override @@ -1378,17 +1368,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count次'; + return '重复 $count 次'; } @override String repeatUntilSummary(String date) { - return '至 $date'; + return '直到 $date'; } @override - String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + String get unsupportedRecurrencePreserved => '此重复规则使用了编辑器不会更改的选项。'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -1450,18 +1439,18 @@ class AppLocalizationsZh extends AppLocalizations { String get duplicateTask => '复制任务'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => '任务已复制。'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return '无法复制任务:$error'; } @override String get hideSubtasks => '隐藏子任务'; @override - String get hideClosedSubtasks => '隐藏关闭的子任务'; + String get hideClosedSubtasks => '隐藏已关闭的子任务'; @override String get moveToTop => '移到顶部'; @@ -1753,7 +1742,7 @@ class AppLocalizationsZh extends AppLocalizations { String get notificationSnoozeAction => '10 分钟后提醒'; @override - String get notificationDismissAction => '忽略'; + String get notificationDismissAction => '关闭'; @override String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; @@ -1808,14 +1797,13 @@ class AppLocalizationsZh extends AppLocalizations { String get noLocationsFound => '未找到地点'; @override - String get requiredField => 'This field is required.'; + String get requiredField => '此字段为必填项。'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '连接以下任一提供商中的日历和任务。'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud 日历'; @override String get nextcloudProvider => 'Nextcloud'; @@ -1824,188 +1812,182 @@ class AppLocalizationsZh extends AppLocalizations { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud 任务'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => '添加 Apple iCloud 日历账户'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => '添加 Nextcloud 账户'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => '正在连接 Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => '正在等待 Nextcloud 授权…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => '连接 Apple iCloud 日历'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple 账户电子邮件'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App 专用密码'; @override - String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + String get appleAppSpecificPasswordHelp => '为 Apple 账户启用双重认证后,创建 App 专用密码。'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + '重置 Apple 账户密码会撤销 App 专用密码。'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => '连接 Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud 服务器或 CalDAV 地址'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + '输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '连接'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '取消连接'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + '账户已在本地移除,但无法撤销 Nextcloud App 密码。'; @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String get davCachedOfflineNotice => '日历和任务数据会缓存在本地,以供离线使用。'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '重新连接此账户以恢复同步。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => '此账户暂时不可用。'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => '服务器权限已更改。待处理的编辑已暂停。'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => '不支持此服务器或提供商配置。'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => '日历和任务列表'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => '日历活动'; @override - String get taskContent => 'Tasks'; + String get taskContent => '任务'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '只读'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => '本地待处理'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '因冲突而阻止'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '重新连接前阻止'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '操作失败'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => '保留服务器版本'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => '查看并重新应用本地更改'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '复制为新项目'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '连接状态'; @override - String get davConnected => 'Connected'; + String get davConnected => '已连接'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '正在连接…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => '已退出登录'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '上次成功同步:$time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => '尚未同步'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => '刷新日历和任务列表'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return '服务器:$host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '活动日历'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => '任务列表'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '活动和任务'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '可写'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '已共享'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '上次同步:$time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '同步问题:$code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '同步冲突'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return '服务器更改:$time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return '本地编辑:$summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '无法解决冲突。'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => '重复活动范围'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => '整个系列'; @override String get singleOccurrence => '此事件'; @@ -2034,8 +2016,7 @@ class AppLocalizationsZh extends AppLocalizations { String get copyAndDelete => '复制并删除'; @override - String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。'; @override String get taskDueBeforeStart => '截止时间不能早于开始时间。'; @@ -2066,177 +2047,172 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get calendarCannotRemove => '无法从此账号中删除或移除此日历。'; + String get calendarCannotRemove => '无法从此账户删除或移除此日历。'; @override String get calendarPendingChangesPreventRemoval => - '请等待此日历的待处理更改同步完成,然后再删除或移除它。'; + '请等待此日历的待处理更改完成同步,然后再删除或移除它。'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => '日历订阅'; @override - String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + String get calendarSubscriptionsDescription => '添加从安全 WebCal URL 刷新的只读日历。'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => '添加日历订阅'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => '本地名称'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => '订阅 URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + '输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。'; @override - String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + String get subscriptionUrlInvalid => '请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => '本地颜色'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => '使用六位颜色,例如 #3584E4。'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => '请输入六位十六进制颜色。'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => '刷新频率'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => '自动'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => '每小时'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => '每六小时'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => '每天'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return '来源:$origin'; } @override - String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + String get subscriptionSafeOriginUnavailable => '请输入有效 URL 以预览其安全来源。'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => '只读订阅'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => '尚未刷新'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return '上次成功刷新:$time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return '下次刷新:$time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => '已是最新'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return '刷新问题:$code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => '立即刷新'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => '取消订阅'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '要取消订阅“$name”吗?'; } @override - String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的活动。已发布的日历不会更改。'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => '添加订阅'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return '日历订阅失败:$error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => '订阅'; @override - String get calendarImport => '导入日历'; + String get calendarImport => '日历导入'; @override - String get calendarImportDescription => '选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。'; + String get calendarImportDescription => '选择文件,查看其中的活动,然后选择接收这些活动的可写日历。'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => '导入 .ics 文件'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => '导入日历活动'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return '可导入活动集:$count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return '无效活动:$count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return '有意省略:$fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => '没有可用的可写目标日历。'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => '目标日历'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => '导入活动'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => '导入完成'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return '已导入或已排队:$count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return '已跳过重复项:$count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return '不支持的重复集:$count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return '无法导入日历文件:$error'; } @override @@ -2258,7 +2234,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + '连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。'; @override String get googlePermissionsConsentNotice => '在 Google 权限页面上,同时选择日历和任务权限。'; @@ -2281,7 +2257,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。'; @override String get onboardingPreferencesStepTitle => '选择系统设置'; @@ -2453,68 +2429,68 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get trayOpenBusyMax => '打开 BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => '显示 BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => '新活动…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => '新任务…'; @override - String get trayToday => 'Today'; + String get trayToday => '今天'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => '全天'; @override - String get trayNow => 'Now'; + String get trayNow => '现在'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => '日历活动'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => '无标题活动'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => '今天没有其他内容'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '今天有 $count 个任务到期', + one: '今天有 1 个任务到期', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => '打开今天的日程'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => '立即同步'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => '正在同步…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => '未连接'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => '尚未同步'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => '刚刚同步'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: '$count 分钟前同步', + one: '1 分钟前同步', ); return '$_temp0'; } @@ -2524,8 +2500,8 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: '$count 小时前同步', + one: '1 小时前同步', ); return '$_temp0'; } @@ -2535,17 +2511,17 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: '$count 天前同步', + one: '1 天前同步', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => '设置'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => '退出 BusyMax'; @override String get agendaLoadMoreOverdue => '加载更多逾期任务'; @@ -2627,130 +2603,123 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get guests => '参与者'; @override - String get noGuests => '没有参与者'; + String get noGuests => '无参与者'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '必需'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '可选'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '会议'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => '添加 Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => '添加 Microsoft Teams 会议'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => '已添加在线会议'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '请求回复'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => '要求参与者回复邀请。'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => '隐藏参与者列表'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => '参与者无法查看其他受邀者。'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '允许提出新时间'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => '参与者可以建议其他会议时间。'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '通知参与者?'; @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或活动更新吗?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => '此会议有参与者。删除时发送取消通知吗?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '发送更新'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => '发送取消通知'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '不发送'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '保存会议?'; @override - String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或活动更新。'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '删除会议?'; @override - String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + String get microsoftNotifyGuestsDeleteMessage => 'Microsoft 将向参与者发送取消通知。'; @override - String get organizer => 'Organizer'; + String get organizer => '组织者'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '你的回复'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => '参与者回复'; @override - String get respond => 'Respond'; + String get respond => '回复'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '接受'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '暂定'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '拒绝'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '加入会议'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '已接受'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '暂定'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '已拒绝'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '等待回复'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '未回复'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '组织者'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '无法发送回复:$error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '无法打开会议链接。'; @override String get description => '说明'; @@ -3088,7 +3057,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + '这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。'; @override String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; @@ -3107,21 +3076,21 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; @override - String get newTaskList => '新建任务列表'; + String get newTaskList => '新任务列表'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return '无法创建任务列表:$error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return '无法重命名任务列表:$error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return '无法删除任务列表:$error'; } @override @@ -3149,12 +3118,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get unshare => '取消共享'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get readOnlyTaskListCannotRename => '此任务列表为只读,无法重命名。'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '使用当前权限无法删除此任务列表。'; @override String get builtInMicrosoftList => '内置'; @@ -3165,17 +3132,17 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String deleteListConfirmation(String title) { - return '从 Google Tasks 中删除“$title”?'; + return '要从 Google Tasks 中删除“$title”吗?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '要删除“$title”及其所有任务吗?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '要取消此账户对“$title”的共享吗?'; } @override @@ -3300,72 +3267,72 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get doneStatus => '已完成'; @override - String get taskStatus => 'Status'; + String get taskStatus => '状态'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => '无状态'; @override String get taskStatusNeedsAction => '需要操作'; @override - String get taskStatusInProcess => '处理中'; + String get taskStatusInProcess => '进行中'; @override - String get taskStatusCompleted => '完成'; + String get taskStatusCompleted => '已完成'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => '已取消'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '已完成 $percent%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '完成日期'; @override String get priority => '优先级'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '无优先级'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return '优先级 $priority · 高'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return '优先级 $priority · 中'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return '优先级 $priority · 低'; } @override - String get taskUrl => 'URL'; + String get taskUrl => '任务 URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => '请输入包含方案的绝对 URL。'; @override - String get classification => 'Classification'; + String get classification => '分类'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '共享时显示完整任务'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '共享时仅显示忙碌状态'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '共享时隐藏此任务'; @override - String get pinTask => 'Pin task'; + String get pinTask => '固定任务'; @override String get notes => '备注'; @@ -3404,75 +3371,74 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get addReminder => '添加提醒'; @override - String get reminders => 'Reminders'; + String get reminders => '提醒'; @override String get noReminders => '无提醒'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => '编辑提醒'; @override String get beforeTaskStarts => '任务开始前'; @override - String get beforeTaskDue => '任务截止前'; + String get beforeTaskDue => '任务到期前'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => '任务开始后'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => '任务到期后'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => '相对于任务开始日期'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => '相对于任务到期日期'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '时间'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '在指定日期和时间'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '数量'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '单位'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '秒'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '分钟'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '小时'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '天'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '周'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => '任务开始时'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => '任务到期时'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => '此提醒类型会保留,但无法编辑其时间。'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '保留相关提醒?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return '此日期有 $count 个相关提醒。要保留它们当前的日期和时间吗?'; } @override @@ -3515,86 +3481,86 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get repeatYearly => '每年'; @override - String get repeatEvery => '重复每'; + String get repeatEvery => '重复间隔'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '重复日期'; @override String get repeatEnd => '结束重复'; @override - String get repeatNever => 'Never'; + String get repeatNever => '从不'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '指定日期'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '指定次数后'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '重复次数'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '每月日期'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '月份'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '星期位置'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '特定日期'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '第一'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '第二'; @override - String get repeatThird => 'Third'; + String get repeatThird => '第三'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '第四'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '第五'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '倒数第二'; @override - String get repeatLast => 'Last'; + String get repeatLast => '最后'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '日期'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '工作日'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '周末'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return '每 $count 天'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return '每 $count 周'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return '每 $count 个月'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return '每 $count 年'; } @override @@ -3604,12 +3570,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String repeatOnMonthDaysSummary(String days) { - return '在第 $days 天'; + return '每月 $days 日'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$days 的第 $ordinal 个'; } @override @@ -3619,17 +3585,16 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String repeatTimesSummary(int count) { - return '$count次'; + return '重复 $count 次'; } @override String repeatUntilSummary(String date) { - return '至 $date'; + return '直到 $date'; } @override - String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + String get unsupportedRecurrencePreserved => '此重复规则使用了编辑器不会更改的选项。'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -3691,18 +3656,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get duplicateTask => '复制任务'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => '任务已复制。'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return '无法复制任务:$error'; } @override String get hideSubtasks => '隐藏子任务'; @override - String get hideClosedSubtasks => '隐藏关闭的子任务'; + String get hideClosedSubtasks => '隐藏已关闭的子任务'; @override String get moveToTop => '移到顶部'; @@ -3994,7 +3959,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get notificationSnoozeAction => '10 分钟后提醒'; @override - String get notificationDismissAction => '忽略'; + String get notificationDismissAction => '关闭'; @override String get notificationDetailsHidden => '根据隐私设置,详细信息已隐藏。'; @@ -4049,14 +4014,13 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get noLocationsFound => '未找到地点'; @override - String get requiredField => 'This field is required.'; + String get requiredField => '此字段为必填项。'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '连接以下任一提供商中的日历和任务。'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud 日历'; @override String get nextcloudProvider => 'Nextcloud'; @@ -4065,188 +4029,182 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud 任务'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => '添加 Apple iCloud 日历账户'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => '添加 Nextcloud 账户'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => '正在连接 Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => '正在等待 Nextcloud 授权…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => '连接 Apple iCloud 日历'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple 账户电子邮件'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App 专用密码'; @override - String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + String get appleAppSpecificPasswordHelp => '为 Apple 账户启用双重认证后,创建 App 专用密码。'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + '重置 Apple 账户密码会撤销 App 专用密码。'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => '连接 Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud 服务器或 CalDAV 地址'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + '输入 Nextcloud 服务器 URL,或粘贴从 Nextcloud 复制的主要 CalDAV 地址。'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax 将打开浏览器。请在那里批准访问,然后返回 BusyMax。'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '连接'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '取消连接'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + '账户已在本地移除,但无法撤销 Nextcloud App 密码。'; @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String get davCachedOfflineNotice => '日历和任务数据会缓存在本地,以供离线使用。'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '重新连接此账户以恢复同步。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => '此账户暂时不可用。'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => '服务器权限已更改。待处理的编辑已暂停。'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => '不支持此服务器或提供商配置。'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => '日历和任务列表'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => '日历活动'; @override - String get taskContent => 'Tasks'; + String get taskContent => '任务'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '只读'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => '本地待处理'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '因冲突而阻止'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '重新连接前阻止'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '操作失败'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => '保留服务器版本'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => '查看并重新应用本地更改'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '复制为新项目'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '连接状态'; @override - String get davConnected => 'Connected'; + String get davConnected => '已连接'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '正在连接…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => '已退出登录'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '上次成功同步:$time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => '尚未同步'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => '刷新日历和任务列表'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return '服务器:$host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '活动日历'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => '任务列表'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '活动和任务'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '可写'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '已共享'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '上次同步:$time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '同步问题:$code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '同步冲突'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return '服务器更改:$time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return '本地编辑:$summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '无法解决冲突。'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => '重复活动范围'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => '整个系列'; @override String get singleOccurrence => '此事件'; @@ -4275,8 +4233,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get copyAndDelete => '复制并删除'; @override - String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。'; @override String get taskDueBeforeStart => '截止时间不能早于开始时间。'; @@ -4307,177 +4264,172 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { } @override - String get calendarCannotRemove => '无法从此账号中删除或移除此日历。'; + String get calendarCannotRemove => '无法从此账户删除或移除此日历。'; @override String get calendarPendingChangesPreventRemoval => - '请等待此日历的待处理更改同步完成,然后再删除或移除它。'; + '请等待此日历的待处理更改完成同步,然后再删除或移除它。'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => '日历订阅'; @override - String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + String get calendarSubscriptionsDescription => '添加从安全 WebCal URL 刷新的只读日历。'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => '添加日历订阅'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => '本地名称'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => '订阅 URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + '输入 HTTPS 或 webcal URL。BusyMax 会将完整 URL 保存在安全存储中。'; @override - String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + String get subscriptionUrlInvalid => '请输入不含用户信息或片段的有效 HTTPS 或 webcal URL。'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => '本地颜色'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => '使用六位颜色,例如 #3584E4。'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => '请输入六位十六进制颜色。'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => '刷新频率'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => '自动'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => '每小时'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => '每六小时'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => '每天'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return '来源:$origin'; } @override - String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + String get subscriptionSafeOriginUnavailable => '请输入有效 URL 以预览其安全来源。'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => '只读订阅'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => '尚未刷新'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return '上次成功刷新:$time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return '下次刷新:$time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => '已是最新'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return '刷新问题:$code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => '立即刷新'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => '取消订阅'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '要取消订阅“$name”吗?'; } @override - String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的活动。已发布的日历不会更改。'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => '添加订阅'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return '日历订阅失败:$error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => '订阅'; @override - String get calendarImport => '导入日历'; + String get calendarImport => '日历导入'; @override - String get calendarImportDescription => '选择文件并检查其中的日程,然后选择要接收这些日程的可写日历。'; + String get calendarImportDescription => '选择文件,查看其中的活动,然后选择接收这些活动的可写日历。'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => '导入 .ics 文件'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => '导入日历活动'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return '可导入活动集:$count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return '无效活动:$count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return '有意省略:$fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => '没有可用的可写目标日历。'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => '目标日历'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => '导入活动'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => '导入完成'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return '已导入或已排队:$count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return '已跳过重复项:$count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return '不支持的重复集:$count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return '无法导入日历文件:$error'; } @override @@ -4499,7 +4451,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get connectGoogleAccount => - 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + '連接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 帳戶。'; @override String get googlePermissionsConsentNotice => '在 Google 權限畫面中,同時選取行事曆和待辦事項權限。'; @@ -4522,7 +4474,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get onboardingAccountsStepDescription => - 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + '新增所有要使用的帳戶。BusyMax 會同步每個帳戶中支援的行事曆、活動、待辦清單和待辦事項。'; @override String get onboardingPreferencesStepTitle => '選擇系統設定'; @@ -4691,71 +4643,71 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get refresh => '重新整理'; @override - String get trayOpenBusyMax => '開啟 BusyMax'; + String get trayOpenBusyMax => '打開 BusyMax'; @override - String get trayShowBusyMax => 'Show BusyMax'; + String get trayShowBusyMax => '顯示 BusyMax'; @override - String get trayNewEvent => 'New event…'; + String get trayNewEvent => '新增活動…'; @override - String get trayNewTask => 'New task…'; + String get trayNewTask => '新增待辦事項…'; @override - String get trayToday => 'Today'; + String get trayToday => '今天'; @override - String get trayAllDay => 'All day'; + String get trayAllDay => '全天'; @override - String get trayNow => 'Now'; + String get trayNow => '現在'; @override - String get trayCalendarEvent => 'Calendar event'; + String get trayCalendarEvent => '行事曆活動'; @override - String get trayUntitledEvent => 'Untitled event'; + String get trayUntitledEvent => '未命名活動'; @override - String get trayNothingElseToday => 'Nothing else today'; + String get trayNothingElseToday => '今天沒有其他內容'; @override String trayTasksDueToday(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', - one: '1 task due today', + other: '今天有 $count 個任務到期', + one: '今天有 1 個任務到期', ); return '$_temp0'; } @override - String get trayOpenTodayAgenda => 'Open today’s agenda'; + String get trayOpenTodayAgenda => '打開今天的日程'; @override - String get traySyncNow => 'Sync now'; + String get traySyncNow => '立即同步'; @override - String get traySyncing => 'Syncing…'; + String get traySyncing => '正在同步…'; @override - String get trayNotConnected => 'Not connected'; + String get trayNotConnected => '未連接'; @override - String get trayNotYetSynced => 'Not yet synced'; + String get trayNotYetSynced => '尚未同步'; @override - String get trayLastSyncedJustNow => 'Last synced just now'; + String get trayLastSyncedJustNow => '剛剛同步'; @override String trayLastSyncedMinutesAgo(int count) { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', - one: 'Last synced 1 minute ago', + other: '$count 分鐘前同步', + one: '1 分鐘前同步', ); return '$_temp0'; } @@ -4765,8 +4717,8 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', - one: 'Last synced 1 hour ago', + other: '$count 小時前同步', + one: '1 小時前同步', ); return '$_temp0'; } @@ -4776,17 +4728,17 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', - one: 'Last synced 1 day ago', + other: '$count 天前同步', + one: '1 天前同步', ); return '$_temp0'; } @override - String get traySettings => 'Settings'; + String get traySettings => '設定'; @override - String get trayQuitBusyMax => 'Quit BusyMax'; + String get trayQuitBusyMax => '結束 BusyMax'; @override String get agendaLoadMoreOverdue => '載入更多逾期待辦事項'; @@ -4868,130 +4820,123 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get guests => '參與者'; @override - String get noGuests => '沒有參與者'; + String get noGuests => '無參與者'; @override - String get attendeeRequired => 'Required'; + String get attendeeRequired => '必要'; @override - String get attendeeOptional => 'Optional'; + String get attendeeOptional => '選用'; @override - String get meetingSection => 'Meeting'; + String get meetingSection => '會議'; @override - String get addGoogleMeet => 'Add Google Meet'; + String get addGoogleMeet => '添加 Google Meet'; @override - String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + String get addTeamsMeeting => '添加 Microsoft Teams 會議'; @override - String get onlineMeetingAdded => 'Online meeting added'; + String get onlineMeetingAdded => '已添加在線會議'; @override - String get requestResponses => 'Request responses'; + String get requestResponses => '要求回覆'; @override - String get requestResponsesDescription => - 'Ask guests to respond to the invitation.'; + String get requestResponsesDescription => '要求參與者回復邀請。'; @override - String get hideGuestList => 'Hide guest list'; + String get hideGuestList => '隱藏參與者清單'; @override - String get hideGuestListDescription => - 'Guests cannot see who else was invited.'; + String get hideGuestListDescription => '參與者無法查看其他受邀者。'; @override - String get allowNewTimeProposals => 'Allow new time proposals'; + String get allowNewTimeProposals => '允許提出新時間'; @override - String get allowNewTimeProposalsDescription => - 'Guests can suggest a different meeting time.'; + String get allowNewTimeProposalsDescription => '參與者可以建議其他會議時間。'; @override - String get notifyGuestsTitle => 'Notify guests?'; + String get notifyGuestsTitle => '通知參與者?'; @override - String get notifyGuestsSaveMessage => - 'This meeting has guests. Send invitations or event updates when it is saved?'; + String get notifyGuestsSaveMessage => '此會議有參與者。保存時發送邀請或活動更新嗎?'; @override - String get notifyGuestsDeleteMessage => - 'This meeting has guests. Send a cancellation when it is deleted?'; + String get notifyGuestsDeleteMessage => '此會議有參與者。刪除時發送取消通知嗎?'; @override - String get sendUpdates => 'Send updates'; + String get sendUpdates => '傳送更新'; @override - String get sendCancellation => 'Send cancellation'; + String get sendCancellation => '傳送取消通知'; @override - String get doNotSend => 'Don’t send'; + String get doNotSend => '不要傳送'; @override - String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + String get microsoftNotifyGuestsSaveTitle => '保存會議?'; @override - String get microsoftNotifyGuestsSaveMessage => - 'Microsoft will send invitations or event updates to guests.'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 將向參與者發送邀請或活動更新。'; @override - String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + String get microsoftNotifyGuestsDeleteTitle => '刪除會議?'; @override - String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft will send a cancellation to guests.'; + String get microsoftNotifyGuestsDeleteMessage => 'Microsoft 將向參與者發送取消通知。'; @override - String get organizer => 'Organizer'; + String get organizer => '主辦人'; @override - String get yourResponse => 'Your response'; + String get yourResponse => '你的回覆'; @override - String get guestResponses => 'Guest responses'; + String get guestResponses => '參與者回覆'; @override - String get respond => 'Respond'; + String get respond => '回復'; @override - String get acceptInvitation => 'Accept'; + String get acceptInvitation => '接受'; @override - String get tentativeInvitation => 'Tentative'; + String get tentativeInvitation => '暫定'; @override - String get declineInvitation => 'Decline'; + String get declineInvitation => '拒絕'; @override - String get joinMeeting => 'Join meeting'; + String get joinMeeting => '加入會議'; @override - String get responseAccepted => 'Accepted'; + String get responseAccepted => '已接受'; @override - String get responseTentative => 'Tentative'; + String get responseTentative => '暫定'; @override - String get responseDeclined => 'Declined'; + String get responseDeclined => '已拒絕'; @override - String get responseNeedsAction => 'Awaiting response'; + String get responseNeedsAction => '等待回復'; @override - String get responseNotResponded => 'Not responded'; + String get responseNotResponded => '未回復'; @override - String get responseOrganizer => 'Organizer'; + String get responseOrganizer => '組織者'; @override String invitationResponseFailed(String error) { - return 'Could not send your response: $error'; + return '無法發送回復:$error'; } @override - String get joinMeetingFailed => 'Could not open the meeting link.'; + String get joinMeetingFailed => '無法打開會議鏈接。'; @override String get description => '說明'; @@ -5324,12 +5269,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String removeAccountTitle(String account) { - return '要從 BusyMax 移除 $account 嗎?'; + return '從 BusyMax 中移除 $account?'; } @override String get removeAccountConfirmation => - 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + '這會從此設備刪除緩存的任務、日曆、活動、提醒和待處理的離線更改。未同步的更改將會丟失。提供商中的日曆、活動、任務列表和任務副本不會被刪除。'; @override String get revokeGoogleAccess => '同時撤銷 BusyMax 對此 Google 帳戶的存取權'; @@ -5348,21 +5293,21 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { '該帳戶已從此裝置移除,但無法撤銷 BusyMax 對您的 Google 帳戶的存取權。您可以在 Google 帳戶中手動撤銷該權限。'; @override - String get newTaskList => '新增待辦清單'; + String get newTaskList => '新任務列表'; @override String taskListCreateFailed(String error) { - return 'Could not create the task list: $error'; + return '無法創建任務列表:$error'; } @override String taskListRenameFailed(String error) { - return 'Could not rename the task list: $error'; + return '無法重命名任務列表:$error'; } @override String taskListDeleteFailed(String error) { - return 'Could not delete the task list: $error'; + return '無法刪除任務列表:$error'; } @override @@ -5387,15 +5332,13 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get deleteList => '刪除清單'; @override - String get unshare => '撤回分享'; + String get unshare => '取消共享'; @override - String get readOnlyTaskListCannotRename => - 'This task list is read-only and cannot be renamed.'; + String get readOnlyTaskListCannotRename => '此任務列表為只讀,無法重命名。'; @override - String get taskListCannotDelete => - 'This task list cannot be deleted with your current permissions.'; + String get taskListCannotDelete => '使用當前權限無法刪除此任務列表。'; @override String get builtInMicrosoftList => '內建'; @@ -5406,17 +5349,17 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String deleteListConfirmation(String title) { - return '要從 Google Tasks 刪除「$title」嗎?'; + return '要從 Google Tasks 中刪除“$title”嗎?'; } @override String deleteTaskListConfirmation(String title) { - return 'Delete \"$title\" and all of its tasks?'; + return '要刪除“$title”及其所有任務嗎?'; } @override String unshareTaskListConfirmation(String title) { - return 'Unshare \"$title\" from this account?'; + return '要取消此賬戶對“$title”的共享嗎?'; } @override @@ -5541,10 +5484,10 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get doneStatus => '已完成'; @override - String get taskStatus => 'Status'; + String get taskStatus => '狀態'; @override - String get taskStatusNone => 'No status'; + String get taskStatusNone => '無狀態'; @override String get taskStatusNeedsAction => '需要動作'; @@ -5553,60 +5496,60 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get taskStatusInProcess => '進行中'; @override - String get taskStatusCompleted => '完成'; + String get taskStatusCompleted => '已完成'; @override - String get taskStatusCancelled => 'Cancelled'; + String get taskStatusCancelled => '已取消'; @override String completionPercent(int percent) { - return '$percent% completed'; + return '已完成 $percent%'; } @override - String get completionDate => 'Completion date'; + String get completionDate => '完成日期'; @override - String get priority => '優先'; + String get priority => '優先順序'; @override - String get priorityNone => 'No priority'; + String get priorityNone => '無優先順序'; @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + return '優先級 $priority · 高'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + return '優先級 $priority · 中'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + return '優先級 $priority · 低'; } @override - String get taskUrl => 'URL'; + String get taskUrl => '任務 URL'; @override - String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + String get invalidTaskUrl => '請輸入包含方案的絕對 URL。'; @override - String get classification => 'Classification'; + String get classification => '分類'; @override - String get classificationPublic => 'When shared, show the full task'; + String get classificationPublic => '共享時顯示完整任務'; @override - String get classificationConfidential => 'When shared, show only busy'; + String get classificationConfidential => '共享時僅顯示忙碌狀態'; @override - String get classificationPrivate => 'When shared, hide this task'; + String get classificationPrivate => '共享時隱藏此任務'; @override - String get pinTask => 'Pin task'; + String get pinTask => '固定任務'; @override String get notes => '備註'; @@ -5642,78 +5585,77 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get reminder => '提醒'; @override - String get addReminder => '新增提醒'; + String get addReminder => '添加提醒'; @override - String get reminders => 'Reminders'; + String get reminders => '提醒'; @override String get noReminders => '無提醒'; @override - String get editReminder => 'Edit reminder'; + String get editReminder => '編輯提醒'; @override - String get beforeTaskStarts => '在任務開始前'; + String get beforeTaskStarts => '待辦事項開始前'; @override - String get beforeTaskDue => '任務截止前'; + String get beforeTaskDue => '待辦事項到期前'; @override - String get afterTaskStarts => 'After the task starts'; + String get afterTaskStarts => '任務開始後'; @override - String get afterTaskDue => 'After the task is due'; + String get afterTaskDue => '任務到期後'; @override - String get relativeToTaskStart => 'Relative to the task start date'; + String get relativeToTaskStart => '相對於任務開始日期'; @override - String get relativeToTaskDue => 'Relative to the task due date'; + String get relativeToTaskDue => '相對於任務到期日期'; @override - String get reminderTimeOfDay => 'Time of day'; + String get reminderTimeOfDay => '時間'; @override - String get absoluteReminder => 'At a date and time'; + String get absoluteReminder => '在指定日期和時間'; @override - String get reminderAmount => 'Amount'; + String get reminderAmount => '數量'; @override - String get reminderUnit => 'Unit'; + String get reminderUnit => '單位'; @override - String get reminderUnitSeconds => 'Seconds'; + String get reminderUnitSeconds => '秒'; @override - String get reminderUnitMinutes => 'Minutes'; + String get reminderUnitMinutes => '分鐘'; @override - String get reminderUnitHours => 'Hours'; + String get reminderUnitHours => '小時'; @override - String get reminderUnitDays => 'Days'; + String get reminderUnitDays => '天'; @override - String get reminderUnitWeeks => 'Weeks'; + String get reminderUnitWeeks => '週'; @override - String get reminderAtTaskStart => 'At the task start'; + String get reminderAtTaskStart => '任務開始時'; @override - String get reminderAtTaskDue => 'At the task due time'; + String get reminderAtTaskDue => '任務到期時'; @override - String get unsupportedReminder => - 'This reminder type is preserved but its time cannot be edited.'; + String get unsupportedReminder => '此提醒類型會保留,但無法編輯其時間。'; @override - String get relatedRemindersTitle => 'Keep related reminders?'; + String get relatedRemindersTitle => '保留相關提醒?'; @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + return '此日期有 $count 個相關提醒。要保留它們當前的日期和時間嗎?'; } @override @@ -5756,101 +5698,101 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get repeatYearly => '每年'; @override - String get repeatEvery => '重複循環'; + String get repeatEvery => '重複間隔'; @override - String get repeatOn => 'Repeat on'; + String get repeatOn => '重複日期'; @override - String get repeatEnd => '停止重複'; + String get repeatEnd => '結束重複'; @override - String get repeatNever => 'Never'; + String get repeatNever => '永不'; @override - String get repeatUntil => 'On date'; + String get repeatUntil => '指定日期'; @override - String get repeatAfter => 'After a number of occurrences'; + String get repeatAfter => '指定次數後'; @override - String get repeatCount => 'Occurrences'; + String get repeatCount => '重複次數'; @override - String get repeatDayOfMonth => 'Days of month'; + String get repeatDayOfMonth => '每月日期'; @override - String get repeatMonths => 'Months'; + String get repeatMonths => '月份'; @override - String get repeatOrdinal => 'Weekday position'; + String get repeatOrdinal => '星期位置'; @override - String get repeatSpecificDays => 'Specific days'; + String get repeatSpecificDays => '特定日期'; @override - String get repeatFirst => 'First'; + String get repeatFirst => '第一'; @override - String get repeatSecond => 'Second'; + String get repeatSecond => '第二'; @override - String get repeatThird => 'Third'; + String get repeatThird => '第三'; @override - String get repeatFourth => 'Fourth'; + String get repeatFourth => '第四'; @override - String get repeatFifth => 'Fifth'; + String get repeatFifth => '第五'; @override - String get repeatSecondToLast => 'Second to last'; + String get repeatSecondToLast => '倒數第二'; @override - String get repeatLast => 'Last'; + String get repeatLast => '最後'; @override - String get repeatAnyDay => 'Day'; + String get repeatAnyDay => '日期'; @override - String get repeatWeekday => 'Weekday'; + String get repeatWeekday => '工作日'; @override - String get repeatWeekendDay => 'Weekend day'; + String get repeatWeekendDay => '週末'; @override String repeatEveryDays(int count) { - return 'Every $count days'; + return '每 $count 天'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + return '每 $count 週'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + return '每 $count 個月'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + return '每 $count 年'; } @override String repeatOnDaysSummary(String days) { - return '於 $days'; + return '在 $days'; } @override String repeatOnMonthDaysSummary(String days) { - return '於 $days 天'; + return '每月 $days 日'; } @override String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + return '$days 的第 $ordinal 個'; } @override @@ -5860,17 +5802,16 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String repeatTimesSummary(int count) { - return '$count 次'; + return '重複 $count 次'; } @override String repeatUntilSummary(String date) { - return '到 $date'; + return '直到 $date'; } @override - String get unsupportedRecurrencePreserved => - 'This recurrence rule uses options that this editor does not change.'; + String get unsupportedRecurrencePreserved => '此重複規則使用了編輯器不會更改的選項。'; @override String recurrenceUnsupportedByProvider(String provider) { @@ -5930,14 +5871,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get subtasks => '子待辦事項'; @override - String get duplicateTask => '再製任務'; + String get duplicateTask => '複製待辦事項'; @override - String get taskDuplicated => 'Task duplicated.'; + String get taskDuplicated => '任務已複製。'; @override String taskDuplicateFailed(String error) { - return 'Could not duplicate the task: $error'; + return '無法複製任務:$error'; } @override @@ -6200,7 +6141,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String conflictNotificationBody(String summary) { - return '一項待處理的本機變更遭到封鎖。$summary'; + return '一項擱置中的本機變更已被封鎖。$summary'; } @override @@ -6236,7 +6177,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get notificationSnoozeAction => '10 分鐘後提醒'; @override - String get notificationDismissAction => '忽略'; + String get notificationDismissAction => '關閉'; @override String get notificationDetailsHidden => '根據隱私權設定,詳細資料已隱藏。'; @@ -6285,20 +6226,19 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get selectTimeZone => '選擇時區'; @override - String get searchLocations => '搜尋地點'; + String get searchLocations => '搜索地點'; @override - String get noLocationsFound => '找不到地點'; + String get noLocationsFound => '未找到地點'; @override - String get requiredField => 'This field is required.'; + String get requiredField => '此字段為必填項。'; @override - String get providerConnectionDescription => - 'Connect calendars and tasks from one of these providers.'; + String get providerConnectionDescription => '連接以下任一提供商中的日曆和任務。'; @override - String get appleICloudProvider => 'Apple iCloud Calendar'; + String get appleICloudProvider => 'Apple iCloud 行事曆'; @override String get nextcloudProvider => 'Nextcloud'; @@ -6307,188 +6247,182 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get appleICloudTasksProvider => 'Apple iCloud'; @override - String get nextcloudTasksProvider => 'Nextcloud Tasks'; + String get nextcloudTasksProvider => 'Nextcloud 待辦事項'; @override - String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + String get addAppleICloudAccount => '新增 Apple iCloud 行事曆帳戶'; @override - String get addNextcloudAccount => 'Add Nextcloud account'; + String get addNextcloudAccount => '新增 Nextcloud 帳戶'; @override - String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + String get waitingForAppleICloud => '正在連接 Apple iCloud…'; @override - String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + String get waitingForNextcloud => '正在等待 Nextcloud 授權…'; @override - String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + String get connectAppleICloudTitle => '連接 Apple iCloud 行事曆'; @override - String get appleAccountEmail => 'Apple Account email'; + String get appleAccountEmail => 'Apple 賬戶電子郵件'; @override - String get appleAppSpecificPassword => 'App-specific password'; + String get appleAppSpecificPassword => 'App 專用密碼'; @override - String get appleAppSpecificPasswordHelp => - 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + String get appleAppSpecificPasswordHelp => '為 Apple 賬戶啓用雙重認證後,創建 App 專用密碼。'; @override String get appleAppSpecificPasswordResetWarning => - 'Resetting your Apple Account password revokes app-specific passwords.'; + '重置 Apple 賬戶密碼會撤銷 App 專用密碼。'; @override - String get connectNextcloudTitle => 'Connect Nextcloud'; + String get connectNextcloudTitle => '連接 Nextcloud'; @override - String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + String get nextcloudServerUrl => 'Nextcloud 伺服器或 CalDAV 位址'; @override String get nextcloudServerUrlHelp => - 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + '輸入 Nextcloud 服務器 URL,或粘貼從 Nextcloud 複製的主要 CalDAV 地址。'; @override String get nextcloudBrowserAuthorizationHelp => - 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + 'BusyMax 將打開瀏覽器。請在那裡批准訪問,然後返回 BusyMax。'; @override - String get connectAccountAction => 'Connect'; + String get connectAccountAction => '連接'; @override - String get cancelAccountConnection => 'Cancel connection'; + String get cancelAccountConnection => '取消連接'; @override String get nextcloudAccountRemovedRevokeFailed => - 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + '賬戶已在本地移除,但無法撤銷 Nextcloud App 密碼。'; @override - String get davCachedOfflineNotice => - 'Calendar and task data is cached locally for offline use.'; + String get davCachedOfflineNotice => '日曆和任務數據會緩存在本地,以供離線使用。'; @override - String get davReauthenticationRequired => - 'Reconnect this account to resume synchronization.'; + String get davReauthenticationRequired => '重新連接此賬戶以恢復同步。'; @override - String get davTemporarilyUnavailable => - 'This account is temporarily unavailable.'; + String get davTemporarilyUnavailable => '此賬戶暫時不可用。'; @override - String get davPermissionChanged => - 'Server permissions changed. Pending edits are paused.'; + String get davPermissionChanged => '服務器權限已更改。待處理的編輯已暫停。'; @override - String get davUnsupportedServer => - 'This server or provider profile is not supported.'; + String get davUnsupportedServer => '不支持此服務器或提供商配置。'; @override - String get collectionSettings => 'Calendars and task lists'; + String get collectionSettings => '行事曆和待辦清單'; @override - String get calendarContent => 'Calendar events'; + String get calendarContent => '行事曆活動'; @override - String get taskContent => 'Tasks'; + String get taskContent => '待辦事項'; @override - String get readOnlySharedCollection => 'Read-only'; + String get readOnlySharedCollection => '唯讀'; @override - String get pendingLocally => 'Pending locally'; + String get pendingLocally => '本機擱置中'; @override - String get conflictBlocked => 'Blocked by conflict'; + String get conflictBlocked => '因衝突而封鎖'; @override - String get authenticationBlocked => 'Blocked until reconnect'; + String get authenticationBlocked => '重新連接前阻止'; @override - String get operationFailed => 'Operation failed'; + String get operationFailed => '操作失敗'; @override - String get keepServerVersion => 'Keep server version'; + String get keepServerVersion => '保留伺服器版本'; @override - String get reapplyLocalChange => 'Review and reapply local change'; + String get reapplyLocalChange => '查看並重新應用本地更改'; @override - String get duplicateLocalItem => 'Duplicate as new item'; + String get duplicateLocalItem => '複製為新項目'; @override - String get davConnectionState => 'Connection state'; + String get davConnectionState => '連線狀態'; @override - String get davConnected => 'Connected'; + String get davConnected => '已連線'; @override - String get davConnecting => 'Connecting…'; + String get davConnecting => '正在連線…'; @override - String get davSignedOut => 'Signed out'; + String get davSignedOut => '已登出'; @override String davLastSuccessfulSync(String time) { - return 'Last successful sync: $time'; + return '上次成功同步:$time'; } @override - String get davNeverSynced => 'Not synchronized yet'; + String get davNeverSynced => '尚未同步'; @override - String get refreshCollections => 'Refresh calendars and task lists'; + String get refreshCollections => '刷新日曆和任務列表'; @override String nextcloudServerHost(String host) { - return 'Server: $host'; + return '服務器:$host'; } @override - String get collectionSupportsEvents => 'Event calendar'; + String get collectionSupportsEvents => '活動日曆'; @override - String get collectionSupportsTasks => 'Task list'; + String get collectionSupportsTasks => '任務列表'; @override - String get collectionSupportsEventsAndTasks => 'Events and tasks'; + String get collectionSupportsEventsAndTasks => '活動和任務'; @override - String get writableCollection => 'Writable'; + String get writableCollection => '可寫'; @override - String get sharedCollection => 'Shared'; + String get sharedCollection => '已共享'; @override String collectionLastSynced(String time) { - return 'Last synchronized: $time'; + return '上次同步:$time'; } @override String collectionSyncError(String code) { - return 'Sync issue: $code'; + return '同步問題:$code'; } @override - String get syncConflicts => 'Synchronization conflicts'; + String get syncConflicts => '同步衝突'; @override String remoteChangedAt(String time) { - return 'Server changed: $time'; + return '服務器更改:$time'; } @override String localPendingEdit(String summary) { - return 'Local edit: $summary'; + return '本地編輯:$summary'; } @override - String get conflictResolutionFailed => 'The conflict could not be resolved.'; + String get conflictResolutionFailed => '無法解決衝突。'; @override - String get recurringEventScope => 'Recurring event scope'; + String get recurringEventScope => '重複活動範圍'; @override - String get entireSeries => 'Entire series'; + String get entireSeries => '整個系列'; @override String get singleOccurrence => '此事件'; @@ -6517,14 +6451,13 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get copyAndDelete => '複製並刪除'; @override - String get chooseRecurringEventScope => - 'Choose whether this change applies to the entire series or only this occurrence.'; + String get chooseRecurringEventScope => '選擇此變更適用於整個系列、僅此活動,還是此活動及後續活動。'; @override String get taskDueBeforeStart => '到期時間不得早於開始時間。'; @override - String get taskStartDueTimeModeMismatch => '請同時設定開始與到期時間,或將工作設為全天。'; + String get taskStartDueTimeModeMismatch => '請同時設定開始與到期時間,或將待辦事項設為全天。'; @override String deleteCalendarConfirmation(String title) { @@ -6549,177 +6482,172 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { } @override - String get calendarCannotRemove => '無法從此帳戶刪除或移除此日曆。'; + String get calendarCannotRemove => '無法從此賬戶刪除或移除此日曆。'; @override String get calendarPendingChangesPreventRemoval => - '請等待此行事曆的待處理變更同步完成,再刪除或移除它。'; + '請等待此日曆的待處理更改完成同步,然後再刪除或移除它。'; @override - String get calendarSubscriptions => 'Calendar subscriptions'; + String get calendarSubscriptions => '行事曆訂閱'; @override - String get calendarSubscriptionsDescription => - 'Add read-only calendars that refresh from a secure WebCal URL.'; + String get calendarSubscriptionsDescription => '添加從安全 WebCal URL 刷新的只讀日曆。'; @override - String get addCalendarSubscription => 'Add calendar subscription'; + String get addCalendarSubscription => '新增行事曆訂閱'; @override - String get subscriptionName => 'Local name'; + String get subscriptionName => '本機名稱'; @override - String get subscriptionUrl => 'Subscription URL'; + String get subscriptionUrl => '訂閱 URL'; @override String get subscriptionUrlHelp => - 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + '輸入 HTTPS 或 webcal URL。BusyMax 會將完整 URL 保存在安全存儲中。'; @override - String get subscriptionUrlInvalid => - 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + String get subscriptionUrlInvalid => '請輸入不含用戶信息或片段的有效 HTTPS 或 webcal URL。'; @override - String get subscriptionColor => 'Local color'; + String get subscriptionColor => '本地顏色'; @override - String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + String get subscriptionColorHelp => '使用六位顏色,例如 #3584E4。'; @override - String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + String get subscriptionColorInvalid => '請輸入六位十六進制顏色。'; @override - String get subscriptionRefreshMode => 'Refresh frequency'; + String get subscriptionRefreshMode => '刷新頻率'; @override - String get subscriptionAutomatic => 'Automatic'; + String get subscriptionAutomatic => '自動'; @override - String get subscriptionHourly => 'Hourly'; + String get subscriptionHourly => '每小時'; @override - String get subscriptionSixHours => 'Every six hours'; + String get subscriptionSixHours => '每六小時'; @override - String get subscriptionDaily => 'Daily'; + String get subscriptionDaily => '每天'; @override String subscriptionSafeOrigin(String origin) { - return 'Source: $origin'; + return '來源:$origin'; } @override - String get subscriptionSafeOriginUnavailable => - 'Enter a valid URL to preview its safe origin.'; + String get subscriptionSafeOriginUnavailable => '請輸入有效 URL 以預覽其安全來源。'; @override - String get subscriptionReadOnly => 'Read-only subscription'; + String get subscriptionReadOnly => '只讀訂閱'; @override - String get subscriptionNeverRefreshed => 'Not refreshed yet'; + String get subscriptionNeverRefreshed => '尚未刷新'; @override String subscriptionLastRefresh(String time) { - return 'Last successful refresh: $time'; + return '上次成功刷新:$time'; } @override String subscriptionNextRefresh(String time) { - return 'Next refresh: $time'; + return '下次刷新:$time'; } @override - String get subscriptionStatusHealthy => 'Up to date'; + String get subscriptionStatusHealthy => '已是最新'; @override String subscriptionStatusIssue(String code) { - return 'Refresh issue: $code'; + return '刷新問題:$code'; } @override - String get refreshNow => 'Refresh now'; + String get refreshNow => '立即重新整理'; @override - String get unsubscribe => 'Unsubscribe'; + String get unsubscribe => '取消訂閱'; @override String unsubscribeCalendarTitle(String name) { - return 'Unsubscribe from “$name”?'; + return '要取消訂閱“$name”嗎?'; } @override - String get unsubscribeCalendarConfirmation => - 'This removes the local subscription and its cached events. The published calendar is not changed.'; + String get unsubscribeCalendarConfirmation => '這會移除本地訂閱及其緩存的活動。已發佈的日曆不會更改。'; @override - String get addSubscriptionAction => 'Add subscription'; + String get addSubscriptionAction => '添加訂閱'; @override String subscriptionOperationFailed(String error) { - return 'Calendar subscription failed: $error'; + return '日曆訂閱失敗:$error'; } @override - String get subscriptions => 'Subscriptions'; + String get subscriptions => '訂閱'; @override String get calendarImport => '匯入行事曆'; @override - String get calendarImportDescription => '選擇檔案並檢查其中的行程,然後選擇要接收這些行程的可寫入行事曆。'; + String get calendarImportDescription => '選擇文件,查看其中的活動,然後選擇接收這些活動的可寫日曆。'; @override - String get importIcsFile => 'Import .ics file'; + String get importIcsFile => '匯入 .ics 檔案'; @override - String get importIcsPreview => 'Import calendar events'; + String get importIcsPreview => '匯入行事曆活動'; @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + return '可導入活動集:$count'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + return '無效活動:$count'; } @override String importFieldsOmitted(String fields) { - return 'Intentionally omitted: $fields'; + return '有意省略:$fields'; } @override - String get noWritableCalendars => - 'No writable destination calendar is available.'; + String get noWritableCalendars => '沒有可用的可寫目標日曆。'; @override - String get importDestinationCalendar => 'Destination calendar'; + String get importDestinationCalendar => '目標日曆'; @override - String get importIcsConfirm => 'Import events'; + String get importIcsConfirm => '導入活動'; @override - String get importIcsComplete => 'Import complete'; + String get importIcsComplete => '匯入完成'; @override String importQueued(int count) { - return 'Imported or queued: $count'; + return '已導入或已排隊:$count'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + return '已跳過重複項:$count'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + return '不支持的重複集:$count'; } @override String importIcsFailed(String error) { - return 'Could not import the calendar file: $error'; + return '無法導入日曆文件:$error'; } @override diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 0069f9ef..1ba6f528 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -55,11 +55,54 @@ void main() { if (translation.trim().isEmpty) { failures.add('$path: $key has an empty translation'); } - for (final placeholder in _declaredPlaceholders(templateArb, key)) { + final declaredPlaceholders = + _declaredPlaceholders(templateArb, key).toSet(); + for (final placeholder in declaredPlaceholders) { if (!translation.contains('{$placeholder')) { failures.add('$path: $key does not use {$placeholder}'); } } + for (final placeholder in _usedPlaceholders(translation)) { + if (!declaredPlaceholders.contains(placeholder)) { + failures.add('$path: $key adds {$placeholder}'); + } + } + } + } + + expect(failures, isEmpty, reason: failures.join('\n')); + }); + + test('translated ARB catalogs do not silently copy English messages', () { + final template = _decodeArb(File('lib/l10n/app_en.arb')); + final templateMessages = _messages(template); + const allowedIdenticalKeys = { + 'appTitle', + 'apacheLicenseName', + 'dateTimeDisplay', + 'etag', + 'formatBoldShortLabel', + 'formatItalicShortLabel', + 'formatUnderlineShortLabel', + 'googleProvider', + 'googleTasksApi', + 'googleTasksProvider', + 'id', + 'microsoftProvider', + 'microsoftTodoProvider', + 'nextcloudProvider', + 'appleICloudTasksProvider', + 'taskUrl', + }; + final failures = []; + + for (final file in _translatedArbFiles()) { + final messages = _messages(_decodeArb(file)); + for (final key in templateMessages.keys) { + if (allowedIdenticalKeys.contains(key)) continue; + if (messages[key] == templateMessages[key]) { + failures.add('${file.path}: $key copies the English value'); + } } } @@ -512,6 +555,11 @@ Iterable _declaredPlaceholders( yield* placeholders.keys.cast(); } +Iterable _usedPlaceholders(String value) sync* { + final pattern = RegExp(r'\{([A-Za-z][A-Za-z0-9_]*)(?=,|\})'); + yield* pattern.allMatches(value).map((match) => match.group(1)!); +} + int _lineForOffset(String source, int offset) { return '\n'.allMatches(source.substring(0, offset)).length + 1; } From 331027524f2fef85b878b9d9450ae8f3e73249f1 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 15:24:28 -0700 Subject: [PATCH 13/69] fix translations --- lib/l10n/app_ar.arb | 32 +- lib/l10n/app_de.arb | 24 +- lib/l10n/app_en.arb | 625 +++++++++++++++--- lib/l10n/app_es.arb | 24 +- lib/l10n/app_et.arb | 24 +- lib/l10n/app_fa.arb | 30 +- lib/l10n/app_fi.arb | 24 +- lib/l10n/app_fr.arb | 24 +- lib/l10n/app_hi.arb | 24 +- lib/l10n/app_it.arb | 24 +- lib/l10n/app_ja.arb | 26 +- lib/l10n/app_ko.arb | 24 +- lib/l10n/app_pt.arb | 30 +- lib/l10n/app_ru.arb | 24 +- lib/l10n/app_vi.arb | 24 +- lib/l10n/app_zh.arb | 24 +- lib/l10n/app_zh_Hans.arb | 24 +- lib/l10n/app_zh_Hant.arb | 24 +- lib/l10n/generated/app_localizations.dart | 12 +- lib/l10n/generated/app_localizations_ar.dart | 27 +- lib/l10n/generated/app_localizations_de.dart | 19 +- lib/l10n/generated/app_localizations_en.dart | 19 +- lib/l10n/generated/app_localizations_es.dart | 19 +- lib/l10n/generated/app_localizations_et.dart | 19 +- lib/l10n/generated/app_localizations_fa.dart | 25 +- lib/l10n/generated/app_localizations_fi.dart | 19 +- lib/l10n/generated/app_localizations_fr.dart | 19 +- lib/l10n/generated/app_localizations_hi.dart | 19 +- lib/l10n/generated/app_localizations_it.dart | 19 +- lib/l10n/generated/app_localizations_ja.dart | 21 +- lib/l10n/generated/app_localizations_ko.dart | 19 +- lib/l10n/generated/app_localizations_pt.dart | 25 +- lib/l10n/generated/app_localizations_ru.dart | 19 +- lib/l10n/generated/app_localizations_vi.dart | 19 +- lib/l10n/generated/app_localizations_zh.dart | 57 +- .../presentation/recurrence_editor.dart | 26 +- test/app/localization_audit_test.dart | 96 ++- 37 files changed, 1371 insertions(+), 182 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 5d29196c..6d1464bc 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -265,7 +265,7 @@ "removeAccount": "إزالة الحساب…", "removingAccount": "جارٍ إزالة الحساب…", "removeAccountDescription": "إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.", - "removeAccountTitle": "إزالة {account} من BusyMax؟", + "removeAccountTitle": "إزالة ⁨{account}⁩ من BusyMax؟", "removeAccountConfirmation": "سيؤدي هذا إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المخزنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات التي لم تتم مزامنتها، ولن تُحذف نسخ التقويمات والأحداث وقوائم المهام والمهام لدى موفّر الخدمة.", "revokeGoogleAccess": "إلغاء وصول BusyMax إلى حساب Google هذا أيضًا", "revokeGoogleAccessDescription": "ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.", @@ -282,7 +282,7 @@ "deleteList": "حذف القائمة", "builtInMicrosoftList": "مدمجة", "builtInMicrosoftListCannotRenameDelete": "لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.", - "deleteListConfirmation": "حذف \"{title}\" من Google Tasks؟", + "deleteListConfirmation": "حذف \"⁨{title}⁩\" من Google Tasks؟", "deleteEvent": "حذف الحدث", "title": "العنوان", "create": "إنشاء", @@ -439,7 +439,7 @@ "syncFailureNotificationTitle": "فشلت مزامنة BusyMax", "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. ⁨{message}⁩", "conflictNotificationTitle": "تعارض في مزامنة BusyMax", - "conflictNotificationBody": "تم حظر تغيير محلي معلق. {summary}", + "conflictNotificationBody": "تم حظر تغيير محلي معلق. ⁨{summary}⁩", "dueTodayNotificationTitle": "المهام المستحقة اليوم", "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك ⁨{count}⁩ مهام مستحقة اليوم.} many{هناك ⁨{count}⁩ مهمة مستحقة اليوم.} other{هناك ⁨{count}⁩ مهمة مستحقة اليوم.}}", "eventReminderNotificationTitle": "تذكير بحدث", @@ -546,7 +546,7 @@ } }, "syncConflicts": "تعارضات المزامنة", - "remoteChangedAt": "غيّر الخادم في: {time}", + "remoteChangedAt": "تم التغيير على الخادم في: ⁨{time}⁩", "@remoteChangedAt": { "placeholders": { "time": { @@ -655,7 +655,7 @@ "repeatEveryYears": "كل {count} سنوات", "repeatOnDaysSummary": "في {days}", "repeatOnMonthDaysSummary": "في اليوم {days} من الشهر", - "repeatOnOrdinalSummary": "في {ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{في {days} الأول} second{في {days} الثاني} third{في {days} الثالث} fourth{في {days} الرابع} fifth{في {days} الخامس} secondToLast{في {days} ما قبل الأخير} last{في {days} الأخير} other{في {days}}}", "repeatInMonthsSummary": "في {months}", "repeatTimesSummary": "{count} مرات", "repeatUntilSummary": "حتى {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "غير متصل", "networkOfflineDescription": "ستتم مزامنة التغييرات عند استعادة الاتصال.", - "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى." + "networkOfflineTryAgain": "أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "في أيام الشهر {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e68fb2b2..fd4180a8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -718,7 +718,7 @@ "repeatEveryYears": "Alle {count} Jahre", "repeatOnDaysSummary": "an {days}", "repeatOnMonthDaysSummary": "am {days}. Tag", - "repeatOnOrdinalSummary": "am {ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{am ersten {days}} second{am zweiten {days}} third{am dritten {days}} fourth{am vierten {days}} fifth{am fünften {days}} secondToLast{am vorletzten {days}} last{am letzten {days}} other{an {days}}}", "repeatInMonthsSummary": "in den Monaten {months}", "repeatTimesSummary": "{count}-mal", "repeatUntilSummary": "bis {date}", @@ -920,5 +920,25 @@ }, "networkOffline": "Ohne Verbindung", "networkOfflineDescription": "Änderungen werden synchronisiert, sobald die Verbindung wiederhergestellt ist.", - "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut." + "networkOfflineTryAgain": "Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "an den Tagen {days} des Monats", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index db85e47a..f14de1e1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -42,16 +42,40 @@ "newCalendar": "New calendar", "calendarColor": "Calendar color", "calendarColorOption": "Color {number}", - "@calendarColorOption": {"placeholders": {"number": {"type": "int"}}}, + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "calendarManagementUnsupported": "This provider does not support calendar management in BusyMax.", "primaryCalendarCannotDelete": "The primary calendar cannot be deleted.", "calendarCreateFailed": "Could not create the calendar: {error}", - "@calendarCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarCreatedRefreshPending": "The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.", "calendarUpdateFailed": "Could not update the calendar: {error}", - "@calendarUpdateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "calendarDeleteFailed": "Could not delete the calendar: {error}", - "@calendarDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "newEvent": "New event", "refreshCalendar": "Refresh calendar", "openInProvider": "Open in provider", @@ -60,7 +84,13 @@ "noCalendarsSynced": "No calendars synced yet.", "allDay": "All day", "moreItems": "+{count} more", - "@moreItems": {"placeholders": {"count": {"type": "int"}}}, + "@moreItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "noEventsOrTasks": "No events or tasks", "scheduleLoading": "Loading schedule...", "scheduleUnavailable": "Schedule unavailable", @@ -82,7 +112,13 @@ "trayUntitledEvent": "Untitled event", "trayNothingElseToday": "Nothing else today", "trayTasksDueToday": "{count, plural, =1{1 task due today} other{{count} tasks due today}}", - "@trayTasksDueToday": {"placeholders": {"count": {"type": "int"}}}, + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "trayOpenTodayAgenda": "Open today’s agenda", "traySyncNow": "Sync now", "traySyncing": "Syncing…", @@ -90,11 +126,29 @@ "trayNotYetSynced": "Not yet synced", "trayLastSyncedJustNow": "Last synced just now", "trayLastSyncedMinutesAgo": "{count, plural, =1{Last synced 1 minute ago} other{Last synced {count} minutes ago}}", - "@trayLastSyncedMinutesAgo": {"placeholders": {"count": {"type": "int"}}}, + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "trayLastSyncedHoursAgo": "{count, plural, =1{Last synced 1 hour ago} other{Last synced {count} hours ago}}", - "@trayLastSyncedHoursAgo": {"placeholders": {"count": {"type": "int"}}}, + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "trayLastSyncedDaysAgo": "{count, plural, =1{Last synced 1 day ago} other{Last synced {count} days ago}}", - "@trayLastSyncedDaysAgo": {"placeholders": {"count": {"type": "int"}}}, + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "traySettings": "Settings", "trayQuitBusyMax": "Quit BusyMax", "agendaLoadMoreOverdue": "Load more overdue tasks", @@ -161,7 +215,13 @@ "responseNotResponded": "Not responded", "responseOrganizer": "Organizer", "invitationResponseFailed": "Could not send your response: {error}", - "@invitationResponseFailed": {"placeholders": {"error": {"type": "String"}}}, + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "joinMeetingFailed": "Could not open the meeting link.", "description": "Description", "availabilityShowAs": "Availability / Show as", @@ -178,12 +238,30 @@ "formatUnderlineShortLabel": "U", "formatUnderlineTooltip": "Underline", "reminderMinutesBefore": "{minutes, plural, =1{1 minute before} other{{minutes} minutes before}}", - "@reminderMinutesBefore": {"placeholders": {"minutes": {"type": "int"}}}, + "@reminderMinutesBefore": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "reminderAtStart": "At start", "reminderHoursBefore": "{hours, plural, =1{1 hour before} other{{hours} hours before}}", - "@reminderHoursBefore": {"placeholders": {"hours": {"type": "int"}}}, + "@reminderHoursBefore": { + "placeholders": { + "hours": { + "type": "int" + } + } + }, "reminderDaysBefore": "{days, plural, =1{1 day before} other{{days} days before}}", - "@reminderDaysBefore": {"placeholders": {"days": {"type": "int"}}}, + "@reminderDaysBefore": { + "placeholders": { + "days": { + "type": "int" + } + } + }, "availabilityFree": "Free", "availabilityTentative": "Tentative", "availabilityOutOfOffice": "Out of office", @@ -197,7 +275,13 @@ "tasks": "Tasks", "allTasks": "All tasks", "tasksInList": "Tasks in {title}", - "@tasksInList": {"placeholders": {"title": {"type": "String"}}}, + "@tasksInList": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "taskLists": "Task lists", "navigation": "Navigation", "mainMenu": "Main Menu", @@ -253,7 +337,13 @@ "feedbackRejectedError": "The server rejected the submission. Review the fields and try again.", "feedbackServerError": "BusyStack could not accept your feedback right now. Your feedback has not been cleared; please try again.", "feedbackSuccess": "Feedback sent. Reference: {id}", - "@feedbackSuccess": {"placeholders": {"id": {"type": "String"}}}, + "@feedbackSuccess": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "toggleSidebar": "Toggle Sidebar", "showSidebar": "Show sidebar panel", "hideSidebar": "Hide sidebar panel", @@ -269,7 +359,13 @@ "removingAccount": "Removing account…", "removeAccountDescription": "Stop syncing and remove this account’s data from this device.", "removeAccountTitle": "Remove {account} from BusyMax?", - "@removeAccountTitle": {"placeholders": {"account": {"type": "String"}}}, + "@removeAccountTitle": { + "placeholders": { + "account": { + "type": "String" + } + } + }, "removeAccountConfirmation": "This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.", "revokeGoogleAccess": "Also revoke BusyMax’s access to this Google Account", "revokeGoogleAccessDescription": "You will need to grant access again before reconnecting.", @@ -278,11 +374,29 @@ "accountRemovedGoogleRevokeFailed": "The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.", "newTaskList": "New task list", "taskListCreateFailed": "Could not create the task list: {error}", - "@taskListCreateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskListCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "taskListRenameFailed": "Could not rename the task list: {error}", - "@taskListRenameFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskListRenameFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "taskListDeleteFailed": "Could not delete the task list: {error}", - "@taskListDeleteFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskListDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "signInToViewTaskLists": "Sign in to view task lists.", "noTaskListsSynced": "No task lists synced yet.", "listActions": "List actions", @@ -296,11 +410,29 @@ "builtInMicrosoftList": "Built-in", "builtInMicrosoftListCannotRenameDelete": "Built-in Microsoft To Do lists cannot be renamed or deleted.", "deleteListConfirmation": "Delete \"{title}\" from Google Tasks?", - "@deleteListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteListConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "deleteTaskListConfirmation": "Delete \"{title}\" and all of its tasks?", - "@deleteTaskListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteTaskListConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "unshareTaskListConfirmation": "Unshare \"{title}\" from this account?", - "@unshareTaskListConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@unshareTaskListConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "deleteEvent": "Delete Event", "title": "Title", "create": "Create", @@ -311,11 +443,29 @@ "listRefreshed": "List refreshed.", "allTasksRefreshed": "All accounts refreshed.", "exportedFile": "Exported to {path}", - "@exportedFile": {"placeholders": {"path": {"type": "String"}}}, + "@exportedFile": { + "placeholders": { + "path": { + "type": "String" + } + } + }, "exportFailed": "Export failed: {error}", - "@exportFailed": {"placeholders": {"error": {"type": "String"}}}, + "@exportFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "refreshFailed": "Refresh failed: {error}", - "@refreshFailed": {"placeholders": {"error": {"type": "String"}}}, + "@refreshFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "selectOrCreateTaskList": "Select or create a task list to begin.", "signInToViewTasks": "Sign in to view tasks.", "noTasks": "No tasks.", @@ -329,12 +479,22 @@ "noDate": "No date", "completed": "Completed", "duePrefix": "Due {date}", - "@duePrefix": {"placeholders": {"date": {"type": "String"}}}, + "@duePrefix": { + "placeholders": { + "date": { + "type": "String" + } + } + }, "dateTimeDisplay": "{date} · {time}", "@dateTimeDisplay": { "placeholders": { - "date": {"type": "String"}, - "time": {"type": "String"} + "date": { + "type": "String" + }, + "time": { + "type": "String" + } } }, "taskDetails": "Task details", @@ -355,16 +515,40 @@ "taskStatusCompleted": "Completed", "taskStatusCancelled": "Cancelled", "completionPercent": "{percent}% completed", - "@completionPercent": {"placeholders": {"percent": {"type": "int"}}}, + "@completionPercent": { + "placeholders": { + "percent": { + "type": "int" + } + } + }, "completionDate": "Completion date", "priority": "Priority", "priorityNone": "No priority", "priorityHighValue": "Priority {priority} · High", - "@priorityHighValue": {"placeholders": {"priority": {"type": "int"}}}, + "@priorityHighValue": { + "placeholders": { + "priority": { + "type": "int" + } + } + }, "priorityMediumValue": "Priority {priority} · Medium", - "@priorityMediumValue": {"placeholders": {"priority": {"type": "int"}}}, + "@priorityMediumValue": { + "placeholders": { + "priority": { + "type": "int" + } + } + }, "priorityLowValue": "Priority {priority} · Low", - "@priorityLowValue": {"placeholders": {"priority": {"type": "int"}}}, + "@priorityLowValue": { + "placeholders": { + "priority": { + "type": "int" + } + } + }, "taskUrl": "URL", "invalidTaskUrl": "Enter an absolute URL, including its scheme.", "classification": "Classification", @@ -410,7 +594,13 @@ "unsupportedReminder": "This reminder type is preserved but its time cannot be edited.", "relatedRemindersTitle": "Keep related reminders?", "relatedRemindersDescription": "This date has {count} related reminders. Keep them at their current date and time?", - "@relatedRemindersDescription": {"placeholders": {"count": {"type": "int"}}}, + "@relatedRemindersDescription": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "discardRelatedReminders": "Discard reminders", "keepRelatedReminders": "Keep reminders", "addGuest": "Add Guest", @@ -452,46 +642,103 @@ "repeatWeekday": "Weekday", "repeatWeekendDay": "Weekend day", "repeatEveryDays": "Every {count} days", - "@repeatEveryDays": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryDays": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "repeatEveryWeeks": "Every {count} weeks", - "@repeatEveryWeeks": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryWeeks": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "repeatEveryMonths": "Every {count} months", - "@repeatEveryMonths": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryMonths": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "repeatEveryYears": "Every {count} years", - "@repeatEveryYears": {"placeholders": {"count": {"type": "int"}}}, + "@repeatEveryYears": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "repeatOnDaysSummary": "on {days}", "@repeatOnDaysSummary": { "description": "Sentence fragment appended to a recurrence summary; {days} is a localized list of weekdays.", - "placeholders": {"days": {"type": "String"}} + "placeholders": { + "days": { + "type": "String" + } + } }, "repeatOnMonthDaysSummary": "on day {days}", "@repeatOnMonthDaysSummary": { "description": "Sentence fragment appended to a recurrence summary; {days} is a localized list of day-of-month numbers.", - "placeholders": {"days": {"type": "String"}} + "placeholders": { + "days": { + "type": "String" + } + } }, - "repeatOnOrdinalSummary": "on the {ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{on the first {days}} second{on the second {days}} third{on the third {days}} fourth{on the fourth {days}} fifth{on the fifth {days}} secondToLast{on the second to last {days}} last{on the last {days}} other{on {days}}}", "@repeatOnOrdinalSummary": { - "description": "Sentence fragment for ordinal recurrence, such as the first Monday; {ordinal} and {days} are localized.", - "placeholders": {"ordinal": {"type": "String"}, "days": {"type": "String"}} + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } }, "repeatInMonthsSummary": "in {months}", "@repeatInMonthsSummary": { "description": "Sentence fragment for selected recurrence months; {months} is a localized list of month names.", - "placeholders": {"months": {"type": "String"}} + "placeholders": { + "months": { + "type": "String" + } + } }, "repeatTimesSummary": "{count} times", "@repeatTimesSummary": { "description": "Sentence fragment summarizing a finite recurrence count.", - "placeholders": {"count": {"type": "int"}} + "placeholders": { + "count": { + "type": "int" + } + } }, "repeatUntilSummary": "until {date}", "@repeatUntilSummary": { "description": "Sentence fragment for the recurrence end date.", - "placeholders": {"date": {"type": "String"}} + "placeholders": { + "date": { + "type": "String" + } + } }, "unsupportedRecurrencePreserved": "This recurrence rule uses options that this editor does not change.", "recurrenceUnsupportedByProvider": "This recurrence cannot be used with {provider}.", - "@recurrenceUnsupportedByProvider": {"placeholders": {"provider": {"type": "String"}}}, + "@recurrenceUnsupportedByProvider": { + "placeholders": { + "provider": { + "type": "String" + } + } + }, "importance": "Importance", "importanceLow": "Low", "importanceNormal": "Normal", @@ -512,14 +759,26 @@ "duplicateTask": "Duplicate task", "taskDuplicated": "Task duplicated.", "taskDuplicateFailed": "Could not duplicate the task: {error}", - "@taskDuplicateFailed": {"placeholders": {"error": {"type": "String"}}}, + "@taskDuplicateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "hideSubtasks": "Hide subtasks", "hideClosedSubtasks": "Hide closed subtasks", "moveToTop": "Move to top", "deleteTask": "Delete Task", "newSubtask": "New subtask", "deleteTaskConfirmation": "Delete \"{title}\"?", - "@deleteTaskConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteTaskConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "metadata": "Metadata", "id": "ID", "etag": "ETag", @@ -546,7 +805,13 @@ "requiresTrayIcon": "Requires the tray icon.", "syncComplete": "Sync complete.", "syncFailed": "Sync failed: {error}", - "@syncFailed": {"placeholders": {"error": {"type": "String"}}}, + "@syncFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "notifySyncFailures": "Notifications on sync failure", "notifyConflicts": "Notifications on conflicts", "notifyDueToday": "Due-today notifications", @@ -577,7 +842,13 @@ "apiInspectorDisabled": "Show API inspector", "googleTasksApi": "Google Tasks API", "discoveryRevision": "Discovery revision: {revision}", - "@discoveryRevision": {"placeholders": {"revision": {"type": "String"}}}, + "@discoveryRevision": { + "placeholders": { + "revision": { + "type": "String" + } + } + }, "implementedMethods": "Implemented methods", "supportsTasksScopes": "Supports tasks and tasks.readonly scopes", "requiresTasksScope": "Requires tasks scope", @@ -586,11 +857,29 @@ "noBlockedPendingOperations": "No blocked pending operations.", "operationActions": "Operation actions", "pendingOpListId": "list={id}", - "@pendingOpListId": {"placeholders": {"id": {"type": "String"}}}, + "@pendingOpListId": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "pendingOpTaskId": "task={id}", - "@pendingOpTaskId": {"placeholders": {"id": {"type": "String"}}}, + "@pendingOpTaskId": { + "placeholders": { + "id": { + "type": "String" + } + } + }, "pendingOpAttempts": "attempts={count}", - "@pendingOpAttempts": {"placeholders": {"count": {"type": "int"}}}, + "@pendingOpAttempts": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "retry": "Retry", "discard": "Discard", "discardChangesAction": "Discard", @@ -605,13 +894,31 @@ "pendingOperationDiscarded": "Pending operation discarded.", "syncFailureNotificationTitle": "BusyMax sync failed", "syncFailureNotificationBody": "Background sync failed. {message}", - "@syncFailureNotificationBody": {"placeholders": {"message": {"type": "String"}}}, + "@syncFailureNotificationBody": { + "placeholders": { + "message": { + "type": "String" + } + } + }, "conflictNotificationTitle": "BusyMax sync conflict", "conflictNotificationBody": "A pending local change was blocked. {summary}", - "@conflictNotificationBody": {"placeholders": {"summary": {"type": "String"}}}, + "@conflictNotificationBody": { + "placeholders": { + "summary": { + "type": "String" + } + } + }, "dueTodayNotificationTitle": "Tasks due today", "dueTodayNotificationBody": "{count, plural, =1{One task is due today.} other{{count} tasks are due today.}}", - "@dueTodayNotificationBody": {"placeholders": {"count": {"type": "int"}}}, + "@dueTodayNotificationBody": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "eventReminderNotificationTitle": "Event reminder", "taskReminderNotificationTitle": "Task reminder", "eventReminderNotificationBody": "Event starts soon.", @@ -630,10 +937,22 @@ "nextYear": "Next year", "openYearView": "Open year view", "weekNumberTooltip": "Week {number}", - "@weekNumberTooltip": {"placeholders": {"number": {"type": "int"}}}, + "@weekNumberTooltip": { + "placeholders": { + "number": { + "type": "int" + } + } + }, "resizeAllDayPanel": "Resize the all-day panel", "scheduleItemCount": "{count, plural, =1{1 item} other{{count} items}}", - "@scheduleItemCount": {"placeholders": {"count": {"type": "int"}}}, + "@scheduleItemCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "readOnlyCalendar": "This calendar is read-only.", "selectTimeZone": "Select Timezone", "searchLocations": "Search locations", @@ -681,25 +1000,61 @@ "davConnecting": "Connecting…", "davSignedOut": "Signed out", "davLastSuccessfulSync": "Last successful sync: {time}", - "@davLastSuccessfulSync": {"placeholders": {"time": {"type": "String"}}}, + "@davLastSuccessfulSync": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "davNeverSynced": "Not synchronized yet", "refreshCollections": "Refresh calendars and task lists", "nextcloudServerHost": "Server: {host}", - "@nextcloudServerHost": {"placeholders": {"host": {"type": "String"}}}, + "@nextcloudServerHost": { + "placeholders": { + "host": { + "type": "String" + } + } + }, "collectionSupportsEvents": "Event calendar", "collectionSupportsTasks": "Task list", "collectionSupportsEventsAndTasks": "Events and tasks", "writableCollection": "Writable", "sharedCollection": "Shared", "collectionLastSynced": "Last synchronized: {time}", - "@collectionLastSynced": {"placeholders": {"time": {"type": "String"}}}, + "@collectionLastSynced": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "collectionSyncError": "Sync issue: {code}", - "@collectionSyncError": {"placeholders": {"code": {"type": "String"}}}, + "@collectionSyncError": { + "placeholders": { + "code": { + "type": "String" + } + } + }, "syncConflicts": "Synchronization conflicts", "remoteChangedAt": "Server changed: {time}", - "@remoteChangedAt": {"placeholders": {"time": {"type": "String"}}}, + "@remoteChangedAt": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "localPendingEdit": "Local edit: {summary}", - "@localPendingEdit": {"placeholders": {"summary": {"type": "String"}}}, + "@localPendingEdit": { + "placeholders": { + "summary": { + "type": "String" + } + } + }, "conflictResolutionFailed": "The conflict could not be resolved.", "recurringEventScope": "Recurring event scope", "entireSeries": "Entire series", @@ -710,7 +1065,16 @@ "entireSeriesMoveUnavailable": "The recurrence rule is not available locally. Move this event instead.", "copyEventAndDeleteOriginal": "Copy event and delete original?", "copyEventMoveWarning": "BusyMax cannot move this event directly from {source} to {destination}. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.", - "@copyEventMoveWarning": {"placeholders": {"source": {"type": "String"}, "destination": {"type": "String"}}}, + "@copyEventMoveWarning": { + "placeholders": { + "source": { + "type": "String" + }, + "destination": { + "type": "String" + } + } + }, "copyAndDelete": "Copy and delete", "chooseRecurringEventScope": "Choose whether this change applies to the entire series, only this occurrence, or this and following events.", "@chooseRecurringEventScope": { @@ -719,13 +1083,25 @@ "taskDueBeforeStart": "Due must not be before start.", "taskStartDueTimeModeMismatch": "Set times for both start and due, or make the task all day.", "deleteCalendarConfirmation": "Delete \"{title}\"?", - "@deleteCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@deleteCalendarConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "setCustomCalendarName": "Set custom name", "setAction": "Set", "removeFromMyCalendars": "Remove from my calendars", "removeAction": "Remove", "removeCalendarConfirmation": "Remove \"{title}\" from your Google Calendar list? The shared calendar and its events will not be deleted.", - "@removeCalendarConfirmation": {"placeholders": {"title": {"type": "String"}}}, + "@removeCalendarConfirmation": { + "placeholders": { + "title": { + "type": "String" + } + } + }, "calendarCannotRemove": "This calendar cannot be deleted or removed from this account.", "calendarPendingChangesPreventRemoval": "Wait for this calendar’s pending changes to finish syncing before deleting or removing it.", "calendarSubscriptions": "Calendar subscriptions", @@ -744,49 +1120,136 @@ "subscriptionSixHours": "Every six hours", "subscriptionDaily": "Daily", "subscriptionSafeOrigin": "Source: {origin}", - "@subscriptionSafeOrigin": {"placeholders": {"origin": {"type": "String"}}}, + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, "subscriptionSafeOriginUnavailable": "Enter a valid URL to preview its safe origin.", "subscriptionReadOnly": "Read-only subscription", "subscriptionNeverRefreshed": "Not refreshed yet", "subscriptionLastRefresh": "Last successful refresh: {time}", - "@subscriptionLastRefresh": {"placeholders": {"time": {"type": "String"}}}, + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "subscriptionNextRefresh": "Next refresh: {time}", - "@subscriptionNextRefresh": {"placeholders": {"time": {"type": "String"}}}, + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "subscriptionStatusHealthy": "Up to date", "subscriptionStatusIssue": "Refresh issue: {code}", - "@subscriptionStatusIssue": {"placeholders": {"code": {"type": "String"}}}, + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, "refreshNow": "Refresh now", "unsubscribe": "Unsubscribe", "unsubscribeCalendarTitle": "Unsubscribe from “{name}”?", - "@unsubscribeCalendarTitle": {"placeholders": {"name": {"type": "String"}}}, + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "unsubscribeCalendarConfirmation": "This removes the local subscription and its cached events. The published calendar is not changed.", "addSubscriptionAction": "Add subscription", "subscriptionOperationFailed": "Calendar subscription failed: {error}", - "@subscriptionOperationFailed": {"placeholders": {"error": {"type": "String"}}}, + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "subscriptions": "Subscriptions", "calendarImport": "Calendar import", "calendarImportDescription": "Select a file, review its events, then choose the writable calendar that should receive them.", "importIcsFile": "Import .ics file", "importIcsPreview": "Import calendar events", "importEventsFound": "Importable event sets: {count}", - "@importEventsFound": {"placeholders": {"count": {"type": "int"}}}, + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "importInvalidEvents": "Invalid events: {count}", - "@importInvalidEvents": {"placeholders": {"count": {"type": "int"}}}, + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "importFieldsOmitted": "Intentionally omitted: {fields}", - "@importFieldsOmitted": {"placeholders": {"fields": {"type": "String"}}}, + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, "noWritableCalendars": "No writable destination calendar is available.", "importDestinationCalendar": "Destination calendar", "importIcsConfirm": "Import events", "importIcsComplete": "Import complete", "importQueued": "Imported or queued: {count}", - "@importQueued": {"placeholders": {"count": {"type": "int"}}}, + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "importDuplicatesSkipped": "Duplicates skipped: {count}", - "@importDuplicatesSkipped": {"placeholders": {"count": {"type": "int"}}}, + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "importUnsupportedSets": "Unsupported recurrence sets: {count}", - "@importUnsupportedSets": {"placeholders": {"count": {"type": "int"}}}, + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "importIcsFailed": "Could not import the calendar file: {error}", - "@importIcsFailed": {"placeholders": {"error": {"type": "String"}}}, + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "networkOffline": "Offline", "networkOfflineDescription": "Changes will sync when the connection is restored.", - "networkOfflineTryAgain": "You’re offline. Connect to the internet and try again." + "networkOfflineTryAgain": "You’re offline. Connect to the internet and try again.", + "repeatOnMonthDaysSummaryMultiple": "on days {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1c5b5c72..565791d0 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -718,7 +718,7 @@ "repeatEveryYears": "Cada {count} años", "repeatOnDaysSummary": "los {days}", "repeatOnMonthDaysSummary": "el día {days}", - "repeatOnOrdinalSummary": "el {ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{el primer {days}} second{el segundo {days}} third{el tercer {days}} fourth{el cuarto {days}} fifth{el quinto {days}} secondToLast{el penúltimo {days}} last{el último {days}} other{en {days}}}", "repeatInMonthsSummary": "en {months}", "repeatTimesSummary": "{count} veces", "repeatUntilSummary": "hasta {date}", @@ -920,5 +920,25 @@ }, "networkOffline": "Sin conexión", "networkOfflineDescription": "Los cambios se sincronizarán cuando se restablezca la conexión.", - "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo." + "networkOfflineTryAgain": "No tienes conexión. Conéctate a Internet e inténtalo de nuevo.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "los días {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 00179934..d082b435 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -833,7 +833,7 @@ "repeatEveryYears": "Iga {count} aasta järel", "repeatOnDaysSummary": "päevadel {days}", "repeatOnMonthDaysSummary": "kuu {days}. päeval", - "repeatOnOrdinalSummary": "{ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{esimesel {days}} second{teisel {days}} third{kolmandal {days}} fourth{neljandal {days}} fifth{viiendal {days}} secondToLast{eelviimasel {days}} last{viimasel {days}} other{päevadel {days}}}", "repeatInMonthsSummary": "kuudel {months}", "repeatTimesSummary": "{count} korda", "repeatUntilSummary": "kuni kuupäevani {date}", @@ -1035,5 +1035,25 @@ }, "networkOffline": "Võrguühenduseta", "networkOfflineDescription": "Muudatused sünkroonitakse ühenduse taastumisel.", - "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti." + "networkOfflineTryAgain": "Võrguühendus puudub. Looge internetiühendus ja proovige uuesti.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "kuu päevadel {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index b999a5f3..d87e5881 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -265,7 +265,7 @@ "removeAccount": "حذف حساب…", "removingAccount": "در حال حذف حساب…", "removeAccountDescription": "همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.", - "removeAccountTitle": "{account} از BusyMax حذف شود؟", + "removeAccountTitle": "⁨{account}⁩ از BusyMax حذف شود؟", "removeAccountConfirmation": "این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار را که در این دستگاه ذخیره شده‌اند حذف می‌کند. تغییرات همگام‌نشده از بین می‌روند. نسخه‌های تقویم‌ها، رویدادها، فهرست‌های کار و کارها نزد ارائه‌دهنده حذف نمی‌شوند.", "revokeGoogleAccess": "دسترسی BusyMax به این حساب Google نیز لغو شود", "revokeGoogleAccessDescription": "پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.", @@ -282,7 +282,7 @@ "deleteList": "حذف فهرست", "builtInMicrosoftList": "داخلی", "builtInMicrosoftListCannotRenameDelete": "فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.", - "deleteListConfirmation": "‏«{title}» از Google Tasks حذف شود؟", + "deleteListConfirmation": "‏«⁨{title}⁩» از Google Tasks حذف شود؟", "deleteEvent": "حذف رویداد", "title": "عنوان", "create": "ایجاد", @@ -439,7 +439,7 @@ "syncFailureNotificationTitle": "همگام‌سازی BusyMax ناموفق بود", "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. ⁨{message}⁩", "conflictNotificationTitle": "تداخل همگام‌سازی BusyMax", - "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. {summary}", + "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. ⁨{summary}⁩", "dueTodayNotificationTitle": "کارهای دارای سررسید امروز", "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز ⁨{count}⁩ کار سررسید دارند.}}", "eventReminderNotificationTitle": "یادآور رویداد", @@ -719,7 +719,7 @@ "repeatEveryYears": "هر {count} سال", "repeatOnDaysSummary": "در {days}", "repeatOnMonthDaysSummary": "در روز {days} ماه", - "repeatOnOrdinalSummary": "در {ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{در اولین {days}} second{در دومین {days}} third{در سومین {days}} fourth{در چهارمین {days}} fifth{در پنجمین {days}} secondToLast{در یکی‌مانده‌به‌آخرین {days}} last{در آخرین {days}} other{در {days}}}", "repeatInMonthsSummary": "در {months}", "repeatTimesSummary": "{count} بار", "repeatUntilSummary": "تا {date}", @@ -921,5 +921,25 @@ }, "networkOffline": "آفلاین", "networkOfflineDescription": "تغییرات پس از برقراری دوبارهٔ اتصال همگام‌سازی می‌شوند.", - "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید." + "networkOfflineTryAgain": "آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "در روزهای {days} ماه", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 2845629e..ae6a8d1b 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "Joka {count}. vuosi", "repeatOnDaysSummary": "päivinä {days}", "repeatOnMonthDaysSummary": "kuukauden päivinä {days}", - "repeatOnOrdinalSummary": "{ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{ensimmäisenä {days}} second{toisena {days}} third{kolmantena {days}} fourth{neljäntenä {days}} fifth{viidentenä {days}} secondToLast{toiseksi viimeisenä {days}} last{viimeisenä {days}} other{päivinä {days}}}", "repeatInMonthsSummary": "kuukausina {months}", "repeatTimesSummary": "{count} kertaa", "repeatUntilSummary": "{date} asti", @@ -857,5 +857,25 @@ }, "networkOffline": "Ei verkkoyhteyttä", "networkOfflineDescription": "Muutokset synkronoidaan, kun yhteys palautuu.", - "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen." + "networkOfflineTryAgain": "Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "kuukauden päivinä {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bfec6001..936563ee 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -718,7 +718,7 @@ "repeatEveryYears": "Toutes les {count} années", "repeatOnDaysSummary": "les {days}", "repeatOnMonthDaysSummary": "le {days} du mois", - "repeatOnOrdinalSummary": "le {ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{le premier {days}} second{le deuxième {days}} third{le troisième {days}} fourth{le quatrième {days}} fifth{le cinquième {days}} secondToLast{l’avant-dernier {days}} last{le dernier {days}} other{les {days}}}", "repeatInMonthsSummary": "en {months}", "repeatTimesSummary": "{count} fois", "repeatUntilSummary": "jusqu’au {date}", @@ -920,5 +920,25 @@ }, "networkOffline": "Hors ligne", "networkOfflineDescription": "Les modifications seront synchronisées au rétablissement de la connexion.", - "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez." + "networkOfflineTryAgain": "Vous êtes hors ligne. Connectez-vous à Internet et réessayez.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "les {days} du mois", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index d2f07db1..fd1819e3 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "हर {count} साल", "repeatOnDaysSummary": "{days} को", "repeatOnMonthDaysSummary": "महीने के {days}वें दिन", - "repeatOnOrdinalSummary": "{days} के {ordinal} दिन", + "repeatOnOrdinalSummary": "{position, select, first{पहले {days}} second{दूसरे {days}} third{तीसरे {days}} fourth{चौथे {days}} fifth{पाँचवें {days}} secondToLast{अंतिम से दूसरे {days}} last{अंतिम {days}} other{{days}}}", "repeatInMonthsSummary": "{months} में", "repeatTimesSummary": "{count} बार", "repeatUntilSummary": "{date} तक", @@ -857,5 +857,25 @@ }, "networkOffline": "ऑफ़लाइन", "networkOfflineDescription": "कनेक्शन वापस आने पर बदलाव सिंक हो जाएंगे।", - "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।" + "networkOfflineTryAgain": "आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "महीने के दिन {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 46702456..9b6ae1f2 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "Ogni {count} anni", "repeatOnDaysSummary": "il {days}", "repeatOnMonthDaysSummary": "il giorno {days}", - "repeatOnOrdinalSummary": "il {ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{il primo {days}} second{il secondo {days}} third{il terzo {days}} fourth{il quarto {days}} fifth{il quinto {days}} secondToLast{il penultimo {days}} last{l’ultimo {days}} other{nei giorni {days}}}", "repeatInMonthsSummary": "nei mesi {months}", "repeatTimesSummary": "{count} volte", "repeatUntilSummary": "fino al {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "Senza connessione", "networkOfflineDescription": "Le modifiche verranno sincronizzate al ripristino della connessione.", - "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova." + "networkOfflineTryAgain": "Sei offline. Connettiti a Internet e riprova.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "i giorni {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index fb7156e9..5dd5e5a8 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -653,9 +653,9 @@ "repeatEveryWeeks": "{count} 週間ごと", "repeatEveryMonths": "{count} か月ごと", "repeatEveryYears": "{count} 年ごと", - "repeatOnDaysSummary": "{days} 曜日", + "repeatOnDaysSummary": "{days}", "repeatOnMonthDaysSummary": "毎月 {days} 日", - "repeatOnOrdinalSummary": "{days} の第 {ordinal}", + "repeatOnOrdinalSummary": "{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", "repeatInMonthsSummary": "{months} に", "repeatTimesSummary": "{count} 回", "repeatUntilSummary": "{date} まで", @@ -857,5 +857,25 @@ }, "networkOffline": "オフライン", "networkOfflineDescription": "接続が復旧すると変更が同期されます。", - "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。" + "networkOfflineTryAgain": "オフラインです。インターネットに接続して、もう一度お試しください。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "毎月 {days} 日", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1ded5c69..6e70dfa8 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "{count}년마다", "repeatOnDaysSummary": "{days}에", "repeatOnMonthDaysSummary": "매월 {days}일", - "repeatOnOrdinalSummary": "{days} {ordinal}", + "repeatOnOrdinalSummary": "{position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", "repeatInMonthsSummary": "{months}에", "repeatTimesSummary": "{count}회", "repeatUntilSummary": "{date}까지", @@ -857,5 +857,25 @@ }, "networkOffline": "오프라인", "networkOfflineDescription": "연결이 복구되면 변경 사항이 동기화됩니다.", - "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요." + "networkOfflineTryAgain": "오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "매월 {days}일", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 308bd4e6..89fa618b 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -44,9 +44,9 @@ } }, "calendarManagementUnsupported": "Este fornecedor não suporta a gestão de calendários no BusyMax.", - "primaryCalendarCannotDelete": "O calendário principal não pode ser excluído.", + "primaryCalendarCannotDelete": "O calendário principal não pode ser eliminado.", "calendarCreateFailed": "Não foi possível criar o calendário: {error}", - "calendarCreatedRefreshPending": "O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Ele aparecerá após a próxima sincronização.", + "calendarCreatedRefreshPending": "O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Será apresentado após a próxima sincronização.", "@calendarCreateFailed": { "placeholders": { "error": { @@ -62,7 +62,7 @@ } } }, - "calendarDeleteFailed": "Não foi possível excluir o calendário: {error}", + "calendarDeleteFailed": "Não foi possível eliminar o calendário: {error}", "@calendarDeleteFailed": { "placeholders": { "error": { @@ -655,7 +655,7 @@ "repeatEveryYears": "A cada {count} anos", "repeatOnDaysSummary": "em {days}", "repeatOnMonthDaysSummary": "no dia {days}", - "repeatOnOrdinalSummary": "na {ordinal}ª ocorrência de {days}", + "repeatOnOrdinalSummary": "{position, select, first{na primeira ocorrência de {days}} second{na segunda ocorrência de {days}} third{na terceira ocorrência de {days}} fourth{na quarta ocorrência de {days}} fifth{na quinta ocorrência de {days}} secondToLast{na penúltima ocorrência de {days}} last{na última ocorrência de {days}} other{em {days}}}", "repeatInMonthsSummary": "em {months}", "repeatTimesSummary": "{count} vezes", "repeatUntilSummary": "até {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "Sem ligação", "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", - "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente." + "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "nos dias {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fcf39ed5..d1fdd994 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "Каждые {count} г.", "repeatOnDaysSummary": "в дни {days}", "repeatOnMonthDaysSummary": "{days}-го числа месяца", - "repeatOnOrdinalSummary": "{ordinal} {days}", + "repeatOnOrdinalSummary": "{position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", "repeatInMonthsSummary": "в месяцы {months}", "repeatTimesSummary": "{count} раз", "repeatUntilSummary": "до {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "Нет подключения", "networkOfflineDescription": "Изменения будут синхронизированы после восстановления подключения.", - "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку." + "networkOfflineTryAgain": "Нет подключения к сети. Подключитесь к Интернету и повторите попытку.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "в дни месяца {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index eb6c1276..22f3d0ea 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "Mỗi {count} năm", "repeatOnDaysSummary": "vào {days}", "repeatOnMonthDaysSummary": "vào ngày {days} trong tháng", - "repeatOnOrdinalSummary": "vào {days} thứ {ordinal}", + "repeatOnOrdinalSummary": "{position, select, first{vào {days} đầu tiên} second{vào {days} thứ hai} third{vào {days} thứ ba} fourth{vào {days} thứ tư} fifth{vào {days} thứ năm} secondToLast{vào {days} áp chót} last{vào {days} cuối cùng} other{vào {days}}}", "repeatInMonthsSummary": "vào {months}", "repeatTimesSummary": "{count} lần", "repeatUntilSummary": "đến {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "Ngoại tuyến", "networkOfflineDescription": "Các thay đổi sẽ được đồng bộ khi kết nối được khôi phục.", - "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại." + "networkOfflineTryAgain": "Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "vào các ngày {days} trong tháng", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3db01963..2a939f44 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", "repeatOnMonthDaysSummary": "每月 {days} 日", - "repeatOnOrdinalSummary": "{days} 的第 {ordinal} 个", + "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", "repeatTimesSummary": "重复 {count} 次", "repeatUntilSummary": "直到 {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", - "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" + "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "每月 {days} 日", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 89dd98fd..8c497cf5 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", "repeatOnMonthDaysSummary": "每月 {days} 日", - "repeatOnOrdinalSummary": "{days} 的第 {ordinal} 个", + "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", "repeatTimesSummary": "重复 {count} 次", "repeatUntilSummary": "直到 {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "离线", "networkOfflineDescription": "恢复连接后将同步更改。", - "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。" + "networkOfflineTryAgain": "您当前处于离线状态。请连接互联网后重试。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "每月 {days} 日", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index b830df9b..3018ce95 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -655,7 +655,7 @@ "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", "repeatOnMonthDaysSummary": "每月 {days} 日", - "repeatOnOrdinalSummary": "{days} 的第 {ordinal} 個", + "repeatOnOrdinalSummary": "{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", "repeatTimesSummary": "重複 {count} 次", "repeatUntilSummary": "直到 {date}", @@ -857,5 +857,25 @@ }, "networkOffline": "離線", "networkOfflineDescription": "連線恢復後將同步變更。", - "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。" + "networkOfflineTryAgain": "您目前處於離線狀態。請連接網際網路後再試一次。", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "每月 {days} 日", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + } } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 9c689914..6ac1bfa3 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2562,11 +2562,11 @@ abstract class AppLocalizations { /// **'on day {days}'** String repeatOnMonthDaysSummary(String days); - /// Sentence fragment for ordinal recurrence, such as the first Monday; {ordinal} and {days} are localized. + /// Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday. /// /// In en, this message translates to: - /// **'on the {ordinal} {days}'** - String repeatOnOrdinalSummary(String ordinal, String days); + /// **'{position, select, first{on the first {days}} second{on the second {days}} third{on the third {days}} fourth{on the fourth {days}} fifth{on the fifth {days}} secondToLast{on the second to last {days}} last{on the last {days}} other{on {days}}}'** + String repeatOnOrdinalSummary(String position, String days); /// Sentence fragment for selected recurrence months; {months} is a localized list of month names. /// @@ -4103,6 +4103,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You’re offline. Connect to the internet and try again.'** String get networkOfflineTryAgain; + + /// Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers. + /// + /// In en, this message translates to: + /// **'on days {days}'** + String repeatOnMonthDaysSummaryMultiple(String days); } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index ee3961ad..4a0897d3 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -876,7 +876,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'إزالة $account من BusyMax؟'; + return 'إزالة ⁨$account⁩ من BusyMax؟'; } @override @@ -960,7 +960,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'حذف \"$title\" من Google Tasks؟'; + return 'حذف \"⁨$title⁩\" من Google Tasks؟'; } @override @@ -1403,8 +1403,18 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'في $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'في $days الأول', + 'second': 'في $days الثاني', + 'third': 'في $days الثالث', + 'fourth': 'في $days الرابع', + 'fifth': 'في $days الخامس', + 'secondToLast': 'في $days ما قبل الأخير', + 'last': 'في $days الأخير', + 'other': 'في $days', + }); + return '$_temp0'; } @override @@ -1759,7 +1769,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'تم حظر تغيير محلي معلق. $summary'; + return 'تم حظر تغيير محلي معلق. ⁨$summary⁩'; } @override @@ -2040,7 +2050,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String remoteChangedAt(String time) { - return 'غيّر الخادم في: $time'; + return 'تم التغيير على الخادم في: ⁨$time⁩'; } @override @@ -2304,4 +2314,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get networkOfflineTryAgain => 'أنت غير متصل. اتصل بالإنترنت وحاول مرة أخرى.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'في أيام الشهر $days'; + } } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 4846af29..49f78f9a 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1401,8 +1401,18 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'am $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'am ersten $days', + 'second': 'am zweiten $days', + 'third': 'am dritten $days', + 'fourth': 'am vierten $days', + 'fifth': 'am fünften $days', + 'secondToLast': 'am vorletzten $days', + 'last': 'am letzten $days', + 'other': 'an $days', + }); + return '$_temp0'; } @override @@ -2311,4 +2321,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get networkOfflineTryAgain => 'Sie sind offline. Stellen Sie eine Internetverbindung her und versuchen Sie es erneut.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'an den Tagen $days des Monats'; + } } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 45c77304..22fe0ccf 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1389,8 +1389,18 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'on the $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days', + 'second': 'on the second $days', + 'third': 'on the third $days', + 'fourth': 'on the fourth $days', + 'fifth': 'on the fifth $days', + 'secondToLast': 'on the second to last $days', + 'last': 'on the last $days', + 'other': 'on $days', + }); + return '$_temp0'; } @override @@ -2284,4 +2294,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get networkOfflineTryAgain => 'You’re offline. Connect to the internet and try again.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'on days $days'; + } } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index cfd1e7c6..5b1503d6 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1404,8 +1404,18 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'el $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'el primer $days', + 'second': 'el segundo $days', + 'third': 'el tercer $days', + 'fourth': 'el cuarto $days', + 'fifth': 'el quinto $days', + 'secondToLast': 'el penúltimo $days', + 'last': 'el último $days', + 'other': 'en $days', + }); + return '$_temp0'; } @override @@ -2313,4 +2323,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get networkOfflineTryAgain => 'No tienes conexión. Conéctate a Internet e inténtalo de nuevo.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'los días $days'; + } } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index daa0b0c0..8e3b3ebc 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1392,8 +1392,18 @@ class AppLocalizationsEt extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'esimesel $days', + 'second': 'teisel $days', + 'third': 'kolmandal $days', + 'fourth': 'neljandal $days', + 'fifth': 'viiendal $days', + 'secondToLast': 'eelviimasel $days', + 'last': 'viimasel $days', + 'other': 'päevadel $days', + }); + return '$_temp0'; } @override @@ -2290,4 +2300,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get networkOfflineTryAgain => 'Võrguühendus puudub. Looge internetiühendus ja proovige uuesti.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'kuu päevadel $days'; + } } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 5a8dcbea..90784b95 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -880,7 +880,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String removeAccountTitle(String account) { - return '$account از BusyMax حذف شود؟'; + return '⁨$account⁩ از BusyMax حذف شود؟'; } @override @@ -964,7 +964,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '‏«$title» از Google Tasks حذف شود؟'; + return '‏«⁨$title⁩» از Google Tasks حذف شود؟'; } @override @@ -1411,8 +1411,18 @@ class AppLocalizationsFa extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'در $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'در اولین $days', + 'second': 'در دومین $days', + 'third': 'در سومین $days', + 'fourth': 'در چهارمین $days', + 'fifth': 'در پنجمین $days', + 'secondToLast': 'در یکی‌مانده‌به‌آخرین $days', + 'last': 'در آخرین $days', + 'other': 'در $days', + }); + return '$_temp0'; } @override @@ -1773,7 +1783,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'یک تغییر محلی در انتظار مسدود شد. $summary'; + return 'یک تغییر محلی در انتظار مسدود شد. ⁨$summary⁩'; } @override @@ -2324,4 +2334,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get networkOfflineTryAgain => 'آفلاین هستید. به اینترنت متصل شوید و دوباره تلاش کنید.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'در روزهای $days ماه'; + } } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index a3f88a00..5380cce5 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1398,8 +1398,18 @@ class AppLocalizationsFi extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'ensimmäisenä $days', + 'second': 'toisena $days', + 'third': 'kolmantena $days', + 'fourth': 'neljäntenä $days', + 'fifth': 'viidentenä $days', + 'secondToLast': 'toiseksi viimeisenä $days', + 'last': 'viimeisenä $days', + 'other': 'päivinä $days', + }); + return '$_temp0'; } @override @@ -2301,4 +2311,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get networkOfflineTryAgain => 'Verkkoyhteyttä ei ole. Yhdistä internetiin ja yritä uudelleen.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'kuukauden päivinä $days'; + } } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index e6b5c911..3f73d37f 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1402,8 +1402,18 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'le $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'le premier $days', + 'second': 'le deuxième $days', + 'third': 'le troisième $days', + 'fourth': 'le quatrième $days', + 'fifth': 'le cinquième $days', + 'secondToLast': 'l’avant-dernier $days', + 'last': 'le dernier $days', + 'other': 'les $days', + }); + return '$_temp0'; } @override @@ -2312,4 +2322,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get networkOfflineTryAgain => 'Vous êtes hors ligne. Connectez-vous à Internet et réessayez.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'les $days du mois'; + } } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 52d44e0d..8f969aba 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1396,8 +1396,18 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$days के $ordinal दिन'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'पहले $days', + 'second': 'दूसरे $days', + 'third': 'तीसरे $days', + 'fourth': 'चौथे $days', + 'fifth': 'पाँचवें $days', + 'secondToLast': 'अंतिम से दूसरे $days', + 'last': 'अंतिम $days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -2293,4 +2303,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get networkOfflineTryAgain => 'आप ऑफ़लाइन हैं। इंटरनेट से कनेक्ट करें और फिर से कोशिश करें।'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'महीने के दिन $days'; + } } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 4ffcf354..4b2c18db 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1404,8 +1404,18 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'il $ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'il primo $days', + 'second': 'il secondo $days', + 'third': 'il terzo $days', + 'fourth': 'il quarto $days', + 'fifth': 'il quinto $days', + 'secondToLast': 'il penultimo $days', + 'last': 'l’ultimo $days', + 'other': 'nei giorni $days', + }); + return '$_temp0'; } @override @@ -2310,4 +2320,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get networkOfflineTryAgain => 'Sei offline. Connettiti a Internet e riprova.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'i giorni $days'; + } } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 0fed01eb..1240296e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1358,7 +1358,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String repeatOnDaysSummary(String days) { - return '$days 曜日'; + return '$days'; } @override @@ -1367,8 +1367,18 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$days の第 $ordinal'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第1$days', + 'second': '第2$days', + 'third': '第3$days', + 'fourth': '第4$days', + 'fifth': '第5$days', + 'secondToLast': '最後から2番目の$days', + 'last': '最後の$days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -2246,4 +2256,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get networkOfflineTryAgain => 'オフラインです。インターネットに接続して、もう一度お試しください。'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '毎月 $days 日'; + } } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 74d5c98f..588ba954 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1369,8 +1369,18 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$days $ordinal'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '첫 번째 $days', + 'second': '두 번째 $days', + 'third': '세 번째 $days', + 'fourth': '네 번째 $days', + 'fifth': '다섯 번째 $days', + 'secondToLast': '끝에서 두 번째 $days', + 'last': '마지막 $days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -2248,4 +2258,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get networkOfflineTryAgain => '오프라인 상태입니다. 인터넷에 연결한 후 다시 시도하세요.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '매월 $days일'; + } } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index d494b22d..fb8872fa 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -136,7 +136,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get primaryCalendarCannotDelete => - 'O calendário principal não pode ser excluído.'; + 'O calendário principal não pode ser eliminado.'; @override String calendarCreateFailed(String error) { @@ -145,7 +145,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get calendarCreatedRefreshPending => - 'O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Ele aparecerá após a próxima sincronização.'; + 'O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Será apresentado após a próxima sincronização.'; @override String calendarUpdateFailed(String error) { @@ -154,7 +154,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String calendarDeleteFailed(String error) { - return 'Não foi possível excluir o calendário: $error'; + return 'Não foi possível eliminar o calendário: $error'; } @override @@ -1403,8 +1403,18 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'na $ordinalª ocorrência de $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'na primeira ocorrência de $days', + 'second': 'na segunda ocorrência de $days', + 'third': 'na terceira ocorrência de $days', + 'fourth': 'na quarta ocorrência de $days', + 'fifth': 'na quinta ocorrência de $days', + 'secondToLast': 'na penúltima ocorrência de $days', + 'last': 'na última ocorrência de $days', + 'other': 'em $days', + }); + return '$_temp0'; } @override @@ -2310,4 +2320,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get networkOfflineTryAgain => 'Está offline. Ligue-se à Internet e tente novamente.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'nos dias $days'; + } } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 404a3165..7007a918 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1410,8 +1410,18 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$ordinal $days'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'в первый $days', + 'second': 'во второй $days', + 'third': 'в третий $days', + 'fourth': 'в четвёртый $days', + 'fifth': 'в пятый $days', + 'secondToLast': 'в предпоследний $days', + 'last': 'в последний $days', + 'other': 'в $days', + }); + return '$_temp0'; } @override @@ -2315,4 +2325,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get networkOfflineTryAgain => 'Нет подключения к сети. Подключитесь к Интернету и повторите попытку.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'в дни месяца $days'; + } } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 953ba7d2..d0c2e6f0 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1394,8 +1394,18 @@ class AppLocalizationsVi extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return 'vào $days thứ $ordinal'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'vào $days đầu tiên', + 'second': 'vào $days thứ hai', + 'third': 'vào $days thứ ba', + 'fourth': 'vào $days thứ tư', + 'fifth': 'vào $days thứ năm', + 'secondToLast': 'vào $days áp chót', + 'last': 'vào $days cuối cùng', + 'other': 'vào $days', + }); + return '$_temp0'; } @override @@ -2290,4 +2300,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get networkOfflineTryAgain => 'Bạn đang ngoại tuyến. Hãy kết nối Internet rồi thử lại.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'vào các ngày $days trong tháng'; + } } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 91b80a20..858a86e7 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1357,8 +1357,18 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$days 的第 $ordinal 个'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -2223,6 +2233,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get networkOfflineTryAgain => '您当前处于离线状态。请连接互联网后重试。'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '每月 $days 日'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -3574,8 +3589,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$days 的第 $ordinal 个'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -4440,6 +4465,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get networkOfflineTryAgain => '您当前处于离线状态。请连接互联网后重试。'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '每月 $days 日'; + } } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -5791,8 +5821,18 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { } @override - String repeatOnOrdinalSummary(String ordinal, String days) { - return '$days 的第 $ordinal 個'; + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一個$days', + 'second': '第二個$days', + 'third': '第三個$days', + 'fourth': '第四個$days', + 'fifth': '第五個$days', + 'secondToLast': '倒數第二個$days', + 'last': '最後一個$days', + 'other': '$days', + }); + return '$_temp0'; } @override @@ -6658,4 +6698,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get networkOfflineTryAgain => '您目前處於離線狀態。請連接網際網路後再試一次。'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return '每月 $days 日'; + } } diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index 925798bd..ae947605 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -80,11 +80,18 @@ String recurrenceRuleSummary( } else if ((recurrence.frequency == RecurrenceFrequency.monthly || recurrence.frequency == RecurrenceFrequency.yearly) && recurrence.byMonthDay.isNotEmpty) { - summary = - '$summary ${l10n.repeatOnMonthDaysSummary(recurrence.byMonthDay.join(', '))}'; + final monthDaysSummary = recurrence.byMonthDay.length == 1 + ? l10n.repeatOnMonthDaysSummary(recurrence.byMonthDay.join(', ')) + : l10n.repeatOnMonthDaysSummaryMultiple( + recurrence.byMonthDay.join(', '), + ); + summary = '$summary $monthDaysSummary'; } else if (recurrence.bySetPosition case final position?) { - summary = - '$summary ${l10n.repeatOnOrdinalSummary(_localizedOrdinal(context, position), _localizedOrdinalDay(context, recurrence.byDay))}'; + final ordinalSummary = l10n.repeatOnOrdinalSummary( + _ordinalPositionKey(position), + _localizedOrdinalDay(context, recurrence.byDay), + ); + summary = '$summary $ordinalSummary'; } if (recurrence.frequency == RecurrenceFrequency.yearly && recurrence.byMonth.isNotEmpty) { @@ -617,6 +624,17 @@ 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 _localizedOrdinalDay(BuildContext context, List days) { if (days.length == 1) { return _localizedWeekday(context, days.single, abbreviated: false); diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 1ba6f528..3a60f364 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -5,6 +5,8 @@ import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/l10n/generated/app_localizations_ar.dart'; import 'package:busymax/l10n/generated/app_localizations_fa.dart'; import 'package:busymax/src/l10n/app_locale.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/features/recurrence/presentation/recurrence_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -55,8 +57,10 @@ void main() { if (translation.trim().isEmpty) { failures.add('$path: $key has an empty translation'); } - final declaredPlaceholders = - _declaredPlaceholders(templateArb, key).toSet(); + final declaredPlaceholders = _declaredPlaceholders( + templateArb, + key, + ).toSet(); for (final placeholder in declaredPlaceholders) { if (!translation.contains('{$placeholder')) { failures.add('$path: $key does not use {$placeholder}'); @@ -359,6 +363,94 @@ void main() { } }); + test( + 'RTL translations isolate dynamic values in confirmations and conflicts', + () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final localizedValues = [ + AppLocalizationsAr(), + AppLocalizationsFa(), + ]; + + for (final l10n in localizedValues) { + expect( + l10n.removeAccountTitle('Acme (EU)'), + contains('${fsi}Acme (EU)$pdi'), + ); + expect( + l10n.deleteListConfirmation('Inbox / 2026'), + contains('${fsi}Inbox / 2026$pdi'), + ); + expect( + l10n.conflictNotificationBody('Meeting — 東京'), + contains('${fsi}Meeting — 東京$pdi'), + ); + } + }, + ); + + testWidgets('recurrence summaries use sentence-form localized grammar', ( + tester, + ) async { + const ordinalRule = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: ['MO'], + byMonth: [], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const monthDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: [], + byMonth: [], + byMonthDay: [1, 15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + + Future summary(Locale locale, RecurrenceRule rule) async { + var value = ''; + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + value = recurrenceRuleSummary(context, rule); + return const SizedBox(); + }, + ), + ), + ); + return value; + } + + expect( + await summary(const Locale('de'), ordinalRule), + 'Monatlich am ersten Montag', + ); + expect(await summary(const Locale('ja'), ordinalRule), '毎月 第1月曜日'); + expect( + await summary(const Locale('ru'), monthDaysRule), + 'Ежемесячно в дни месяца 1, 15', + ); + }); + test('Persian dynamic numbers use Persian digits', () { const fsi = '\u2068'; const pdi = '\u2069'; From d41ad031d92d23636b8dc5e9a9b6d7be8f370fee Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 16:21:06 -0700 Subject: [PATCH 14/69] fix translations --- lib/l10n/app_ar.arb | 17 ++ lib/l10n/app_de.arb | 17 ++ lib/l10n/app_en.arb | 17 ++ lib/l10n/app_es.arb | 17 ++ lib/l10n/app_et.arb | 19 +- lib/l10n/app_fa.arb | 17 ++ lib/l10n/app_fi.arb | 17 ++ lib/l10n/app_fr.arb | 17 ++ lib/l10n/app_hi.arb | 17 ++ lib/l10n/app_it.arb | 17 ++ lib/l10n/app_ja.arb | 21 +- lib/l10n/app_ko.arb | 21 +- lib/l10n/app_pt.arb | 17 ++ lib/l10n/app_ru.arb | 19 +- lib/l10n/app_vi.arb | 17 ++ lib/l10n/app_zh.arb | 21 +- lib/l10n/app_zh_Hans.arb | 21 +- lib/l10n/app_zh_Hant.arb | 21 +- lib/l10n/generated/app_localizations.dart | 18 ++ lib/l10n/generated/app_localizations_ar.dart | 11 + lib/l10n/generated/app_localizations_de.dart | 11 + lib/l10n/generated/app_localizations_en.dart | 11 + lib/l10n/generated/app_localizations_es.dart | 11 + lib/l10n/generated/app_localizations_et.dart | 27 ++- lib/l10n/generated/app_localizations_fa.dart | 11 + lib/l10n/generated/app_localizations_fi.dart | 11 + lib/l10n/generated/app_localizations_fr.dart | 11 + lib/l10n/generated/app_localizations_hi.dart | 11 + lib/l10n/generated/app_localizations_it.dart | 11 + lib/l10n/generated/app_localizations_ja.dart | 15 +- lib/l10n/generated/app_localizations_ko.dart | 15 +- lib/l10n/generated/app_localizations_pt.dart | 11 + lib/l10n/generated/app_localizations_ru.dart | 13 +- lib/l10n/generated/app_localizations_vi.dart | 11 + lib/l10n/generated/app_localizations_zh.dart | 45 ++++- .../presentation/recurrence_editor.dart | 100 ++++++--- .../presentation/calendar_day_semantics.dart | 5 +- .../schedule/presentation/mini_calendar.dart | 9 +- .../presentation/schedule_agenda_view.dart | 3 +- .../presentation/schedule_day_week_view.dart | 9 +- .../presentation/schedule_month_view.dart | 9 +- .../presentation/schedule_more_popover.dart | 4 +- .../presentation/schedule_toolbar.dart | 25 +-- .../presentation/schedule_workspace.dart | 22 +- .../desktop_date_time_fields.dart | 3 +- lib/src/l10n/localized_formatters.dart | 123 +++++++++++- test/app/localization_audit_test.dart | 66 +++++- test/app/localized_formatters_test.dart | 189 ++++++++++++++++++ .../presentation/schedule_toolbar_test.dart | 36 ++++ .../presentation/schedule_views_test.dart | 16 +- .../desktop_date_time_fields_test.dart | 2 + 51 files changed, 1077 insertions(+), 128 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 6d1464bc..b70da77c 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fd4180a8..c716cf6b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -940,5 +940,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f14de1e1..72102b32 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1251,5 +1251,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 565791d0..ef182560 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -940,5 +940,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index d082b435..705f86cd 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -833,7 +833,7 @@ "repeatEveryYears": "Iga {count} aasta järel", "repeatOnDaysSummary": "päevadel {days}", "repeatOnMonthDaysSummary": "kuu {days}. päeval", - "repeatOnOrdinalSummary": "{position, select, first{esimesel {days}} second{teisel {days}} third{kolmandal {days}} fourth{neljandal {days}} fifth{viiendal {days}} secondToLast{eelviimasel {days}} last{viimasel {days}} other{päevadel {days}}}", + "repeatOnOrdinalSummary": "{position, select, first{esimene {days}} second{teine {days}} third{kolmas {days}} fourth{neljas {days}} fifth{viies {days}} secondToLast{eelviimane {days}} last{viimane {days}} other{{days}}}", "repeatInMonthsSummary": "kuudel {months}", "repeatTimesSummary": "{count} korda", "repeatUntilSummary": "kuni kuupäevani {date}", @@ -1055,5 +1055,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index d87e5881..7f558d04 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -941,5 +941,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index ae6a8d1b..f7a864a1 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 936563ee..a73409e8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -940,5 +940,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index fd1819e3..3a3f66cc 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9b6ae1f2..9d87afd3 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 5dd5e5a8..2b8b2ffa 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -654,7 +654,7 @@ "repeatEveryMonths": "{count} か月ごと", "repeatEveryYears": "{count} 年ごと", "repeatOnDaysSummary": "{days}", - "repeatOnMonthDaysSummary": "毎月 {days} 日", + "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", "repeatInMonthsSummary": "{months} に", "repeatTimesSummary": "{count} 回", @@ -869,7 +869,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "毎月 {days} 日", + "repeatOnMonthDaysSummaryMultiple": "{days}", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": "、", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6e70dfa8..79b9b693 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -654,7 +654,7 @@ "repeatEveryMonths": "{count}개월마다", "repeatEveryYears": "{count}년마다", "repeatOnDaysSummary": "{days}에", - "repeatOnMonthDaysSummary": "매월 {days}일", + "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", "repeatInMonthsSummary": "{months}에", "repeatTimesSummary": "{count}회", @@ -869,7 +869,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "매월 {days}일", + "repeatOnMonthDaysSummaryMultiple": "{days}", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}일", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 89fa618b..9c40a3ff 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d1fdd994..3c9f5ae3 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -654,7 +654,7 @@ "repeatEveryMonths": "Каждые {count} мес.", "repeatEveryYears": "Каждые {count} г.", "repeatOnDaysSummary": "в дни {days}", - "repeatOnMonthDaysSummary": "{days}-го числа месяца", + "repeatOnMonthDaysSummary": "{days} числа месяца", "repeatOnOrdinalSummary": "{position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", "repeatInMonthsSummary": "в месяцы {months}", "repeatTimesSummary": "{count} раз", @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}-го", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": " и ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 22f3d0ea..9e757f12 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": ", ", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2a939f44..c1bd3c1d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -654,7 +654,7 @@ "repeatEveryMonths": "每 {count} 个月", "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", - "repeatOnMonthDaysSummary": "每月 {days} 日", + "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", "repeatTimesSummary": "重复 {count} 次", @@ -869,7 +869,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "每月 {days} 日", + "repeatOnMonthDaysSummaryMultiple": "{days}", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": "、", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 8c497cf5..5ac2fafb 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -654,7 +654,7 @@ "repeatEveryMonths": "每 {count} 个月", "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", - "repeatOnMonthDaysSummary": "每月 {days} 日", + "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", "repeatTimesSummary": "重复 {count} 次", @@ -869,7 +869,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "每月 {days} 日", + "repeatOnMonthDaysSummaryMultiple": "{days}", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": "、", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 3018ce95..4aa426ca 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -654,7 +654,7 @@ "repeatEveryMonths": "每 {count} 個月", "repeatEveryYears": "每 {count} 年", "repeatOnDaysSummary": "在 {days}", - "repeatOnMonthDaysSummary": "每月 {days} 日", + "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", "repeatTimesSummary": "重複 {count} 次", @@ -869,7 +869,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "每月 {days} 日", + "repeatOnMonthDaysSummaryMultiple": "{days}", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -877,5 +877,22 @@ "type": "String" } } + }, + "repeatSummarySeparator": "", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}日", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "int" + } + } + }, + "repeatMonthDayListSeparator": "、", + "@repeatMonthDayListSeparator": { + "description": "Separator between multiple localized recurrence day values." } } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 6ac1bfa3..34f31011 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -4109,6 +4109,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'on days {days}'** String repeatOnMonthDaysSummaryMultiple(String days); + + /// Separator inserted between the base recurrence frequency and a following day or ordinal phrase. + /// + /// In en, this message translates to: + /// **' '** + String get repeatSummarySeparator; + + /// Localized day-of-month value used when composing a list of recurrence days. + /// + /// In en, this message translates to: + /// **'{day}'** + String repeatMonthDayValue(int day); + + /// Separator between multiple localized recurrence day values. + /// + /// In en, this message translates to: + /// **', '** + String get repeatMonthDayListSeparator; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 4a0897d3..b75b14cd 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2319,4 +2319,15 @@ class AppLocalizationsAr extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'في أيام الشهر $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 49f78f9a..e7e21966 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2326,4 +2326,15 @@ class AppLocalizationsDe extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'an den Tagen $days des Monats'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 22fe0ccf..ad4a69df 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2299,4 +2299,15 @@ class AppLocalizationsEn extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'on days $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 5b1503d6..c2603a94 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2328,4 +2328,15 @@ class AppLocalizationsEs extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'los días $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 8e3b3ebc..972b265d 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1394,14 +1394,14 @@ class AppLocalizationsEt extends AppLocalizations { @override String repeatOnOrdinalSummary(String position, String days) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'esimesel $days', - 'second': 'teisel $days', - 'third': 'kolmandal $days', - 'fourth': 'neljandal $days', - 'fifth': 'viiendal $days', - 'secondToLast': 'eelviimasel $days', - 'last': 'viimasel $days', - 'other': 'päevadel $days', + 'first': 'esimene $days', + 'second': 'teine $days', + 'third': 'kolmas $days', + 'fourth': 'neljas $days', + 'fifth': 'viies $days', + 'secondToLast': 'eelviimane $days', + 'last': 'viimane $days', + 'other': '$days', }); return '$_temp0'; } @@ -2305,4 +2305,15 @@ class AppLocalizationsEt extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'kuu päevadel $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 90784b95..ad7f58f0 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2339,4 +2339,15 @@ class AppLocalizationsFa extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'در روزهای $days ماه'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 5380cce5..3fd8e694 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -2316,4 +2316,15 @@ class AppLocalizationsFi extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'kuukauden päivinä $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 3f73d37f..83cf324d 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2327,4 +2327,15 @@ class AppLocalizationsFr extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'les $days du mois'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 8f969aba..9bfa65fc 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -2308,4 +2308,15 @@ class AppLocalizationsHi extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'महीने के दिन $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 4b2c18db..7e275d6e 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2325,4 +2325,15 @@ class AppLocalizationsIt extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'i giorni $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 1240296e..a3bbb66a 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1363,7 +1363,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return '毎月 $days 日'; + return '$days'; } @override @@ -2259,6 +2259,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return '毎月 $days 日'; + return '$days'; } + + @override + String get repeatSummarySeparator => ''; + + @override + String repeatMonthDayValue(int day) { + return '$day日'; + } + + @override + String get repeatMonthDayListSeparator => '、'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 588ba954..b4b06f42 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1365,7 +1365,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return '매월 $days일'; + return '$days'; } @override @@ -2261,6 +2261,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return '매월 $days일'; + return '$days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day일'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index fb8872fa..149f28cd 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2325,4 +2325,15 @@ class AppLocalizationsPt extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'nos dias $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 7007a918..1b9109e2 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1406,7 +1406,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return '$days-го числа месяца'; + return '$days числа месяца'; } @override @@ -2330,4 +2330,15 @@ class AppLocalizationsRu extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'в дни месяца $days'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day-го'; + } + + @override + String get repeatMonthDayListSeparator => ' и '; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index d0c2e6f0..546419c0 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -2305,4 +2305,15 @@ class AppLocalizationsVi extends AppLocalizations { String repeatOnMonthDaysSummaryMultiple(String days) { return 'vào các ngày $days trong tháng'; } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(int day) { + return '$day'; + } + + @override + String get repeatMonthDayListSeparator => ', '; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 858a86e7..0e409750 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1353,7 +1353,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return '每月 $days 日'; + return '$days'; } @override @@ -2236,8 +2236,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return '每月 $days 日'; + return '$days'; } + + @override + String get repeatSummarySeparator => ''; + + @override + String repeatMonthDayValue(int day) { + return '$day日'; + } + + @override + String get repeatMonthDayListSeparator => '、'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -3585,7 +3596,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String repeatOnMonthDaysSummary(String days) { - return '每月 $days 日'; + return '$days'; } @override @@ -4468,8 +4479,19 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return '每月 $days 日'; + return '$days'; } + + @override + String get repeatSummarySeparator => ''; + + @override + String repeatMonthDayValue(int day) { + return '$day日'; + } + + @override + String get repeatMonthDayListSeparator => '、'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -5817,7 +5839,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String repeatOnMonthDaysSummary(String days) { - return '每月 $days 日'; + return '$days'; } @override @@ -6701,6 +6723,17 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return '每月 $days 日'; + return '$days'; + } + + @override + String get repeatSummarySeparator => ''; + + @override + String repeatMonthDayValue(int day) { + return '$day日'; } + + @override + String get repeatMonthDayListSeparator => '、'; } diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index ae947605..d0ed3e0d 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; import '../domain/event_recurrence_codec.dart'; @@ -75,28 +75,32 @@ String recurrenceRuleSummary( }; if (recurrence.frequency == RecurrenceFrequency.weekly && recurrence.byDay.isNotEmpty) { + final days = recurrence.byDay + .map((day) => _localizedInlineWeekday(context, day)) + .join(', '); summary = - '$summary ${l10n.repeatOnDaysSummary(recurrence.byDay.map((day) => _localizedWeekday(context, day, abbreviated: false)).join(', '))}'; + '$summary${l10n.repeatSummarySeparator}${l10n.repeatOnDaysSummary(days)}'; } else if ((recurrence.frequency == RecurrenceFrequency.monthly || recurrence.frequency == RecurrenceFrequency.yearly) && recurrence.byMonthDay.isNotEmpty) { + final monthDays = recurrence.byMonthDay + .map((day) => l10n.repeatMonthDayValue(day)) + .join(l10n.repeatMonthDayListSeparator); final monthDaysSummary = recurrence.byMonthDay.length == 1 - ? l10n.repeatOnMonthDaysSummary(recurrence.byMonthDay.join(', ')) - : l10n.repeatOnMonthDaysSummaryMultiple( - recurrence.byMonthDay.join(', '), - ); - summary = '$summary $monthDaysSummary'; + ? l10n.repeatOnMonthDaysSummary(monthDays) + : l10n.repeatOnMonthDaysSummaryMultiple(monthDays); + summary = '$summary${l10n.repeatSummarySeparator}$monthDaysSummary'; } else if (recurrence.bySetPosition case final position?) { final ordinalSummary = l10n.repeatOnOrdinalSummary( _ordinalPositionKey(position), - _localizedOrdinalDay(context, recurrence.byDay), + _localizedOrdinalDaySummary(context, recurrence.byDay), ); - summary = '$summary $ordinalSummary'; + summary = '$summary${l10n.repeatSummarySeparator}$ordinalSummary'; } if (recurrence.frequency == RecurrenceFrequency.yearly && recurrence.byMonth.isNotEmpty) { summary = - '$summary ${l10n.repeatInMonthsSummary(recurrence.byMonth.map((month) => _localizedMonth(context, month)).join(', '))}'; + '$summary ${l10n.repeatInMonthsSummary(recurrence.byMonth.map((month) => _localizedInlineMonth(context, month)).join(', '))}'; } if (recurrence.count != null) { return '$summary · ${l10n.repeatTimesSummary(recurrence.count!)}'; @@ -221,7 +225,13 @@ class _RecurrenceEditorDialogState extends State { style: YaruChoiceChipBarStyle.wrap, labels: [ for (final day in rfcWeekdays) - Text(_localizedWeekday(context, day, abbreviated: true)), + Text( + _localizedStandaloneWeekday( + context, + day, + abbreviated: true, + ), + ), ], isSelected: [ for (final day in rfcWeekdays) _value.byDay.contains(day), @@ -283,7 +293,7 @@ class _RecurrenceEditorDialogState extends State { title: l10n.repeatOn, values: [for (final choice in _ordinalDayChoices) choice.key], selected: _ordinalDayChoiceKey(_value.byDay), - labelFor: (value) => _localizedOrdinalDay( + labelFor: (value) => _localizedOrdinalDayChoice( context, _ordinalDayChoices .firstWhere((choice) => choice.key == value) @@ -309,7 +319,7 @@ class _RecurrenceEditorDialogState extends State { style: YaruChoiceChipBarStyle.wrap, labels: [ for (var month = 1; month <= 12; month += 1) - Text(_localizedMonth(context, month)), + Text(_localizedStandaloneMonth(context, month)), ], isSelected: [ for (var month = 1; month <= 12; month += 1) @@ -593,24 +603,40 @@ String _ordinalDayChoiceKey(List days) { return days.firstOrNull ?? 'MO'; } -String _localizedWeekday( +DateTime? _weekdayDate(String day) { + final index = rfcWeekdays.indexOf(day); + if (index < 0) return null; + return DateTime(2024, 1, 1 + index); +} + +String _localizedStandaloneWeekday( BuildContext context, String day, { required bool abbreviated, }) { - final index = rfcWeekdays.indexOf(day); - if (index < 0) return day; - final date = DateTime(2024, 1, 1 + index); + final date = _weekdayDate(day); + if (date == null) return day; + final locale = Localizations.localeOf(context).toLanguageTag(); + 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 DateFormat(abbreviated ? 'EEE' : 'EEEE', locale).format(date); + return localizedInlineWeekday(locale, date); } -String _localizedMonth(BuildContext context, int month) { +String _localizedStandaloneMonth(BuildContext context, int month) { if (month < 1 || month > 12) return '$month'; - return DateFormat( - 'MMM', - Localizations.localeOf(context).toLanguageTag(), - ).format(DateTime(2024, month)); + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedMonthLabel(locale, DateTime(2024, month), abbreviated: true); +} + +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 _localizedOrdinal(BuildContext context, int value) => switch (value) { @@ -635,9 +661,13 @@ String _ordinalPositionKey(int value) => switch (value) { _ => '$value', }; -String _localizedOrdinalDay(BuildContext context, List days) { +String _localizedOrdinalDayChoice(BuildContext context, List days) { if (days.length == 1) { - return _localizedWeekday(context, days.single, abbreviated: false); + return _localizedStandaloneWeekday( + context, + days.single, + abbreviated: false, + ); } if (_sameDaySet(days, _ordinalDayChoices[7].days)) { return context.l10n.repeatAnyDay; @@ -649,10 +679,28 @@ String _localizedOrdinalDay(BuildContext context, List days) { return context.l10n.repeatWeekendDay; } return days - .map((day) => _localizedWeekday(context, day, abbreviated: false)) + .map( + (day) => _localizedStandaloneWeekday(context, day, abbreviated: false), + ) .join(', '); } +String _localizedOrdinalDaySummary(BuildContext context, List days) { + if (days.length == 1) { + return _localizedInlineWeekday(context, days.single); + } + if (_sameDaySet(days, _ordinalDayChoices[7].days)) { + return context.l10n.repeatAnyDay; + } + if (_sameDaySet(days, _ordinalDayChoices[8].days)) { + return context.l10n.repeatWeekday; + } + if (_sameDaySet(days, _ordinalDayChoices[9].days)) { + return context.l10n.repeatWeekendDay; + } + return days.map((day) => _localizedInlineWeekday(context, day)).join(', '); +} + bool _sameDaySet(List left, List right) { if (left.length != right.length) return false; final leftValues = {...left}; diff --git a/lib/src/features/schedule/presentation/calendar_day_semantics.dart b/lib/src/features/schedule/presentation/calendar_day_semantics.dart index 13f71c59..445cfc74 100644 --- a/lib/src/features/schedule/presentation/calendar_day_semantics.dart +++ b/lib/src/features/schedule/presentation/calendar_day_semantics.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; + +import '../../../l10n/localized_formatters.dart'; /// Gives every interactive calendar day one consistent desktop accessibility /// contract, regardless of the visual calendar that renders it. @@ -20,7 +21,7 @@ class BusyMaxCalendarDaySemantics extends StatelessWidget { @override Widget build(BuildContext context) { final locale = Localizations.localeOf(context).toLanguageTag(); - final label = DateFormat.yMMMMEEEEd(locale).format(day); + final label = localizedDayHeading(locale, day); return Semantics( container: true, diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index 69a417d3..f9d6a489 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -1,7 +1,6 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; @@ -170,7 +169,7 @@ class YearMonthMiniCalendar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _MiniCalendarHeaderLabel( - label: DateFormat.yMMMM(locale).format(month), + label: localizedMonthYearHeading(locale, month), tooltip: context.l10n.openMonthView, onPressed: () => onMonthSelected(month), ), @@ -254,7 +253,11 @@ class MiniCalendarGrid extends StatelessWidget { Expanded( child: Center( child: Text( - DateFormat.E(locale).format(_weekdayDate(weekday)), + localizedWeekdayLabel( + locale, + _weekdayDate(weekday), + abbreviated: true, + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 76eedaca..b06f6983 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -6,6 +6,7 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_glyphs.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../tasks/domain/task_checklist_item.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; @@ -621,7 +622,7 @@ String _dayLabel(BuildContext context, DateTime day) { return context.l10n.tomorrow; } final locale = Localizations.localeOf(context).toLanguageTag(); - return DateFormat.yMMMMEEEEd(locale).format(day); + return localizedDayHeading(locale, day); } String _nestedTaskScheduleLabel( diff --git a/lib/src/features/schedule/presentation/schedule_day_week_view.dart b/lib/src/features/schedule/presentation/schedule_day_week_view.dart index dce5e8f0..97f02ed9 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -2,12 +2,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; -import 'package:intl/intl.dart'; import '../../../app/app_settings.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; @@ -484,7 +484,6 @@ class _PlannerDayHeader extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final surfaceColors = BusyMaxSurfaceColors.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); return Container( alignment: Alignment.center, decoration: BoxDecoration( @@ -497,7 +496,11 @@ class _PlannerDayHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - DateFormat.E(locale).format(day), + localizedWeekdayLabel( + Localizations.localeOf(context).toLanguageTag(), + day, + abbreviated: true, + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 4e0e8c19..9ec17c5c 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -1,12 +1,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; @@ -45,7 +45,6 @@ class ScheduleMonthView extends StatelessWidget { Widget build(BuildContext context) { final days = _daysInRange(range); final rows = (days.length / DateTime.daysPerWeek).ceil(); - final locale = Localizations.localeOf(context).toLanguageTag(); final month = DateTime(selectedDate.year, selectedDate.month); final grouped = ScheduleProjection.groupByDay(items); final theme = Theme.of(context); @@ -64,7 +63,11 @@ class ScheduleMonthView extends StatelessWidget { Expanded( child: Center( child: Text( - DateFormat.E(locale).format(_weekdayDate(weekday)), + localizedWeekdayLabel( + Localizations.localeOf(context).toLanguageTag(), + _weekdayDate(weekday), + abbreviated: true, + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelMedium?.copyWith( diff --git a/lib/src/features/schedule/presentation/schedule_more_popover.dart b/lib/src/features/schedule/presentation/schedule_more_popover.dart index 812668b7..7dd45bad 100644 --- a/lib/src/features/schedule/presentation/schedule_more_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_more_popover.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_yaru_theme.dart'; +import '../../../l10n/localized_formatters.dart'; import '../../../schedule/schedule_item.dart'; import 'schedule_anchored_popover.dart'; import 'schedule_item_chip.dart'; @@ -26,7 +26,7 @@ Future showScheduleMorePopover({ onTaskCompletionChanged, }) async { final locale = Localizations.localeOf(context).toLanguageTag(); - final dayLabel = DateFormat.yMMMMEEEEd(locale).format(day); + final dayLabel = localizedDayHeading(locale, day); final fallbackAnchorPoint = scheduleGlobalRectFor(anchorContext)?.center; return showScheduleAnchoredPopover( context: context, diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 84e16a11..9873646d 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; @@ -130,7 +129,13 @@ class ScheduleToolbar extends StatelessWidget { Expanded( child: _fittingRangeTitle( context, - _rangeLabel(context, mode, range, selectedDate), + localizedScheduleHeading( + Localizations.localeOf(context).toLanguageTag(), + mode, + range, + selectedDate, + agendaLabel: context.l10n.viewAgenda, + ), ), ), BusyMaxMenuButton( @@ -272,22 +277,6 @@ IconData _modeIcon(ScheduleViewMode mode) { }; } -String _rangeLabel( - BuildContext context, - ScheduleViewMode mode, - ScheduleRange range, - DateTime selectedDate, -) { - final locale = Localizations.localeOf(context).toLanguageTag(); - return switch (mode) { - ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(selectedDate), - ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), - ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), - ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), - }; -} - String _modeLabel(BuildContext context, ScheduleViewMode mode) { return switch (mode) { ScheduleViewMode.day => context.l10n.viewDay, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index fea943f6..44e4e293 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../app/app_bootstrap.dart'; @@ -679,11 +678,12 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!_nativeHeaderBarAvailable) { return; } - final titleRange = _scheduleRangeLabel( - context, + final titleRange = localizedScheduleHeading( + Localizations.localeOf(context).toLanguageTag(), _mode, range, _selectedDate, + agendaLabel: context.l10n.viewAgenda, ); final sidebarVisible = showSidebar && !_sidebarCollapsed; final headerBarState = BusyMaxHeaderBarState( @@ -2871,19 +2871,3 @@ int _firstWeekday(BuildContext context) { } DateTime _day(DateTime date) => DateTime(date.year, date.month, date.day); - -String _scheduleRangeLabel( - BuildContext context, - ScheduleViewMode mode, - ScheduleRange range, - DateTime selectedDate, -) { - final locale = Localizations.localeOf(context).toLanguageTag(); - return switch (mode) { - ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(selectedDate), - ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), - ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), - ScheduleViewMode.agenda => context.l10n.viewAgenda, - ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), - }; -} diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index a8734636..16b255d9 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -7,6 +7,7 @@ import 'package:busymax/src/app/busymax_surface_colors.dart'; import 'package:busymax/src/core/time/local_time_zone.dart'; import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/l10n/l10n.dart'; +import 'package:busymax/src/l10n/localized_formatters.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/tasks/presentation/time_zone_selection_dialog.dart'; @@ -445,7 +446,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { Widget _buildDateModeHeader(BuildContext context) { final locale = Localizations.localeOf(context).toLanguageTag(); - final monthLabel = DateFormat.MMMM(locale).format(_displayedMonth); + final monthLabel = localizedMonthHeading(locale, _displayedMonth); final colorScheme = Theme.of(context).colorScheme; final direction = Directionality.of(context); diff --git a/lib/src/l10n/localized_formatters.dart b/lib/src/l10n/localized_formatters.dart index cc40a365..129ad3ef 100644 --- a/lib/src/l10n/localized_formatters.dart +++ b/lib/src/l10n/localized_formatters.dart @@ -1,11 +1,58 @@ import 'package:intl/intl.dart'; import '../schedule/schedule_range.dart'; +import '../schedule/schedule_view_mode.dart'; -// `intl` exposes CLDR's middle-of-sentence month names, but does not apply -// CLDR contextTransforms. These supported locales request titlecase-firstword -// for wide standalone month names used as UI headings. -const _titleCasedStandaloneMonthLanguages = {'es', 'fr', 'it', 'ru'}; +// `intl` exposes CLDR's grammatical contexts, but does not apply CLDR +// contextTransforms. Capitalization is therefore applied only to the +// standalone UI contexts whose locale conventions require it. +const _titleCasedDayLanguages = {'es', 'fr', 'it', 'pt', 'ru'}; +const _titleCasedMonthLanguages = {'es', 'fr', 'it', 'ru'}; + +String _languageCode(String locale) => + Intl.canonicalizedLocale(locale).split('_').first; + +String _applyHeadingCase( + String label, + String locale, + Set titleCasedLanguages, +) { + if (label.isEmpty || !titleCasedLanguages.contains(_languageCode(locale))) { + return label; + } + return toBeginningOfSentenceCase(label, locale); +} + +String _formatContextPattern(String pattern, String locale, DateTime value) { + // intl treats bare known patterns such as EEEE and MMMM as adaptable + // skeletons. The quoted marker makes this an explicit pattern so E/M use + // format-context names rather than standalone c/L names. + const marker = '|'; + final formatted = DateFormat("$pattern'$marker'", locale).format(value); + return formatted.substring(0, formatted.length - marker.length); +} + +/// Formats a complete date used as a standalone calendar-page heading. +String localizedDayHeading(String locale, DateTime day) { + final formatWeekday = _formatContextPattern('EEEE', locale, day); + final standaloneWeekday = localizedWeekdayLabel(locale, day); + var label = DateFormat.yMMMMEEEEd(locale).format(day); + final weekdayStart = label.indexOf(formatWeekday); + if (weekdayStart >= 0 && formatWeekday != standaloneWeekday) { + label = label.replaceRange( + weekdayStart, + weekdayStart + formatWeekday.length, + standaloneWeekday, + ); + } + return _applyHeadingCase(label, locale, _titleCasedDayLanguages); +} + +/// Formats a month and year used as a standalone calendar-page heading. +String localizedMonthYearHeading(String locale, DateTime month) { + final label = DateFormat.yMMMM(locale).format(month); + return _applyHeadingCase(label, locale, _titleCasedMonthLanguages); +} /// Formats a month used by itself as a calendar heading. /// @@ -13,13 +60,67 @@ const _titleCasedStandaloneMonthLanguages = {'es', 'fr', 'it', 'ru'}; /// separate because standalone grammar and standalone UI capitalization are /// distinct concepts in CLDR. String localizedMonthHeading(String locale, DateTime month) { - final label = DateFormat.LLLL(locale).format(month); - final language = Intl.canonicalizedLocale(locale).split('_').first; - if (label.isEmpty || - !_titleCasedStandaloneMonthLanguages.contains(language)) { - return label; - } - return '${label[0].toUpperCase()}${label.substring(1)}'; + return localizedMonthLabel(locale, month); +} + +/// Formats a standalone month label using the grammatical `L` form. +String localizedMonthLabel( + String locale, + DateTime month, { + bool abbreviated = false, +}) { + final label = (abbreviated ? DateFormat.LLL(locale) : DateFormat.LLLL(locale)) + .format(month); + return _applyHeadingCase(label, locale, _titleCasedMonthLanguages); +} + +/// Formats a standalone weekday label using the grammatical `c` form. +String localizedWeekdayLabel( + String locale, + DateTime day, { + bool abbreviated = false, +}) { + final label = _formatContextPattern( + abbreviated ? 'ccc' : 'cccc', + locale, + day, + ); + return _applyHeadingCase(label, locale, _titleCasedDayLanguages); +} + +/// Formats a weekday name inside a sentence using the grammatical `E` form. +String localizedInlineWeekday( + String locale, + DateTime day, { + bool abbreviated = false, +}) { + return _formatContextPattern(abbreviated ? 'EEE' : 'EEEE', locale, day); +} + +/// Formats a month name inside a sentence using the grammatical `M` form. +String localizedInlineMonth( + String locale, + DateTime month, { + bool abbreviated = false, +}) { + return _formatContextPattern(abbreviated ? 'MMM' : 'MMMM', locale, month); +} + +/// Formats the title shared by the Flutter and native schedule headers. +String localizedScheduleHeading( + String locale, + ScheduleViewMode mode, + ScheduleRange range, + DateTime selectedDate, { + required String agendaLabel, +}) { + return switch (mode) { + ScheduleViewMode.day => localizedDayHeading(locale, selectedDate), + ScheduleViewMode.month => localizedMonthYearHeading(locale, selectedDate), + ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), + ScheduleViewMode.agenda => agendaLabel, + ScheduleViewMode.week => localizedScheduleRangeLabel(locale, range), + }; } /// Formats a closed-open schedule range without assuming a month/day/year diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 3a60f364..01dec2f9 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -36,6 +36,7 @@ void main() { final templateFile = File('lib/l10n/app_en.arb'); final templateArb = _decodeArb(templateFile); final templateMessages = _messages(templateArb); + const allowedEmptyTranslationKeys = {'repeatSummarySeparator'}; final failures = []; for (final file in _translatedArbFiles()) { @@ -54,7 +55,8 @@ void main() { } for (final key in templateKeys.intersection(translatedKeys)) { final translation = messages[key]!; - if (translation.trim().isEmpty) { + if (translation.trim().isEmpty && + !allowedEmptyTranslationKeys.contains(key)) { failures.add('$path: $key has an empty translation'); } final declaredPlaceholders = _declaredPlaceholders( @@ -97,6 +99,9 @@ void main() { 'nextcloudProvider', 'appleICloudTasksProvider', 'taskUrl', + 'repeatSummarySeparator', + 'repeatMonthDayValue', + 'repeatMonthDayListSeparator', }; final failures = []; @@ -235,6 +240,39 @@ void main() { expect(localizations.today, 'Hoje'); }); + test( + 'reviewed Arabic conflict wording and Portuguese variant stay stable', + () { + final arabic = AppLocalizationsAr(); + expect( + arabic.remoteChangedAt('14:30'), + contains('تم التغيير على الخادم في:'), + ); + + final portuguese = lookupAppLocalizations(const Locale('pt')); + expect(portuguese.settings, 'Definições'); + expect(portuguese.deleteTask, 'Eliminar tarefa'); + expect(portuguese.copyAndDelete, 'Copiar e eliminar'); + + final portugueseArb = File( + 'lib/l10n/app_pt.arb', + ).readAsStringSync().toLowerCase(); + for (final brazilianForm in [ + 'excluído', + 'excluir', + 'compartilhada', + 'somente', + 'ele aparecerá', + ]) { + expect( + portugueseArb, + isNot(contains(brazilianForm)), + reason: 'pt-PT must not reintroduce $brazilianForm', + ); + } + }, + ); + test('Hindi is generated and exposed as a supported locale', () { const locale = Locale('hi'); final localizations = lookupAppLocalizations(locale); @@ -421,6 +459,20 @@ void main() { rawRules: [], isSupported: true, ); + const singleMonthDayRule = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: [], + byMonth: [], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); Future summary(Locale locale, RecurrenceRule rule) async { var value = ''; @@ -444,10 +496,18 @@ void main() { await summary(const Locale('de'), ordinalRule), 'Monatlich am ersten Montag', ); - expect(await summary(const Locale('ja'), ordinalRule), '毎月 第1月曜日'); + expect(await summary(const Locale('ja'), ordinalRule), '毎月第1月曜日'); + expect(await summary(const Locale('ja'), singleMonthDayRule), '毎月15日'); + expect(await summary(const Locale('ja'), monthDaysRule), '毎月1日、15日'); + expect(await summary(const Locale('ko'), singleMonthDayRule), '매월 15일'); + expect(await summary(const Locale('ko'), monthDaysRule), '매월 1일, 15일'); + expect( + await summary(const Locale('et'), ordinalRule), + 'Iga kuu esimene esmaspäev', + ); expect( await summary(const Locale('ru'), monthDaysRule), - 'Ежемесячно в дни месяца 1, 15', + 'Ежемесячно в дни месяца 1-го и 15-го', ); }); diff --git a/test/app/localized_formatters_test.dart b/test/app/localized_formatters_test.dart index 5d161f51..12f55cd6 100644 --- a/test/app/localized_formatters_test.dart +++ b/test/app/localized_formatters_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:busymax/src/l10n/localized_formatters.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,6 +36,193 @@ void main() { } }); + test( + 'full day headings use heading context without global capitalization', + () async { + const expected = { + 'ar': 'الاثنين، 7 سبتمبر 2026', + 'de': 'Montag, 7. September 2026', + 'en': 'Monday, September 7, 2026', + 'es': 'Lunes, 7 de septiembre de 2026', + 'et': 'esmaspäev, 7. september 2026', + 'fa': 'دوشنبه ۷ سپتامبر ۲۰۲۶', + 'fi': 'maanantai 7. syyskuuta 2026', + 'fr': 'Lundi 7 septembre 2026', + 'hi': 'सोमवार, 7 सितंबर 2026', + 'it': 'Lunedì 7 settembre 2026', + 'ja': '2026年9月7日月曜日', + 'ko': '2026년 9월 7일 월요일', + 'pt': 'Segunda-feira, 7 de setembro de 2026', + 'ru': 'Понедельник, 7 сентября 2026 г.', + 'vi': 'Thứ Hai, 7 tháng 9, 2026', + 'zh': '2026年9月7日星期一', + 'zh_Hans': '2026年9月7日星期一', + 'zh_Hant': '2026年9月7日星期一', + }; + + for (final entry in expected.entries) { + await initializeDateFormatting(entry.key); + expect( + localizedDayHeading(entry.key, DateTime(2026, 9, 7)), + entry.value, + reason: entry.key, + ); + } + }, + ); + + test('month-year headings use standalone month context', () async { + const expected = { + 'ar': 'سبتمبر 2026', + 'de': 'September 2026', + 'en': 'September 2026', + 'es': 'Septiembre de 2026', + 'et': 'september 2026', + 'fa': 'سپتامبر ۲۰۲۶', + 'fi': 'syyskuu 2026', + 'fr': 'Septembre 2026', + 'hi': 'सितंबर 2026', + 'it': 'Settembre 2026', + 'ja': '2026年9月', + 'ko': '2026년 9월', + 'pt': 'setembro de 2026', + 'ru': 'Сентябрь 2026 г.', + 'vi': 'tháng 9 năm 2026', + 'zh': '2026年9月', + 'zh_Hans': '2026年9月', + 'zh_Hant': '2026年9月', + }; + + for (final entry in expected.entries) { + await initializeDateFormatting(entry.key); + expect( + localizedMonthYearHeading(entry.key, DateTime(2026, 9)), + entry.value, + reason: entry.key, + ); + } + }); + + test( + 'standalone weekday and month labels use their own grammatical forms', + () async { + const expectedWeekdays = { + 'ar': ('الاثنين', 'الاثنين'), + 'de': ('Montag', 'Mo'), + 'en': ('Monday', 'Mon'), + 'es': ('Lunes', 'Lun'), + 'et': ('esmaspäev', 'E'), + 'fa': ('دوشنبه', 'دوشنبه'), + 'fi': ('maanantai', 'ma'), + 'fr': ('Lundi', 'Lun.'), + 'hi': ('सोमवार', 'सोम'), + 'it': ('Lunedì', 'Lun'), + 'ja': ('月曜日', '月'), + 'ko': ('월요일', '월'), + 'pt': ('Segunda-feira', 'Seg.'), + 'ru': ('Понедельник', 'Пн'), + 'vi': ('Thứ Hai', 'Th 2'), + 'zh': ('星期一', '周一'), + 'zh_Hans': ('星期一', '周一'), + 'zh_Hant': ('星期一', '周一'), + }; + const expectedMonths = { + 'ar': ('سبتمبر', 'سبتمبر'), + 'de': ('September', 'Sep'), + 'en': ('September', 'Sep'), + 'es': ('Septiembre', 'Sept'), + 'et': ('september', 'september'), + 'fa': ('سپتامبر', 'سپتامبر'), + 'fi': ('syyskuu', 'syys'), + 'fr': ('Septembre', 'Sept.'), + 'hi': ('सितंबर', 'सित॰'), + 'it': ('Settembre', 'Set'), + 'ja': ('9月', '9月'), + 'ko': ('9월', '9월'), + 'pt': ('setembro', 'set.'), + 'ru': ('Сентябрь', 'Сент.'), + 'vi': ('Tháng 9', 'Tháng 9'), + 'zh': ('九月', '9月'), + 'zh_Hans': ('九月', '9月'), + 'zh_Hant': ('九月', '9月'), + }; + + for (final locale in expectedWeekdays.keys) { + await initializeDateFormatting(locale); + final weekday = expectedWeekdays[locale]!; + final month = expectedMonths[locale]!; + expect( + localizedWeekdayLabel(locale, DateTime(2026, 9, 7)), + weekday.$1, + reason: '$locale full weekday', + ); + expect( + localizedWeekdayLabel( + locale, + DateTime(2026, 9, 7), + abbreviated: true, + ), + weekday.$2, + reason: '$locale abbreviated weekday', + ); + expect( + localizedMonthLabel(locale, DateTime(2026, 9)), + month.$1, + reason: '$locale full month', + ); + expect( + localizedMonthLabel(locale, DateTime(2026, 9), abbreviated: true), + month.$2, + reason: '$locale abbreviated month', + ); + } + }, + ); + + test( + 'inline date names stay lowercase where locale data requires it', + () async { + for (final locale in ['es', 'fr', 'it', 'pt', 'ru', 'fi', 'et']) { + await initializeDateFormatting(locale); + final date = DateTime(2026, 9, 7); + expect( + localizedInlineWeekday(locale, date), + isNot(contains(RegExp(r'^[A-ZÁÉÍÓÚÀÂÄÇÈÉÊËÎÏÔÖÙÛÜŸ]'))), + reason: '$locale inline weekday', + ); + expect( + localizedInlineMonth(locale, date), + isNot(contains(RegExp(r'^[A-ZÁÉÍÓÚÀÂÄÇÈÉÊËÎÏÔÖÙÛÜŸ]'))), + reason: '$locale inline month', + ); + } + }, + ); + + test('calendar heading widgets do not call DateFormat directly', () { + const files = [ + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + 'lib/src/features/schedule/presentation/schedule_more_popover.dart', + 'lib/src/features/schedule/presentation/calendar_day_semantics.dart', + 'lib/src/features/schedule/presentation/mini_calendar.dart', + 'lib/src/features/schedule/presentation/schedule_month_view.dart', + 'lib/src/features/schedule/presentation/schedule_day_week_view.dart', + 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', + ]; + final forbidden = RegExp(r'DateFormat\.(?:yMMMMEEEEd|yMMMM|E|MMMM)\b'); + + for (final path in files) { + final source = File(path).readAsStringSync(); + expect( + source, + isNot(contains(forbidden)), + reason: '$path must use localized_formatters.dart', + ); + } + }); + test('schedule ranges localize each complete endpoint', () async { await initializeDateFormatting('de'); final range = ScheduleRange( diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index c206d7ca..4f283101 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -76,6 +76,42 @@ void main() { expect(title.style?.fontWeight, FontWeight.bold); }); + testWidgets('day toolbar uses a localized standalone date heading', ( + tester, + ) async { + tester.view + ..physicalSize = const Size(1600, 900) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('ru'), + child: Scaffold( + body: SizedBox( + width: 2000, + child: ScheduleToolbar( + mode: ScheduleViewMode.day, + range: ScheduleRange.day(DateTime(2026, 9, 7)), + selectedDate: DateTime(2026, 9, 7), + onToday: () {}, + onPrevious: () {}, + onNext: () {}, + onModeChanged: (_) {}, + canCreateEvent: false, + canCreateTask: false, + onCreateEvent: () {}, + onCreateTask: () {}, + onRefresh: () {}, + ), + ), + ), + ), + ); + + expect(find.text('Понедельник, 7 сентября 2026 г.'), findsOneWidget); + }); + testWidgets('Today is an accessible icon-only action', (tester) async { final semantics = tester.ensureSemantics(); var activations = 0; diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 445e560d..c66ecbd2 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2837,6 +2837,11 @@ void main() { expect(source, contains('context.l10n.allTasksRefreshed')); expect(source, contains('setScheduleViewMode(mode)')); expect(source, contains('settings.scheduleViewMode')); + expect( + source, + contains('localizedScheduleHeading('), + reason: 'native header state shares the Flutter heading formatter', + ); expect(source, contains('ScheduleEmptyState')); expect(source, isNot(contains('BusyMaxHeaderBarAction.newItem'))); expect(source, isNot(contains('BusyMaxHeaderBarAction.openMenu'))); @@ -4080,10 +4085,7 @@ void main() { contains('final showPaging = mode != ScheduleViewMode.agenda'), ); expect(toolbar, contains('if (showPaging)')); - expect( - toolbar, - contains('ScheduleViewMode.agenda => context.l10n.viewAgenda'), - ); + expect(toolbar, contains('agendaLabel: context.l10n.viewAgenda')); expect( workspace, contains('navigationVisible: _mode != ScheduleViewMode.agenda'), @@ -4191,7 +4193,11 @@ void main() { expect(mode, contains('year')); expect(range, contains('factory ScheduleRange.year(DateTime day)')); expect(workspace, contains('ScheduleRange.year(_selectedDate)')); - expect(workspace, contains('DateFormat.y(locale).format(selectedDate)')); + expect( + workspace, + contains('localizedScheduleHeading('), + reason: 'schedule headers use the centralized localized heading helper', + ); expect(workspace, contains('ScheduleYearView(')); expect(yearView, contains('final monthWidth =')); expect(yearView, contains('Wrap(')); diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index 43020861..1807fe4c 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -539,6 +539,7 @@ void main() { late BuildContext hostContext; await tester.pumpWidget( localizedTestApp( + locale: const Locale('ru'), child: Builder( builder: (context) { hostContext = context; @@ -557,6 +558,7 @@ void main() { expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); expect(find.byType(ModalBarrier), findsAtLeastNWidgets(1)); + expect(find.text('Июль'), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); From aab877f9ef21db6f143c0c1f7d4072f99d3e0fce Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 16:33:43 -0700 Subject: [PATCH 15/69] refactor: update secret handling to use string values instead of URIs in Ical ingestion and WebCal subscription services --- lib/src/ical/ical_ingestion.dart | 5 +- .../webcal/webcal_subscription_service.dart | 23 ++++- test/ical/ical_ingestion_test.dart | 2 +- .../webcal_subscription_service_test.dart | 85 +++++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/lib/src/ical/ical_ingestion.dart b/lib/src/ical/ical_ingestion.dart index 5e68cf92..ef8529db 100644 --- a/lib/src/ical/ical_ingestion.dart +++ b/lib/src/ical/ical_ingestion.dart @@ -223,10 +223,10 @@ int? _refreshIntervalSeconds(IcalDocument document) { String sanitizedCanonicalSnapshot( IcalDocument input, { - required Iterable secretUris, + required Iterable secretValues, }) { final document = input.deepCopy(); - final secrets = secretUris.map((uri) => uri.toString()).toSet(); + final secrets = secretValues.where((value) => value.isNotEmpty).toSet(); void sanitize(IcalComponent component, {required bool topLevel}) { component.children.removeWhere((node) { if (node is! IcalProperty) return false; @@ -235,6 +235,7 @@ String sanitizedCanonicalSnapshot( } final values = [ node.rawValue.trim(), + node.decodedTextValue.trim(), for (final parameter in node.parameters) ...parameter.values, ]; return secrets.any( diff --git a/lib/src/webcal/webcal_subscription_service.dart b/lib/src/webcal/webcal_subscription_service.dart index e1a4b619..758b4524 100644 --- a/lib/src/webcal/webcal_subscription_service.dart +++ b/lib/src/webcal/webcal_subscription_service.dart @@ -165,7 +165,11 @@ final class WebCalSubscriptionService { final response = await _httpTransport.get(normalized.uri); final candidate = _prepareAcceptedResponse( response, - secretUris: {normalized.uri, response.finalUri}, + secretValues: { + normalized.credentialUri, + normalized.uri.toString(), + response.finalUri.toString(), + }, ); final subscriptionId = _idFactory(); final accountId = 'webcal-account-$subscriptionId'; @@ -430,7 +434,11 @@ final class WebCalSubscriptionService { } final candidate = _prepareAcceptedResponse( response, - secretUris: {subscriptionUri, response.finalUri}, + secretValues: { + secret.normalizedSubscriptionUri, + subscriptionUri.toString(), + response.finalUri.toString(), + }, ); final source = await (_database.select(_database.calendarSources) @@ -665,6 +673,13 @@ final class WebCalSubscriptionService { .select(_database.webCalSubscriptions) .get(); for (final subscription in subscriptions) { + if (!_projectionCoverageRequired( + subscription, + requestedStart: requestedStart, + requestedEnd: requestedEnd, + )) { + continue; + } await _serializeSubscriptionOperation( subscription.id, () => _ensureProjectionCoverage( @@ -814,7 +829,7 @@ final class WebCalSubscriptionService { _PreparedCandidate _prepareAcceptedResponse( WebCalHttpResponse response, { - required Iterable secretUris, + required Iterable secretValues, }) { if (response.finalUri.scheme != 'https' || response.finalUri.host.isEmpty || @@ -862,7 +877,7 @@ final class WebCalSubscriptionService { ); final snapshot = sanitizedCanonicalSnapshot( originalIngestion.document, - secretUris: secretUris, + secretValues: secretValues, ); final projection = _prepareCachedProjection( snapshot, diff --git a/test/ical/ical_ingestion_test.dart b/test/ical/ical_ingestion_test.dart index 3e599c24..800ae9d5 100644 --- a/test/ical/ical_ingestion_test.dart +++ b/test/ical/ical_ingestion_test.dart @@ -304,7 +304,7 @@ END:VEVENT ); final snapshot = sanitizedCanonicalSnapshot( result.document, - secretUris: [secret, target], + secretValues: [secret.toString(), target.toString()], ); expect(snapshot, isNot(contains(secret.toString()))); diff --git a/test/webcal/webcal_subscription_service_test.dart b/test/webcal/webcal_subscription_service_test.dart index f15491cf..6f458798 100644 --- a/test/webcal/webcal_subscription_service_test.dart +++ b/test/webcal/webcal_subscription_service_test.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/db/app_database.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/webcal/webcal_http_client.dart'; import 'package:busymax/src/webcal/webcal_subscription_service.dart'; import 'package:crypto/crypto.dart'; @@ -179,6 +182,55 @@ END:VEVENT }, ); + test( + 'escaped TEXT values cannot expose original or target credentials', + () async { + const original = r'https://calendar.example.test/feed?token=a,b\c'; + final target = Uri.parse( + 'https://cdn.example.test/current.ics?token=a;b', + ); + transport.responses.add( + _response( + uri: target, + body: utf8.encode( + _calendar(r''' +BEGIN:VEVENT +UID:text-secret +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:https://calendar.example.test/feed?token=a\,b\\c +DESCRIPTION:https://calendar.example.test/feed?token=a\,b\\c +LOCATION:https://cdn.example.test/current.ics?token=a\;b +END:VEVENT +'''), + ), + ), + ); + + await service.addSubscription(subscriptionUrl: original); + + final snapshot = await database + .select(database.webCalSubscriptions) + .getSingle(); + final event = await database.select(database.calendarEvents).getSingle(); + final projectedText = [ + event.title, + event.description, + event.location, + ].join('\n'); + expect(snapshot.snapshotIcsBody, isNot(contains(original))); + expect(snapshot.snapshotIcsBody, isNot(contains(target.toString()))); + expect(snapshot.snapshotIcsBody, isNot(contains('SUMMARY:'))); + expect(snapshot.snapshotIcsBody, isNot(contains('DESCRIPTION:'))); + expect(snapshot.snapshotIcsBody, isNot(contains('LOCATION:'))); + expect(projectedText, isNot(contains(original))); + expect(projectedText, isNot(contains(target.toString()))); + expect(event.title, isEmpty); + expect(event.description, isNull); + expect(event.location, isNull); + }, + ); + test( 'malformed refresh preserves snapshot, events, validators, and alarms', () async { @@ -462,6 +514,39 @@ END:VEVENT }, ); + test('covered schedule reads do not wait for an in-flight refresh', () async { + final uri = Uri.parse('https://calendar.example.test/feed'); + transport.responses.add(_response(uri: uri, body: _oneEvent)); + await service.addSubscription(subscriptionUrl: uri.toString()); + + final response = Completer(); + transport.responses.add(response.future); + final refresh = service.refreshSubscription('subscription-1', force: true); + while (transport.requests.length < 2) { + await Future.delayed(Duration.zero); + } + + final repository = ScheduleRepository( + database, + ensureProjectionCoverage: (range) => service.ensureProjectionCoverage( + rangeStartUtc: range.start.toUtc(), + rangeEndUtc: range.end.toUtc(), + ), + ); + final items = await repository + .listItems( + range: ScheduleRange.day(DateTime.utc(2026, 8, 30)), + filters: const ScheduleFilters(includeTasks: false), + ) + .timeout(const Duration(seconds: 1)); + + expect(items.map((item) => item.title), ['One']); + expect(response.isCompleted, isFalse); + + response.complete(_response(uri: uri, body: _oneEvent)); + await refresh; + }); + test('304 reprojects an aging cached recurring snapshot', () async { final uri = Uri.parse('https://calendar.example.test/feed'); transport.responses.add( From e9056c54e4aaef2aaba629b50c6de7721ce9f499 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 17:01:03 -0700 Subject: [PATCH 16/69] feat: add localized summaries for yearly recurrence options in multiple languages --- lib/l10n/app_ar.arb | 21 ++- lib/l10n/app_de.arb | 21 ++- lib/l10n/app_en.arb | 21 ++- lib/l10n/app_es.arb | 21 ++- lib/l10n/app_et.arb | 29 +++- lib/l10n/app_fa.arb | 21 ++- lib/l10n/app_fi.arb | 21 ++- lib/l10n/app_fr.arb | 21 ++- lib/l10n/app_hi.arb | 27 +++- lib/l10n/app_it.arb | 21 ++- lib/l10n/app_ja.arb | 21 ++- lib/l10n/app_ko.arb | 21 ++- lib/l10n/app_pt.arb | 21 ++- lib/l10n/app_ru.arb | 21 ++- lib/l10n/app_vi.arb | 21 ++- lib/l10n/app_zh.arb | 21 ++- lib/l10n/app_zh_Hans.arb | 21 ++- lib/l10n/app_zh_Hant.arb | 21 ++- lib/l10n/generated/app_localizations.dart | 23 ++- lib/l10n/generated/app_localizations_ar.dart | 31 +++- lib/l10n/generated/app_localizations_de.dart | 31 +++- lib/l10n/generated/app_localizations_en.dart | 31 +++- lib/l10n/generated/app_localizations_es.dart | 31 +++- lib/l10n/generated/app_localizations_et.dart | 39 ++++- lib/l10n/generated/app_localizations_fa.dart | 31 +++- lib/l10n/generated/app_localizations_fi.dart | 31 +++- lib/l10n/generated/app_localizations_fr.dart | 31 +++- lib/l10n/generated/app_localizations_hi.dart | 51 ++++-- lib/l10n/generated/app_localizations_it.dart | 31 +++- lib/l10n/generated/app_localizations_ja.dart | 31 +++- lib/l10n/generated/app_localizations_ko.dart | 31 +++- lib/l10n/generated/app_localizations_pt.dart | 31 +++- lib/l10n/generated/app_localizations_ru.dart | 31 +++- lib/l10n/generated/app_localizations_vi.dart | 31 +++- lib/l10n/generated/app_localizations_zh.dart | 93 ++++++++++- .../presentation/recurrence_editor.dart | 60 +++++-- lib/src/l10n/localized_formatters.dart | 5 + test/app/localization_audit_test.dart | 151 +++++++++++++++++- .../presentation/event_editor_test.dart | 48 ++++++ 39 files changed, 1193 insertions(+), 72 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b70da77c..f94e7b5f 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} في {month} يوم {days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} في {position, select, first{أول {days} في {month}} second{ثاني {days} في {month}} third{ثالث {days} في {month}} fourth{رابع {days} في {month}} fifth{خامس {days} في {month}} secondToLast{قبل الأخير {days} في {month}} last{آخر {days} في {month}} other{{days} في {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c716cf6b..df87a92b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -950,12 +950,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} am {days}. {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{am ersten} second{am zweiten} third{am dritten} fourth{am vierten} fifth{am fünften} secondToLast{am vorletzten} last{am letzten} other{an}} {days} im {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 72102b32..2991e9a3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1261,12 +1261,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} on {month} {days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{on the first {days} of {month}} second{on the second {days} of {month}} third{on the third {days} of {month}} fourth{on the fourth {days} of {month}} fifth{on the fifth {days} of {month}} secondToLast{on the second to last {days} of {month}} last{on the last {days} of {month}} other{on {days} of {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ef182560..24840f7c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -950,12 +950,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} el día {days} de {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{el primer {days} de {month}} second{el segundo {days} de {month}} third{el tercer {days} de {month}} fourth{el cuarto {days} de {month}} fifth{el quinto {days} de {month}} secondToLast{el penúltimo {days} de {month}} last{el último {days} de {month}} other{en {days} de {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 705f86cd..7ad32200 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -832,7 +832,7 @@ "repeatEveryMonths": "Iga {count} kuu järel", "repeatEveryYears": "Iga {count} aasta järel", "repeatOnDaysSummary": "päevadel {days}", - "repeatOnMonthDaysSummary": "kuu {days}. päeval", + "repeatOnMonthDaysSummary": "{days} päeval", "repeatOnOrdinalSummary": "{position, select, first{esimene {days}} second{teine {days}} third{kolmas {days}} fourth{neljas {days}} fifth{viies {days}} secondToLast{eelviimane {days}} last{viimane {days}} other{{days}}}", "repeatInMonthsSummary": "kuudel {months}", "repeatTimesSummary": "{count} korda", @@ -1047,7 +1047,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "kuu päevadel {days}", + "repeatOnMonthDaysSummaryMultiple": "{days} päeval", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -1060,17 +1060,36 @@ "@repeatSummarySeparator": { "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." }, - "repeatMonthDayValue": "{day}", + "repeatMonthDayValue": "{day}.", "@repeatMonthDayValue": { "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, - "repeatMonthDayListSeparator": ", ", + "repeatMonthDayListSeparator": " ja ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} {days} {month} kuupäeval", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{esimene} second{teine} third{kolmas} fourth{neljas} fifth{viies} secondToLast{eelviimane} last{viimane} other{}} {days} {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 7f558d04..b3d98607 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -951,12 +951,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} در روز {days} ماه {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} در {position, select, first{اولین} second{دومین} third{سومین} fourth{چهارمین} fifth{پنجمین} secondToLast{یکی‌مانده‌به‌آخرین} last{آخرین} other{}} {days} ماه {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index f7a864a1..2a2a3261 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} {days}. {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{ensimmäisenä} second{toisena} third{kolmantena} fourth{neljäntenä} fifth{viidentenä} secondToLast{toiseksi viimeisenä} last{viimeisenä} other{}} {days} {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a73409e8..7d11da16 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -950,12 +950,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} le {days} {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{le premier {days} de {month}} second{le deuxième {days} de {month}} third{le troisième {days} de {month}} fourth{le quatrième {days} de {month}} fifth{le cinquième {days} de {month}} secondToLast{l’avant-dernier {days} de {month}} last{le dernier {days} de {month}} other{les {days} de {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 3a3f66cc..111ba0f4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -654,8 +654,8 @@ "repeatEveryMonths": "हर {count} महीने", "repeatEveryYears": "हर {count} साल", "repeatOnDaysSummary": "{days} को", - "repeatOnMonthDaysSummary": "महीने के {days}वें दिन", - "repeatOnOrdinalSummary": "{position, select, first{पहले {days}} second{दूसरे {days}} third{तीसरे {days}} fourth{चौथे {days}} fifth{पाँचवें {days}} secondToLast{अंतिम से दूसरे {days}} last{अंतिम {days}} other{{days}}}", + "repeatOnMonthDaysSummary": "के {days}वें दिन", + "repeatOnOrdinalSummary": "{position, select, first{के पहले {days} को} second{के दूसरे {days} को} third{के तीसरे {days} को} fourth{के चौथे {days} को} fifth{के पाँचवें {days} को} secondToLast{के अंतिम से दूसरे {days} को} last{के अंतिम {days} को} other{के {days} को}}", "repeatInMonthsSummary": "{months} में", "repeatTimesSummary": "{count} बार", "repeatUntilSummary": "{date} तक", @@ -869,7 +869,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "महीने के दिन {days}", + "repeatOnMonthDaysSummaryMultiple": "के {days}वें दिन", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} के {days}वें दिन", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {month} के {position, select, first{पहले} second{दूसरे} third{तीसरे} fourth{चौथे} fifth{पाँचवें} secondToLast{अंतिम से दूसरे} last{अंतिम} other{}} {days} को", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9d87afd3..f69b7ab4 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} il giorno {days} di {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{il primo {days} di {month}} second{il secondo {days} di {month}} third{il terzo {days} di {month}} fourth{il quarto {days} di {month}} fifth{il quinto {days} di {month}} secondToLast{il penultimo {days} di {month}} last{l’ultimo {days} di {month}} other{nei giorni {days} di {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2b8b2ffa..5829b6b7 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": "、", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}の{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 79b9b693..4ecab574 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} {days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {month}의 {position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 9c40a3ff..46541c90 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} no dia {days} de {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {month}} second{na segunda ocorrência de {days} de {month}} third{na terceira ocorrência de {days} de {month}} fourth{na quarta ocorrência de {days} de {month}} fifth{na quinta ocorrência de {days} de {month}} secondToLast{na penúltima ocorrência de {days} de {month}} last{na última ocorrência de {days} de {month}} other{em {days} de {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3c9f5ae3..f4e01bad 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": " и ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} {days} числа {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{в первый {days} {month}} second{во второй {days} {month}} third{в третий {days} {month}} fourth{в четвёртый {days} {month}} fifth{в пятый {days} {month}} secondToLast{в предпоследний {days} {month}} last{в последний {days} {month}} other{в {days} {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 9e757f12..58210cf9 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": ", ", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} vào ngày {days} tháng {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} vào {position, select, first{thứ nhất} second{thứ hai} third{thứ ba} fourth{thứ tư} fifth{thứ năm} secondToLast{áp chót} last{cuối cùng} other{}} {days} tháng {month}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c1bd3c1d..27d77445 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": "、", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 5ac2fafb..a01ac0f6 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": "、", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 4aa426ca..071f40da 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -887,12 +887,31 @@ "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { "day": { - "type": "int" + "type": "String" } } }, "repeatMonthDayListSeparator": "、", "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." + }, + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency}{month}的{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 34f31011..b2538da9 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -4120,13 +4120,34 @@ abstract class AppLocalizations { /// /// In en, this message translates to: /// **'{day}'** - String repeatMonthDayValue(int day); + String repeatMonthDayValue(String day); /// Separator between multiple localized recurrence day values. /// /// In en, this message translates to: /// **', '** String get repeatMonthDayListSeparator; + + /// Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale. + /// + /// In en, this message translates to: + /// **'{frequency} on {month} {days}'** + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ); + + /// Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale. + /// + /// In en, this message translates to: + /// **'{frequency} {position, select, first{on the first {days} of {month}} second{on the second {days} of {month}} third{on the third {days} of {month}} fourth{on the fourth {days} of {month}} fifth{on the fifth {days} of {month}} secondToLast{on the second to last {days} of {month}} last{on the last {days} of {month}} other{on {days} of {month}}}'** + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ); } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index b75b14cd..d62bd220 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2324,10 +2324,39 @@ class AppLocalizationsAr extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency في $month يوم $days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'أول $days في $month', + 'second': 'ثاني $days في $month', + 'third': 'ثالث $days في $month', + 'fourth': 'رابع $days في $month', + 'fifth': 'خامس $days في $month', + 'secondToLast': 'قبل الأخير $days في $month', + 'last': 'آخر $days في $month', + 'other': '$days في $month', + }); + return '$frequency في $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index e7e21966..daa8e70a 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2331,10 +2331,39 @@ class AppLocalizationsDe extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency am $days. $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'am ersten', + 'second': 'am zweiten', + 'third': 'am dritten', + 'fourth': 'am vierten', + 'fifth': 'am fünften', + 'secondToLast': 'am vorletzten', + 'last': 'am letzten', + 'other': 'an', + }); + return '$frequency $_temp0 $days im $month'; + } } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index ad4a69df..9a6db72a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2304,10 +2304,39 @@ class AppLocalizationsEn extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency on $month $days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $month', + 'second': 'on the second $days of $month', + 'third': 'on the third $days of $month', + 'fourth': 'on the fourth $days of $month', + 'fifth': 'on the fifth $days of $month', + 'secondToLast': 'on the second to last $days of $month', + 'last': 'on the last $days of $month', + 'other': 'on $days of $month', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index c2603a94..67279834 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2333,10 +2333,39 @@ class AppLocalizationsEs extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency el día $days de $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'el primer $days de $month', + 'second': 'el segundo $days de $month', + 'third': 'el tercer $days de $month', + 'fourth': 'el cuarto $days de $month', + 'fifth': 'el quinto $days de $month', + 'secondToLast': 'el penúltimo $days de $month', + 'last': 'el último $days de $month', + 'other': 'en $days de $month', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 972b265d..25b72797 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1388,7 +1388,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return 'kuu $days. päeval'; + return '$days päeval'; } @override @@ -2303,17 +2303,46 @@ class AppLocalizationsEt extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return 'kuu päevadel $days'; + return '$days päeval'; } @override String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { - return '$day'; + String repeatMonthDayValue(String day) { + return '$day.'; } @override - String get repeatMonthDayListSeparator => ', '; + String get repeatMonthDayListSeparator => ' ja '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $days $month kuupäeval'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'esimene', + 'second': 'teine', + 'third': 'kolmas', + 'fourth': 'neljas', + 'fifth': 'viies', + 'secondToLast': 'eelviimane', + 'last': 'viimane', + 'other': '', + }); + return '$frequency $_temp0 $days $month'; + } } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index ad7f58f0..4d47fd82 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2344,10 +2344,39 @@ class AppLocalizationsFa extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency در روز $days ماه $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'اولین', + 'second': 'دومین', + 'third': 'سومین', + 'fourth': 'چهارمین', + 'fifth': 'پنجمین', + 'secondToLast': 'یکی‌مانده‌به‌آخرین', + 'last': 'آخرین', + 'other': '', + }); + return '$frequency در $_temp0 $days ماه $month'; + } } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 3fd8e694..a4d7dd35 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -2321,10 +2321,39 @@ class AppLocalizationsFi extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $days. $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'ensimmäisenä', + 'second': 'toisena', + 'third': 'kolmantena', + 'fourth': 'neljäntenä', + 'fifth': 'viidentenä', + 'secondToLast': 'toiseksi viimeisenä', + 'last': 'viimeisenä', + 'other': '', + }); + return '$frequency $_temp0 $days $month'; + } } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 83cf324d..066e809d 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2332,10 +2332,39 @@ class AppLocalizationsFr extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency le $days $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'le premier $days de $month', + 'second': 'le deuxième $days de $month', + 'third': 'le troisième $days de $month', + 'fourth': 'le quatrième $days de $month', + 'fifth': 'le cinquième $days de $month', + 'secondToLast': 'l’avant-dernier $days de $month', + 'last': 'le dernier $days de $month', + 'other': 'les $days de $month', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 9bfa65fc..f3fcaa0e 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1392,20 +1392,20 @@ class AppLocalizationsHi extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return 'महीने के $daysवें दिन'; + return 'के $daysवें दिन'; } @override String repeatOnOrdinalSummary(String position, String days) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'पहले $days', - 'second': 'दूसरे $days', - 'third': 'तीसरे $days', - 'fourth': 'चौथे $days', - 'fifth': 'पाँचवें $days', - 'secondToLast': 'अंतिम से दूसरे $days', - 'last': 'अंतिम $days', - 'other': '$days', + 'first': 'के पहले $days को', + 'second': 'के दूसरे $days को', + 'third': 'के तीसरे $days को', + 'fourth': 'के चौथे $days को', + 'fifth': 'के पाँचवें $days को', + 'secondToLast': 'के अंतिम से दूसरे $days को', + 'last': 'के अंतिम $days को', + 'other': 'के $days को', }); return '$_temp0'; } @@ -2306,17 +2306,46 @@ class AppLocalizationsHi extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return 'महीने के दिन $days'; + return 'के $daysवें दिन'; } @override String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $month के $daysवें दिन'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'पहले', + 'second': 'दूसरे', + 'third': 'तीसरे', + 'fourth': 'चौथे', + 'fifth': 'पाँचवें', + 'secondToLast': 'अंतिम से दूसरे', + 'last': 'अंतिम', + 'other': '', + }); + return '$frequency $month के $_temp0 $days को'; + } } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 7e275d6e..e482ca38 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2330,10 +2330,39 @@ class AppLocalizationsIt extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency il giorno $days di $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'il primo $days di $month', + 'second': 'il secondo $days di $month', + 'third': 'il terzo $days di $month', + 'fourth': 'il quarto $days di $month', + 'fifth': 'il quinto $days di $month', + 'secondToLast': 'il penultimo $days di $month', + 'last': 'l’ultimo $days di $month', + 'other': 'nei giorni $days di $month', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index a3bbb66a..dd980907 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -2266,10 +2266,39 @@ class AppLocalizationsJa extends AppLocalizations { String get repeatSummarySeparator => ''; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day日'; } @override String get repeatMonthDayListSeparator => '、'; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$month$days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第1$days', + 'second': '第2$days', + 'third': '第3$days', + 'fourth': '第4$days', + 'fifth': '第5$days', + 'secondToLast': '最後から2番目の$days', + 'last': '最後の$days', + 'other': '$days', + }); + return '$frequency$monthの$_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index b4b06f42..d9583ad7 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -2268,10 +2268,39 @@ class AppLocalizationsKo extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day일'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $month $days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '첫 번째 $days', + 'second': '두 번째 $days', + 'third': '세 번째 $days', + 'fourth': '네 번째 $days', + 'fifth': '다섯 번째 $days', + 'secondToLast': '끝에서 두 번째 $days', + 'last': '마지막 $days', + 'other': '$days', + }); + return '$frequency $month의 $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 149f28cd..5c8ffc2f 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2330,10 +2330,39 @@ class AppLocalizationsPt extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency no dia $days de $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'na primeira ocorrência de $days de $month', + 'second': 'na segunda ocorrência de $days de $month', + 'third': 'na terceira ocorrência de $days de $month', + 'fourth': 'na quarta ocorrência de $days de $month', + 'fifth': 'na quinta ocorrência de $days de $month', + 'secondToLast': 'na penúltima ocorrência de $days de $month', + 'last': 'na última ocorrência de $days de $month', + 'other': 'em $days de $month', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 1b9109e2..80476eff 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -2335,10 +2335,39 @@ class AppLocalizationsRu extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day-го'; } @override String get repeatMonthDayListSeparator => ' и '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency $days числа $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'в первый $days $month', + 'second': 'во второй $days $month', + 'third': 'в третий $days $month', + 'fourth': 'в четвёртый $days $month', + 'fifth': 'в пятый $days $month', + 'secondToLast': 'в предпоследний $days $month', + 'last': 'в последний $days $month', + 'other': 'в $days $month', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 546419c0..bbb4a891 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -2310,10 +2310,39 @@ class AppLocalizationsVi extends AppLocalizations { String get repeatSummarySeparator => ' '; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day'; } @override String get repeatMonthDayListSeparator => ', '; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency vào ngày $days tháng $month'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'thứ nhất', + 'second': 'thứ hai', + 'third': 'thứ ba', + 'fourth': 'thứ tư', + 'fifth': 'thứ năm', + 'secondToLast': 'áp chót', + 'last': 'cuối cùng', + 'other': '', + }); + return '$frequency vào $_temp0 $days tháng $month'; + } } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 0e409750..cba2f7df 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -2243,12 +2243,41 @@ class AppLocalizationsZh extends AppLocalizations { String get repeatSummarySeparator => ''; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day日'; } @override String get repeatMonthDayListSeparator => '、'; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$month$days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$month的$_temp0'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -4486,12 +4515,41 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get repeatSummarySeparator => ''; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day日'; } @override String get repeatMonthDayListSeparator => '、'; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$month$days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$month的$_temp0'; + } } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -6730,10 +6788,39 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get repeatSummarySeparator => ''; @override - String repeatMonthDayValue(int day) { + String repeatMonthDayValue(String day) { return '$day日'; } @override String get repeatMonthDayListSeparator => '、'; + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency$month$days'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一個$days', + 'second': '第二個$days', + 'third': '第三個$days', + 'fourth': '第四個$days', + 'fifth': '第五個$days', + 'secondToLast': '倒數第二個$days', + 'last': '最後一個$days', + 'other': '$days', + }); + return '$frequency$month的$_temp0'; + } } diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index d0ed3e0d..b5d2a105 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -73,6 +73,7 @@ String recurrenceRuleSummary( ), RecurrenceFrequency.none => l10n.repeatNone, }; + var yearlyMonthPlacedInSummary = false; if (recurrence.frequency == RecurrenceFrequency.weekly && recurrence.byDay.isNotEmpty) { final days = recurrence.byDay @@ -84,21 +85,48 @@ String recurrenceRuleSummary( recurrence.frequency == RecurrenceFrequency.yearly) && recurrence.byMonthDay.isNotEmpty) { final monthDays = recurrence.byMonthDay - .map((day) => l10n.repeatMonthDayValue(day)) + .map( + (day) => l10n.repeatMonthDayValue(_localizedDayNumber(context, day)), + ) .join(l10n.repeatMonthDayListSeparator); - final monthDaysSummary = recurrence.byMonthDay.length == 1 - ? l10n.repeatOnMonthDaysSummary(monthDays) - : l10n.repeatOnMonthDaysSummaryMultiple(monthDays); - summary = '$summary${l10n.repeatSummarySeparator}$monthDaysSummary'; + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty) { + final months = recurrence.byMonth + .map((month) => _localizedInlineMonth(context, month)) + .join(l10n.repeatMonthDayListSeparator); + summary = l10n.repeatYearlyOnMonthDaysSummary(summary, months, monthDays); + yearlyMonthPlacedInSummary = true; + } else { + final monthDaysSummary = recurrence.byMonthDay.length == 1 + ? l10n.repeatOnMonthDaysSummary(monthDays) + : l10n.repeatOnMonthDaysSummaryMultiple(monthDays); + summary = '$summary${l10n.repeatSummarySeparator}$monthDaysSummary'; + } } else if (recurrence.bySetPosition case final position?) { - final ordinalSummary = l10n.repeatOnOrdinalSummary( - _ordinalPositionKey(position), - _localizedOrdinalDaySummary(context, recurrence.byDay), - ); - summary = '$summary${l10n.repeatSummarySeparator}$ordinalSummary'; + final days = _localizedOrdinalDaySummary(context, recurrence.byDay); + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty) { + final months = recurrence.byMonth + .map((month) => _localizedInlineMonth(context, month)) + .join(l10n.repeatMonthDayListSeparator); + summary = l10n.repeatYearlyOnOrdinalSummary( + summary, + months, + _ordinalPositionKey(position), + days, + ); + yearlyMonthPlacedInSummary = true; + } else { + final ordinalSummary = l10n.repeatOnOrdinalSummary( + _ordinalPositionKey(position), + days, + ); + summary = '$summary${l10n.repeatSummarySeparator}$ordinalSummary'; + } } if (recurrence.frequency == RecurrenceFrequency.yearly && - recurrence.byMonth.isNotEmpty) { + recurrence.byMonth.isNotEmpty && + !yearlyMonthPlacedInSummary) { summary = '$summary ${l10n.repeatInMonthsSummary(recurrence.byMonth.map((month) => _localizedInlineMonth(context, month)).join(', '))}'; } @@ -256,7 +284,10 @@ class _RecurrenceEditorDialogState extends State { padding: const EdgeInsets.all(BusyMaxSpacing.md), child: YaruChoiceChipBar( style: YaruChoiceChipBarStyle.wrap, - labels: [for (var day = 1; day <= 31; day += 1) Text('$day')], + labels: [ + for (var day = 1; day <= 31; day += 1) + Text(_localizedDayNumber(context, day)), + ], isSelected: [ for (var day = 1; day <= 31; day += 1) _value.byMonthDay.contains(day), @@ -633,6 +664,11 @@ String _localizedStandaloneMonth(BuildContext context, int month) { return localizedMonthLabel(locale, DateTime(2024, month), abbreviated: true); } +String _localizedDayNumber(BuildContext context, int day) { + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedNumber(locale, day); +} + String _localizedInlineMonth(BuildContext context, int month) { if (month < 1 || month > 12) return '$month'; final locale = Localizations.localeOf(context).toLanguageTag(); diff --git a/lib/src/l10n/localized_formatters.dart b/lib/src/l10n/localized_formatters.dart index 129ad3ef..b2813326 100644 --- a/lib/src/l10n/localized_formatters.dart +++ b/lib/src/l10n/localized_formatters.dart @@ -106,6 +106,11 @@ String localizedInlineMonth( return _formatContextPattern(abbreviated ? 'MMM' : 'MMMM', locale, month); } +/// Formats a number using the active locale's decimal digits. +String localizedNumber(String locale, num value) { + return NumberFormat.decimalPattern(locale).format(value); +} + /// Formats the title shared by the Flutter and native schedule headers. String localizedScheduleHeading( String locale, diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 01dec2f9..3922309c 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -473,6 +473,34 @@ void main() { rawRules: [], isSupported: true, ); + const yearlyMonthDayRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyOrdinalRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: ['MO'], + byMonth: [9], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); Future summary(Locale locale, RecurrenceRule rule) async { var value = ''; @@ -509,6 +537,51 @@ void main() { await summary(const Locale('ru'), monthDaysRule), 'Ежемесячно в дни месяца 1-го и 15-го', ); + expect( + await summary(const Locale('et'), singleMonthDayRule), + 'Iga kuu 15. päeval', + ); + expect( + await summary(const Locale('et'), monthDaysRule), + 'Iga kuu 1. ja 15. päeval', + ); + expect( + await summary(const Locale('hi'), singleMonthDayRule), + 'हर महीने के 15वें दिन', + ); + expect( + await summary(const Locale('hi'), ordinalRule), + 'हर महीने के पहले सोमवार को', + ); + for (final locale in [ + const Locale('ja'), + const Locale('ko'), + const Locale('zh'), + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), + ]) { + final monthDays = await summary(locale, yearlyMonthDayRule); + final ordinal = await summary(locale, yearlyOrdinalRule); + if (locale.languageCode == 'ja') { + expect(monthDays, '毎年9月15日'); + expect(ordinal, '毎年9月の第1月曜日'); + } else if (locale.languageCode == 'ko') { + expect(monthDays, '매년 9월 15일'); + expect(ordinal, '매년 9월의 첫 번째 월요일'); + } else if (locale.scriptCode == 'Hant') { + expect(monthDays, '每年9月15日'); + expect(ordinal, '每年9月的第一個星期一'); + } else { + expect(monthDays, '每年9月15日'); + expect(ordinal, '每年9月的第一个星期一'); + } + } + final persianMonthDay = await summary( + const Locale('fa'), + singleMonthDayRule, + ); + expect(persianMonthDay, contains('۱۵')); + expect(persianMonthDay, isNot(contains('15'))); }); test('Persian dynamic numbers use Persian digits', () { @@ -708,8 +781,82 @@ Iterable _declaredPlaceholders( } Iterable _usedPlaceholders(String value) sync* { - final pattern = RegExp(r'\{([A-Za-z][A-Za-z0-9_]*)(?=,|\})'); - yield* pattern.allMatches(value).map((match) => match.group(1)!); + yield* _scanIcuPlaceholders(value); +} + +Iterable _scanIcuPlaceholders(String value) sync* { + for (var index = 0; index < value.length;) { + if (value[index] != '{') { + index += 1; + continue; + } + + final closing = _matchingBrace(value, index); + if (closing == -1) { + index += 1; + continue; + } + + final body = value.substring(index + 1, closing); + final nameMatch = RegExp(r'^\s*([A-Za-z][A-Za-z0-9_]*)').firstMatch(body); + if (nameMatch != null) { + final name = nameMatch.group(1)!; + final remainder = body.substring(nameMatch.end).trimLeft(); + if (remainder.isEmpty || remainder.startsWith(',')) { + yield name; + } + + final selectMatch = RegExp( + r'^\s*[A-Za-z][A-Za-z0-9_]*\s*,\s*select\s*,', + ).firstMatch(body); + if (selectMatch != null) { + yield* _scanIcuSelectOptions(body.substring(selectMatch.end)); + } else if (remainder.startsWith(',')) { + yield* _scanIcuPlaceholders(body.substring(nameMatch.end)); + } + } else { + yield* _scanIcuPlaceholders(body); + } + + index = closing + 1; + } +} + +Iterable _scanIcuSelectOptions(String value) sync* { + for (var index = 0; index < value.length;) { + while (index < value.length && value[index].trim().isEmpty) { + index += 1; + } + if (index >= value.length) return; + + final optionMatch = RegExp( + r'[A-Za-z][A-Za-z0-9_]*', + ).matchAsPrefix(value, index); + if (optionMatch == null) return; + index = optionMatch.end; + while (index < value.length && value[index].trim().isEmpty) { + index += 1; + } + if (index >= value.length || value[index] != '{') return; + + final closing = _matchingBrace(value, index); + if (closing == -1) return; + yield* _scanIcuPlaceholders(value.substring(index + 1, closing)); + index = closing + 1; + } +} + +int _matchingBrace(String value, int opening) { + var depth = 0; + for (var index = opening; index < value.length; index += 1) { + if (value[index] == '{') { + depth += 1; + } else if (value[index] == '}') { + depth -= 1; + if (depth == 0) return index; + } + } + return -1; } int _lineForOffset(String source, int offset) { diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 2e86d019..78fd064f 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -4,6 +4,7 @@ import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/features/recurrence/domain/event_recurrence_codec.dart'; import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; import 'package:busymax/src/features/recurrence/presentation/recurrence_editor.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; @@ -784,6 +785,53 @@ void main() { ]); }); + testWidgets('Persian recurrence day choices use Persian digits', ( + tester, + ) async { + const initial = RecurrenceRule( + frequency: RecurrenceFrequency.monthly, + interval: 1, + byDay: [], + byMonth: [], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + + await tester.pumpWidget( + localizedTestApp( + locale: const Locale('fa'), + child: Scaffold( + body: RecurrenceEditorDialog( + initial: initial, + allDay: true, + baseDate: DateTime(2026, 9, 7), + minimumDate: DateTime(2026, 1, 1), + floating: false, + useNativeDatePicker: false, + limits: RecurrenceRuleLimits.rfc5545, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final dialog = find.byType(RecurrenceEditorDialog); + expect( + find.descendant(of: dialog, matching: find.text('۱۵')), + findsOneWidget, + ); + expect( + find.descendant(of: dialog, matching: find.text('15')), + findsNothing, + ); + }); + testWidgets('Microsoft recurrence range follows a changed start date', ( tester, ) async { From 9bc133f7f77f0f39987d0608c56f0b65d5e22ad5 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 17:19:54 -0700 Subject: [PATCH 17/69] fix: include target in secret values for WebCal subscription service --- .../webcal/webcal_subscription_service.dart | 1 + .../webcal_subscription_service_test.dart | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/lib/src/webcal/webcal_subscription_service.dart b/lib/src/webcal/webcal_subscription_service.dart index 758b4524..470e729c 100644 --- a/lib/src/webcal/webcal_subscription_service.dart +++ b/lib/src/webcal/webcal_subscription_service.dart @@ -437,6 +437,7 @@ final class WebCalSubscriptionService { secretValues: { secret.normalizedSubscriptionUri, subscriptionUri.toString(), + if (target != null) target.toString(), response.finalUri.toString(), }, ); diff --git a/test/webcal/webcal_subscription_service_test.dart b/test/webcal/webcal_subscription_service_test.dart index 6f458798..43ab4405 100644 --- a/test/webcal/webcal_subscription_service_test.dart +++ b/test/webcal/webcal_subscription_service_test.dart @@ -291,6 +291,80 @@ END:VEVENT }, ); + test( + 'refresh sanitizes the previous redirect target after rotation', + () async { + final origin = Uri.parse( + 'https://calendar.example.test/feed?token=original', + ); + final previousTarget = Uri.parse( + 'https://old-cdn.example.test/calendar.ics?token=old,private', + ); + final nextTarget = Uri.parse( + 'https://new-cdn.example.test/calendar.ics?token=new;private', + ); + transport.responses.add( + _response(uri: previousTarget, etag: '"one"', body: _oneEvent), + ); + await service.addSubscription(subscriptionUrl: origin.toString()); + + final escapedPrevious = previousTarget.toString().replaceAll(',', r'\,'); + transport.responses.add( + _response( + uri: nextTarget, + etag: '"two"', + body: utf8.encode( + _calendar(''' +SOURCE:${origin.toString()} +BEGIN:VEVENT +UID:uid-one +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Rotated feed +DESCRIPTION:$escapedPrevious +LOCATION;ALTREP="${previousTarget.toString()}":Office +URL:${nextTarget.toString()} +END:VEVENT +'''), + ), + ), + ); + + await service.refreshSubscription('subscription-1', force: true); + + final request = transport.requests.last; + expect(request.uri, origin); + expect(request.validatorTarget, previousTarget); + expect(request.validators.etag, '"one"'); + + final subscription = await database + .select(database.webCalSubscriptions) + .getSingle(); + final snapshot = subscription.snapshotIcsBody; + expect(snapshot, isNot(contains(origin.toString()))); + expect(snapshot, isNot(contains(previousTarget.toString()))); + expect(snapshot, isNot(contains(nextTarget.toString()))); + expect(snapshot, isNot(contains('DESCRIPTION:'))); + expect(snapshot, isNot(contains('LOCATION;'))); + expect(snapshot, isNot(contains('URL:'))); + + final event = await database.select(database.calendarEvents).getSingle(); + final projectedRow = jsonEncode(event.toJson()); + expect(projectedRow, isNot(contains(origin.toString()))); + expect(projectedRow, isNot(contains(previousTarget.toString()))); + expect(projectedRow, isNot(contains(nextTarget.toString()))); + expect(event.title, 'Rotated feed'); + expect(event.description, isNull); + expect(event.location, isNull); + expect(event.webLink, isNull); + + final committedSecret = + await secrets.readCredential('webcal-account-subscription-1') + as WebCalSecretRecord; + expect(committedSecret.validatorTargetUri, nextTarget); + }, + ); + test('conditional 304 is accepted only for the bound target', () async { final origin = Uri.parse('https://calendar.example.test/feed'); final target = Uri.parse('https://cdn.example.test/current.ics'); From aef74ac073dab7173d05e6f89c570eba9b11139b Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 17:40:02 -0700 Subject: [PATCH 18/69] feat: enhance yearly recurrence summaries with localized month and day formatting --- lib/l10n/app_ar.arb | 13 +- lib/l10n/app_de.arb | 11 +- lib/l10n/app_en.arb | 81 ++++- lib/l10n/app_es.arb | 11 +- lib/l10n/app_et.arb | 15 +- lib/l10n/app_fa.arb | 11 +- lib/l10n/app_fi.arb | 15 +- lib/l10n/app_fr.arb | 11 +- lib/l10n/app_hi.arb | 11 +- lib/l10n/app_it.arb | 11 +- lib/l10n/app_ja.arb | 11 +- lib/l10n/app_ko.arb | 11 +- lib/l10n/app_pt.arb | 11 +- lib/l10n/app_ru.arb | 13 +- lib/l10n/app_vi.arb | 13 +- lib/l10n/app_zh.arb | 11 +- lib/l10n/app_zh_Hans.arb | 11 +- lib/l10n/app_zh_Hant.arb | 11 +- lib/l10n/generated/app_localizations.dart | 75 +++- lib/l10n/generated/app_localizations_ar.dart | 93 ++++- lib/l10n/generated/app_localizations_de.dart | 75 +++- lib/l10n/generated/app_localizations_en.dart | 75 +++- lib/l10n/generated/app_localizations_es.dart | 75 +++- lib/l10n/generated/app_localizations_et.dart | 93 ++++- lib/l10n/generated/app_localizations_fa.dart | 75 +++- lib/l10n/generated/app_localizations_fi.dart | 93 ++++- lib/l10n/generated/app_localizations_fr.dart | 75 +++- lib/l10n/generated/app_localizations_hi.dart | 75 +++- lib/l10n/generated/app_localizations_it.dart | 75 +++- lib/l10n/generated/app_localizations_ja.dart | 75 +++- lib/l10n/generated/app_localizations_ko.dart | 75 +++- lib/l10n/generated/app_localizations_pt.dart | 75 +++- lib/l10n/generated/app_localizations_ru.dart | 93 ++++- lib/l10n/generated/app_localizations_vi.dart | 79 ++++- lib/l10n/generated/app_localizations_zh.dart | 225 +++++++++++- .../presentation/recurrence_editor.dart | 122 ++++++- test/app/localization_audit_test.dart | 334 ++++++++++++++++++ 37 files changed, 2153 insertions(+), 86 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index f94e7b5f..4c54052e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} في {month} يوم {days}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} و{second}", + "repeatYearlyMonthDayListStart": "{first}، {rest}", + "repeatYearlyMonthListPair": "{first} و{second}", + "repeatYearlyMonthListStart": "{first}، {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} في يوم {day} من {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} في يوم {day} من أشهر {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} في الأيام {days} من أشهر {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{في أول يوم {days} من أشهر {months}} second{في ثاني يوم {days} من أشهر {months}} third{في ثالث يوم {days} من أشهر {months}} fourth{في رابع يوم {days} من أشهر {months}} fifth{في خامس يوم {days} من أشهر {months}} secondToLast{في يوم {days} قبل الأخير من أشهر {months}} last{في آخر يوم {days} من أشهر {months}} other{في يوم {days} من أشهر {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} في الأيام {days} من {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { @@ -904,7 +913,7 @@ "days": {"type": "String"} } }, - "repeatYearlyOnOrdinalSummary": "{frequency} في {position, select, first{أول {days} في {month}} second{ثاني {days} في {month}} third{ثالث {days} في {month}} fourth{رابع {days} في {month}} fifth{خامس {days} في {month}} secondToLast{قبل الأخير {days} في {month}} last{آخر {days} في {month}} other{{days} في {month}}}", + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{في أول يوم {days} من {month}} second{في ثاني يوم {days} من {month}} third{في ثالث يوم {days} من {month}} fourth{في رابع يوم {days} من {month}} fifth{في خامس يوم {days} من {month}} secondToLast{في يوم {days} قبل الأخير من {month}} last{في آخر يوم {days} من {month}} other{في يوم {days} من {month}}}", "@repeatYearlyOnOrdinalSummary": { "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index df87a92b..c9062db9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -958,7 +958,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} am {days}. {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} und {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} und {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} am {day}. {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} jeweils am {day}. in {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} jeweils an den Tagen {days} in {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{jeweils am ersten} second{jeweils am zweiten} third{jeweils am dritten} fourth{jeweils am vierten} fifth{jeweils am fünften} secondToLast{jeweils am vorletzten} last{jeweils am letzten} other{jeweils an}} {days} in {months}", + "repeatYearlyOnMonthDaysSummary": "{frequency} an den Tagen {days} im {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2991e9a3..75e46093 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1269,15 +1269,82 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} on {month} {days}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "@repeatYearlyMonthValue": { + "description": "Month value used in yearly recurrence sentences. Locales may select an inflected form with {monthKey}; {month} is the CLDR abbreviated month fallback.", + "placeholders": { + "month": {"type": "String"}, + "monthKey": {"type": "String"} + } + }, + "repeatYearlyMonthDayListPair": "{first} and {second}", + "@repeatYearlyMonthDayListPair": { + "description": "Joins the final two localized day-of-month values in a yearly recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "@repeatYearlyMonthDayListStart": { + "description": "Prepends a localized day-of-month value to an already localized yearly recurrence day list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } + }, + "repeatYearlyMonthListPair": "{first} and {second}", + "@repeatYearlyMonthListPair": { + "description": "Joins the final two localized month names in a yearly recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatYearlyMonthListStart": "{first}, {rest}", + "@repeatYearlyMonthListStart": { + "description": "Prepends a localized month name to an already localized yearly recurrence month list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } + }, + "repeatYearlyOnMonthDaySummary": "{frequency} on {month} {day}", + "@repeatYearlyOnMonthDaySummary": { + "description": "Complete yearly recurrence summary for one day in one month.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "day": {"type": "String"} + } + }, + "repeatYearlyOnMonthDaysSummary": "{frequency} on days {days} of {month}", "@repeatYearlyOnMonthDaysSummary": { - "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "description": "Complete yearly recurrence summary for multiple days in one month.", "placeholders": { "frequency": {"type": "String"}, "month": {"type": "String"}, "days": {"type": "String"} } }, + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} on day {day} of {months}", + "@repeatYearlyInMonthsOnMonthDaySummary": { + "description": "Complete yearly recurrence summary for one day in multiple months.", + "placeholders": { + "frequency": {"type": "String"}, + "months": {"type": "String"}, + "day": {"type": "String"} + } + }, + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} on days {days} of {months}", + "@repeatYearlyInMonthsOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for multiple days in multiple months.", + "placeholders": { + "frequency": {"type": "String"}, + "months": {"type": "String"}, + "days": {"type": "String"} + } + }, "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{on the first {days} of {month}} second{on the second {days} of {month}} third{on the third {days} of {month}} fourth{on the fourth {days} of {month}} fifth{on the fifth {days} of {month}} secondToLast{on the second to last {days} of {month}} last{on the last {days} of {month}} other{on {days} of {month}}}", "@repeatYearlyOnOrdinalSummary": { "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", @@ -1287,5 +1354,15 @@ "position": {"type": "String"}, "days": {"type": "String"} } + }, + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{on the first {days} of {months}} second{on the second {days} of {months}} third{on the third {days} of {months}} fourth{on the fourth {days} of {months}} fifth{on the fifth {days} of {months}} secondToLast{on the second to last {days} of {months}} last{on the last {days} of {months}} other{on {days} of {months}}}", + "@repeatYearlyInMonthsOnOrdinalSummary": { + "description": "Complete yearly ordinal-weekday recurrence summary for multiple months.", + "placeholders": { + "frequency": {"type": "String"}, + "months": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 24840f7c..aec2af6c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -958,7 +958,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} el día {days} de {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} y {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} y {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} el día {day} de {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} el día {day} de {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} los días {days} de {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{el primer {days} de {months}} second{el segundo {days} de {months}} third{el tercer {days} de {months}} fourth{el cuarto {days} de {months}} fifth{el quinto {days} de {months}} secondToLast{el penúltimo {days} de {months}} last{el último {days} de {months}} other{en {days} de {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} los días {days} de {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 7ad32200..333c6d23 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -834,7 +834,7 @@ "repeatOnDaysSummary": "päevadel {days}", "repeatOnMonthDaysSummary": "{days} päeval", "repeatOnOrdinalSummary": "{position, select, first{esimene {days}} second{teine {days}} third{kolmas {days}} fourth{neljas {days}} fifth{viies {days}} secondToLast{eelviimane {days}} last{viimane {days}} other{{days}}}", - "repeatInMonthsSummary": "kuudel {months}", + "repeatInMonthsSummary": "{months}", "repeatTimesSummary": "{count} korda", "repeatUntilSummary": "kuni kuupäevani {date}", "unsupportedRecurrencePreserved": "See kordusreegel kasutab valikuid, mida see redaktor ei muuda.", @@ -1073,7 +1073,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} {days} {month} kuupäeval", + "repeatYearlyMonthValue": "{monthKey, select, jan{jaanuaris} feb{veebruaris} mar{märtsis} apr{aprillis} may{mais} jun{juunis} jul{juulis} aug{augustis} sep{septembris} oct{oktoobris} nov{novembris} dec{detsembris} other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} ja {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} ja {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day} päeval", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} {day} päeval", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} {days} päeval", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months}: {position, select, first{esimene} second{teine} third{kolmas} fourth{neljas} fifth{viies} secondToLast{eelviimane} last{viimane} other{}} {days}", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} {days} päeval", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { @@ -1082,7 +1091,7 @@ "days": {"type": "String"} } }, - "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{esimene} second{teine} third{kolmas} fourth{neljas} fifth{viies} secondToLast{eelviimane} last{viimane} other{}} {days} {month}", + "repeatYearlyOnOrdinalSummary": "{frequency} {month}: {position, select, first{esimene} second{teine} third{kolmas} fourth{neljas} fifth{viies} secondToLast{eelviimane} last{viimane} other{}} {days}", "@repeatYearlyOnOrdinalSummary": { "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index b3d98607..f02e50e3 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -959,7 +959,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} در روز {days} ماه {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} و {second}", + "repeatYearlyMonthDayListStart": "{first}، {rest}", + "repeatYearlyMonthListPair": "{first} و {second}", + "repeatYearlyMonthListStart": "{first}، {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} در روز {day} ماه {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} در روز {day} ماه‌های {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} در روزهای {days} ماه‌های {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} در {position, select, first{اولین} second{دومین} third{سومین} fourth{چهارمین} fifth{پنجمین} secondToLast{یکی‌مانده‌به‌آخرین} last{آخرین} other{}} {days} ماه‌های {months}", + "repeatYearlyOnMonthDaysSummary": "{frequency} در روزهای {days} ماه {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 2a2a3261..80600eb1 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -656,7 +656,7 @@ "repeatOnDaysSummary": "päivinä {days}", "repeatOnMonthDaysSummary": "kuukauden päivinä {days}", "repeatOnOrdinalSummary": "{position, select, first{ensimmäisenä {days}} second{toisena {days}} third{kolmantena {days}} fourth{neljäntenä {days}} fifth{viidentenä {days}} secondToLast{toiseksi viimeisenä {days}} last{viimeisenä {days}} other{päivinä {days}}}", - "repeatInMonthsSummary": "kuukausina {months}", + "repeatInMonthsSummary": "{months} aikana", "repeatTimesSummary": "{count} kertaa", "repeatUntilSummary": "{date} asti", "unsupportedRecurrencePreserved": "Tämä toistosääntö käyttää asetuksia, joita tämä muokkain ei muuta.", @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} {days}. {month}", + "repeatYearlyMonthValue": "{monthKey, select, jan{tammikuun} feb{helmikuun} mar{maaliskuun} apr{huhtikuun} may{toukokuun} jun{kesäkuun} jul{heinäkuun} aug{elokuun} sep{syyskuun} oct{lokakuun} nov{marraskuun} dec{joulukuun} other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} ja {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} ja {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day}. päivänä", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} {day}. päivänä", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} päivinä {days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months} {position, select, first{ensimmäisenä} second{toisena} third{kolmantena} fourth{neljäntenä} fifth{viidentenä} secondToLast{toiseksi viimeisenä} last{viimeisenä} other{}} {days}", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} päivinä {days}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { @@ -904,7 +913,7 @@ "days": {"type": "String"} } }, - "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{ensimmäisenä} second{toisena} third{kolmantena} fourth{neljäntenä} fifth{viidentenä} secondToLast{toiseksi viimeisenä} last{viimeisenä} other{}} {days} {month}", + "repeatYearlyOnOrdinalSummary": "{frequency} {month} {position, select, first{ensimmäisenä} second{toisena} third{kolmantena} fourth{neljäntenä} fifth{viidentenä} secondToLast{toiseksi viimeisenä} last{viimeisenä} other{}} {days}", "@repeatYearlyOnOrdinalSummary": { "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7d11da16..cbae6a5b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -958,7 +958,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} le {days} {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} et {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} et {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} le {day} {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} le {day} de {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} les jours {days} de {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{le premier {days} de {months}} second{le deuxième {days} de {months}} third{le troisième {days} de {months}} fourth{le quatrième {days} de {months}} fifth{le cinquième {days} de {months}} secondToLast{l’avant-dernier {days} de {months}} last{le dernier {days} de {months}} other{les {days} de {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} les jours {days} de {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 111ba0f4..90a07a1e 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} {month} के {days}वें दिन", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} और {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} और {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} की {day} तारीख को", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} की {day} तारीख को", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} की {days} तारीखों को", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months} के {position, select, first{पहले} second{दूसरे} third{तीसरे} fourth{चौथे} fifth{पाँचवें} secondToLast{अंतिम से दूसरे} last{अंतिम} other{}} {days} को", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} की {days} तारीखों को", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f69b7ab4..80cfe967 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} il giorno {days} di {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} e {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} e {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} il giorno {day} di {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} il giorno {day} di {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} nei giorni {days} di {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{il primo {days} di {months}} second{il secondo {days} di {months}} third{il terzo {days} di {months}} fourth{il quarto {days} di {months}} fifth{il quinto {days} di {months}} secondToLast{il penultimo {days} di {months}} last{l’ultimo {days} di {months}} other{nei giorni {days} di {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} nei giorni {days} di {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 5829b6b7..d696f0f4 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}と{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}と{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}の{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}の{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}の{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}の{days}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 4ecab574..f290055f 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} {month} {days}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}과 {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first}과 {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months}의 {day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months}의 {days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months}의 {position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month}의 {days}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 46541c90..ec8626e7 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} no dia {days} de {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} e {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} e {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} no dia {day} de {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} no dia {day} de {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} nos dias {days} de {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {months}} second{na segunda ocorrência de {days} de {months}} third{na terceira ocorrência de {days} de {months}} fourth{na quarta ocorrência de {days} de {months}} fifth{na quinta ocorrência de {days} de {months}} secondToLast{na penúltima ocorrência de {days} de {months}} last{na última ocorrência de {days} de {months}} other{em {days} de {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} nos dias {days} de {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f4e01bad..1aa922e0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} {days} числа {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} и {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} и {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}: {month}, {day} числа", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}: {months}, {day} числа", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}: {months}, {days} числа", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}: {months}, {position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}: {month}, {days} числа", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { @@ -904,7 +913,7 @@ "days": {"type": "String"} } }, - "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{в первый {days} {month}} second{во второй {days} {month}} third{в третий {days} {month}} fourth{в четвёртый {days} {month}} fifth{в пятый {days} {month}} secondToLast{в предпоследний {days} {month}} last{в последний {days} {month}} other{в {days} {month}}}", + "repeatYearlyOnOrdinalSummary": "{frequency}: {month}, {position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", "@repeatYearlyOnOrdinalSummary": { "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 58210cf9..a7dae317 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency} vào ngày {days} tháng {month}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} và {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} và {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} vào ngày {day} {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} vào ngày {day} của {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} vào các ngày {days} của {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} vào {days} {position, select, first{đầu tiên} second{thứ hai} third{thứ ba} fourth{thứ tư} fifth{thứ năm} secondToLast{áp chót} last{cuối cùng} other{}} của {months}", + "repeatYearlyOnMonthDaysSummary": "{frequency} vào các ngày {days} của {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { @@ -904,7 +913,7 @@ "days": {"type": "String"} } }, - "repeatYearlyOnOrdinalSummary": "{frequency} vào {position, select, first{thứ nhất} second{thứ hai} third{thứ ba} fourth{thứ tư} fifth{thứ năm} secondToLast{áp chót} last{cuối cùng} other{}} {days} tháng {month}", + "repeatYearlyOnOrdinalSummary": "{frequency} vào {days} {position, select, first{đầu tiên} second{thứ hai} third{thứ ba} fourth{thứ tư} fifth{thứ năm} secondToLast{áp chót} last{cuối cùng} other{}} của {month}", "@repeatYearlyOnOrdinalSummary": { "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 27d77445..4ba1601a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}和{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}和{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}的{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}的{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}的{days}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index a01ac0f6..d1f20792 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}和{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}和{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}的{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}的{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}的{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}的{days}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 071f40da..06d2a1a7 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -895,7 +895,16 @@ "@repeatMonthDayListSeparator": { "description": "Separator between multiple localized recurrence day values." }, - "repeatYearlyOnMonthDaysSummary": "{frequency}{month}{days}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first}和{second}", + "repeatYearlyMonthDayListStart": "{first}、{rest}", + "repeatYearlyMonthListPair": "{first}和{second}", + "repeatYearlyMonthListStart": "{first}、{rest}", + "repeatYearlyOnMonthDaySummary": "{frequency}{month}{day}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency}{months}的{day}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency}{months}的{days}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency}{months}的{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency}{month}的{days}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index b2538da9..cbc80294 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -4128,16 +4128,76 @@ abstract class AppLocalizations { /// **', '** String get repeatMonthDayListSeparator; - /// Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale. + /// Month value used in yearly recurrence sentences. Locales may select an inflected form with {monthKey}; {month} is the CLDR abbreviated month fallback. /// /// In en, this message translates to: - /// **'{frequency} on {month} {days}'** + /// **'{monthKey, select, other{{month}}}'** + String repeatYearlyMonthValue(String month, String monthKey); + + /// Joins the final two localized day-of-month values in a yearly recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatYearlyMonthDayListPair(String first, String second); + + /// Prepends a localized day-of-month value to an already localized yearly recurrence day list. + /// + /// In en, this message translates to: + /// **'{first}, {rest}'** + String repeatYearlyMonthDayListStart(String first, String rest); + + /// Joins the final two localized month names in a yearly recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatYearlyMonthListPair(String first, String second); + + /// Prepends a localized month name to an already localized yearly recurrence month list. + /// + /// In en, this message translates to: + /// **'{first}, {rest}'** + String repeatYearlyMonthListStart(String first, String rest); + + /// Complete yearly recurrence summary for one day in one month. + /// + /// In en, this message translates to: + /// **'{frequency} on {month} {day}'** + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ); + + /// Complete yearly recurrence summary for multiple days in one month. + /// + /// In en, this message translates to: + /// **'{frequency} on days {days} of {month}'** String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ); + /// Complete yearly recurrence summary for one day in multiple months. + /// + /// In en, this message translates to: + /// **'{frequency} on day {day} of {months}'** + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ); + + /// Complete yearly recurrence summary for multiple days in multiple months. + /// + /// In en, this message translates to: + /// **'{frequency} on days {days} of {months}'** + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ); + /// Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale. /// /// In en, this message translates to: @@ -4148,6 +4208,17 @@ abstract class AppLocalizations { String position, String days, ); + + /// Complete yearly ordinal-weekday recurrence summary for multiple months. + /// + /// In en, this message translates to: + /// **'{frequency} {position, select, first{on the first {days} of {months}} second{on the second {days} of {months}} third{on the third {days} of {months}} fourth{on the fourth {days} of {months}} fifth{on the fifth {days} of {months}} secondToLast{on the second to last {days} of {months}} last{on the last {days} of {months}} other{on {days} of {months}}}'** + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ); } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index d62bd220..2c299e0c 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2331,13 +2331,66 @@ class AppLocalizationsAr extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency في يوم $day من $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency في $month يوم $days'; + return '$frequency في الأيام $days من $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency في يوم $day من أشهر $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency في الأيام $days من أشهر $months'; } @override @@ -2348,15 +2401,35 @@ class AppLocalizationsAr extends AppLocalizations { String days, ) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'أول $days في $month', - 'second': 'ثاني $days في $month', - 'third': 'ثالث $days في $month', - 'fourth': 'رابع $days في $month', - 'fifth': 'خامس $days في $month', - 'secondToLast': 'قبل الأخير $days في $month', - 'last': 'آخر $days في $month', - 'other': '$days في $month', + 'first': 'في أول يوم $days من $month', + 'second': 'في ثاني يوم $days من $month', + 'third': 'في ثالث يوم $days من $month', + 'fourth': 'في رابع يوم $days من $month', + 'fifth': 'في خامس يوم $days من $month', + 'secondToLast': 'في يوم $days قبل الأخير من $month', + 'last': 'في آخر يوم $days من $month', + 'other': 'في يوم $days من $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'في أول يوم $days من أشهر $months', + 'second': 'في ثاني يوم $days من أشهر $months', + 'third': 'في ثالث يوم $days من أشهر $months', + 'fourth': 'في رابع يوم $days من أشهر $months', + 'fifth': 'في خامس يوم $days من أشهر $months', + 'secondToLast': 'في يوم $days قبل الأخير من أشهر $months', + 'last': 'في آخر يوم $days من أشهر $months', + 'other': 'في يوم $days من أشهر $months', }); - return '$frequency في $_temp0'; + return '$frequency $_temp0'; } } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index daa8e70a..8833a0fe 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2338,13 +2338,66 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency am $day. $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency am $days. $month'; + return '$frequency an den Tagen $days im $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency jeweils am $day. in $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency jeweils an den Tagen $days in $months'; } @override @@ -2366,4 +2419,24 @@ class AppLocalizationsDe extends AppLocalizations { }); return '$frequency $_temp0 $days im $month'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'jeweils am ersten', + 'second': 'jeweils am zweiten', + 'third': 'jeweils am dritten', + 'fourth': 'jeweils am vierten', + 'fifth': 'jeweils am fünften', + 'secondToLast': 'jeweils am vorletzten', + 'last': 'jeweils am letzten', + 'other': 'jeweils an', + }); + return '$frequency $_temp0 $days in $months'; + } } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 9a6db72a..5cbc5d8b 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2311,13 +2311,66 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency on $month $day'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency on $month $days'; + return '$frequency on days $days of $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency on day $day of $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency on days $days of $months'; } @override @@ -2339,4 +2392,24 @@ class AppLocalizationsEn extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $months', + 'second': 'on the second $days of $months', + 'third': 'on the third $days of $months', + 'fourth': 'on the fourth $days of $months', + 'fifth': 'on the fifth $days of $months', + 'secondToLast': 'on the second to last $days of $months', + 'last': 'on the last $days of $months', + 'other': 'on $days of $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 67279834..cdec0da7 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2340,13 +2340,66 @@ class AppLocalizationsEs extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency el día $day de $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency el día $days de $month'; + return '$frequency los días $days de $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency el día $day de $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency los días $days de $months'; } @override @@ -2368,4 +2421,24 @@ class AppLocalizationsEs extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'el primer $days de $months', + 'second': 'el segundo $days de $months', + 'third': 'el tercer $days de $months', + 'fourth': 'el cuarto $days de $months', + 'fifth': 'el quinto $days de $months', + 'secondToLast': 'el penúltimo $days de $months', + 'last': 'el último $days de $months', + 'other': 'en $days de $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 25b72797..3ee17c95 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1408,7 +1408,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String repeatInMonthsSummary(String months) { - return 'kuudel $months'; + return '$months'; } @override @@ -2317,13 +2317,80 @@ class AppLocalizationsEt extends AppLocalizations { @override String get repeatMonthDayListSeparator => ' ja '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, { + 'jan': 'jaanuaris', + 'feb': 'veebruaris', + 'mar': 'märtsis', + 'apr': 'aprillis', + 'may': 'mais', + 'jun': 'juunis', + 'jul': 'juulis', + 'aug': 'augustis', + 'sep': 'septembris', + 'oct': 'oktoobris', + 'nov': 'novembris', + 'dec': 'detsembris', + 'other': '$month', + }); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month $day päeval'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency $days $month kuupäeval'; + return '$frequency $month $days päeval'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months $day päeval'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months $days päeval'; } @override @@ -2343,6 +2410,26 @@ class AppLocalizationsEt extends AppLocalizations { 'last': 'viimane', 'other': '', }); - return '$frequency $_temp0 $days $month'; + return '$frequency $month: $_temp0 $days'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'esimene', + 'second': 'teine', + 'third': 'kolmas', + 'fourth': 'neljas', + 'fifth': 'viies', + 'secondToLast': 'eelviimane', + 'last': 'viimane', + 'other': '', + }); + return '$frequency $months: $_temp0 $days'; } } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 4d47fd82..66195666 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2351,13 +2351,66 @@ class AppLocalizationsFa extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency در روز $day ماه $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency در روز $days ماه $month'; + return '$frequency در روزهای $days ماه $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency در روز $day ماه‌های $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency در روزهای $days ماه‌های $months'; } @override @@ -2379,4 +2432,24 @@ class AppLocalizationsFa extends AppLocalizations { }); return '$frequency در $_temp0 $days ماه $month'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'اولین', + 'second': 'دومین', + 'third': 'سومین', + 'fourth': 'چهارمین', + 'fifth': 'پنجمین', + 'secondToLast': 'یکی‌مانده‌به‌آخرین', + 'last': 'آخرین', + 'other': '', + }); + return '$frequency در $_temp0 $days ماه‌های $months'; + } } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index a4d7dd35..d1791c2e 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1414,7 +1414,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String repeatInMonthsSummary(String months) { - return 'kuukausina $months'; + return '$months aikana'; } @override @@ -2328,13 +2328,80 @@ class AppLocalizationsFi extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, { + 'jan': 'tammikuun', + 'feb': 'helmikuun', + 'mar': 'maaliskuun', + 'apr': 'huhtikuun', + 'may': 'toukokuun', + 'jun': 'kesäkuun', + 'jul': 'heinäkuun', + 'aug': 'elokuun', + 'sep': 'syyskuun', + 'oct': 'lokakuun', + 'nov': 'marraskuun', + 'dec': 'joulukuun', + 'other': '$month', + }); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month $day. päivänä'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency $days. $month'; + return '$frequency $month päivinä $days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months $day. päivänä'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months päivinä $days'; } @override @@ -2354,6 +2421,26 @@ class AppLocalizationsFi extends AppLocalizations { 'last': 'viimeisenä', 'other': '', }); - return '$frequency $_temp0 $days $month'; + return '$frequency $month $_temp0 $days'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'ensimmäisenä', + 'second': 'toisena', + 'third': 'kolmantena', + 'fourth': 'neljäntenä', + 'fifth': 'viidentenä', + 'secondToLast': 'toiseksi viimeisenä', + 'last': 'viimeisenä', + 'other': '', + }); + return '$frequency $months $_temp0 $days'; } } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 066e809d..ebf46cb3 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2339,13 +2339,66 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency le $day $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency le $days $month'; + return '$frequency les jours $days de $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency le $day de $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency les jours $days de $months'; } @override @@ -2367,4 +2420,24 @@ class AppLocalizationsFr extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'le premier $days de $months', + 'second': 'le deuxième $days de $months', + 'third': 'le troisième $days de $months', + 'fourth': 'le quatrième $days de $months', + 'fifth': 'le cinquième $days de $months', + 'secondToLast': 'l’avant-dernier $days de $months', + 'last': 'le dernier $days de $months', + 'other': 'les $days de $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index f3fcaa0e..d9214f9b 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -2320,13 +2320,66 @@ class AppLocalizationsHi extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month की $day तारीख को'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency $month के $daysवें दिन'; + return '$frequency $month की $days तारीखों को'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months की $day तारीख को'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months की $days तारीखों को'; } @override @@ -2348,4 +2401,24 @@ class AppLocalizationsHi extends AppLocalizations { }); return '$frequency $month के $_temp0 $days को'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'पहले', + 'second': 'दूसरे', + 'third': 'तीसरे', + 'fourth': 'चौथे', + 'fifth': 'पाँचवें', + 'secondToLast': 'अंतिम से दूसरे', + 'last': 'अंतिम', + 'other': '', + }); + return '$frequency $months के $_temp0 $days को'; + } } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index e482ca38..5d0cb058 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2337,13 +2337,66 @@ class AppLocalizationsIt extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency il giorno $day di $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency il giorno $days di $month'; + return '$frequency nei giorni $days di $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency il giorno $day di $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency nei giorni $days di $months'; } @override @@ -2365,4 +2418,24 @@ class AppLocalizationsIt extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'il primo $days di $months', + 'second': 'il secondo $days di $months', + 'third': 'il terzo $days di $months', + 'fourth': 'il quarto $days di $months', + 'fifth': 'il quinto $days di $months', + 'secondToLast': 'il penultimo $days di $months', + 'last': 'l’ultimo $days di $months', + 'other': 'nei giorni $days di $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index dd980907..2cacad8c 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -2273,13 +2273,66 @@ class AppLocalizationsJa extends AppLocalizations { @override String get repeatMonthDayListSeparator => '、'; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$firstと$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$firstと$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency$month$days'; + return '$frequency$monthの$days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$monthsの$day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$monthsの$days'; } @override @@ -2301,4 +2354,24 @@ class AppLocalizationsJa extends AppLocalizations { }); return '$frequency$monthの$_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第1$days', + 'second': '第2$days', + 'third': '第3$days', + 'fourth': '第4$days', + 'fifth': '第5$days', + 'secondToLast': '最後から2番目の$days', + 'last': '最後の$days', + 'other': '$days', + }); + return '$frequency$monthsの$_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index d9583ad7..5ad220a7 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -2275,13 +2275,66 @@ class AppLocalizationsKo extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency $month $day'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency $month $days'; + return '$frequency $month의 $days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency $months의 $day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency $months의 $days'; } @override @@ -2303,4 +2356,24 @@ class AppLocalizationsKo extends AppLocalizations { }); return '$frequency $month의 $_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '첫 번째 $days', + 'second': '두 번째 $days', + 'third': '세 번째 $days', + 'fourth': '네 번째 $days', + 'fifth': '다섯 번째 $days', + 'secondToLast': '끝에서 두 번째 $days', + 'last': '마지막 $days', + 'other': '$days', + }); + return '$frequency $months의 $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 5c8ffc2f..9a15b764 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2337,13 +2337,66 @@ class AppLocalizationsPt extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency no dia $day de $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency no dia $days de $month'; + return '$frequency nos dias $days de $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency no dia $day de $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency nos dias $days de $months'; } @override @@ -2365,4 +2418,24 @@ class AppLocalizationsPt extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'na primeira ocorrência de $days de $months', + 'second': 'na segunda ocorrência de $days de $months', + 'third': 'na terceira ocorrência de $days de $months', + 'fourth': 'na quarta ocorrência de $days de $months', + 'fifth': 'na quinta ocorrência de $days de $months', + 'secondToLast': 'na penúltima ocorrência de $days de $months', + 'last': 'na última ocorrência de $days de $months', + 'other': 'em $days de $months', + }); + return '$frequency $_temp0'; + } } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 80476eff..d79f14bd 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -2342,13 +2342,66 @@ class AppLocalizationsRu extends AppLocalizations { @override String get repeatMonthDayListSeparator => ' и '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency: $month, $day числа'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency $days числа $month'; + return '$frequency: $month, $days числа'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency: $months, $day числа'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency: $months, $days числа'; } @override @@ -2359,15 +2412,35 @@ class AppLocalizationsRu extends AppLocalizations { String days, ) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'в первый $days $month', - 'second': 'во второй $days $month', - 'third': 'в третий $days $month', - 'fourth': 'в четвёртый $days $month', - 'fifth': 'в пятый $days $month', - 'secondToLast': 'в предпоследний $days $month', - 'last': 'в последний $days $month', - 'other': 'в $days $month', + 'first': 'в первый $days', + 'second': 'во второй $days', + 'third': 'в третий $days', + 'fourth': 'в четвёртый $days', + 'fifth': 'в пятый $days', + 'secondToLast': 'в предпоследний $days', + 'last': 'в последний $days', + 'other': 'в $days', + }); + return '$frequency: $month, $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'в первый $days', + 'second': 'во второй $days', + 'third': 'в третий $days', + 'fourth': 'в четвёртый $days', + 'fifth': 'в пятый $days', + 'secondToLast': 'в предпоследний $days', + 'last': 'в последний $days', + 'other': 'в $days', }); - return '$frequency $_temp0'; + return '$frequency: $months, $_temp0'; } } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index bbb4a891..a6c0d4a1 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -2317,13 +2317,66 @@ class AppLocalizationsVi extends AppLocalizations { @override String get repeatMonthDayListSeparator => ', '; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency vào ngày $day $month'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency vào ngày $days tháng $month'; + return '$frequency vào các ngày $days của $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency vào ngày $day của $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency vào các ngày $days của $months'; } @override @@ -2334,7 +2387,27 @@ class AppLocalizationsVi extends AppLocalizations { String days, ) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'thứ nhất', + 'first': 'đầu tiên', + 'second': 'thứ hai', + 'third': 'thứ ba', + 'fourth': 'thứ tư', + 'fifth': 'thứ năm', + 'secondToLast': 'áp chót', + 'last': 'cuối cùng', + 'other': '', + }); + return '$frequency vào $days $_temp0 của $month'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'đầu tiên', 'second': 'thứ hai', 'third': 'thứ ba', 'fourth': 'thứ tư', @@ -2343,6 +2416,6 @@ class AppLocalizationsVi extends AppLocalizations { 'last': 'cuối cùng', 'other': '', }); - return '$frequency vào $_temp0 $days tháng $month'; + return '$frequency vào $days $_temp0 của $months'; } } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index cba2f7df..e48a59da 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -2250,13 +2250,66 @@ class AppLocalizationsZh extends AppLocalizations { @override String get repeatMonthDayListSeparator => '、'; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency$month$days'; + return '$frequency$month的$days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$months的$day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$months的$days'; } @override @@ -2278,6 +2331,26 @@ class AppLocalizationsZh extends AppLocalizations { }); return '$frequency$month的$_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$months的$_temp0'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -4522,13 +4595,66 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get repeatMonthDayListSeparator => '、'; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency$month$days'; + return '$frequency$month的$days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$months的$day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$months的$days'; } @override @@ -4550,6 +4676,26 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { }); return '$frequency$month的$_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一个$days', + 'second': '第二个$days', + 'third': '第三个$days', + 'fourth': '第四个$days', + 'fifth': '第五个$days', + 'secondToLast': '倒数第二个$days', + 'last': '最后一个$days', + 'other': '$days', + }); + return '$frequency$months的$_temp0'; + } } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -6795,13 +6941,66 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get repeatMonthDayListSeparator => '、'; + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency$month$day'; + } + @override String repeatYearlyOnMonthDaysSummary( String frequency, String month, String days, ) { - return '$frequency$month$days'; + return '$frequency$month的$days'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency$months的$day'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency$months的$days'; } @override @@ -6823,4 +7022,24 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { }); return '$frequency$month的$_temp0'; } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': '第一個$days', + 'second': '第二個$days', + 'third': '第三個$days', + 'fourth': '第四個$days', + 'fifth': '第五個$days', + 'secondToLast': '倒數第二個$days', + 'last': '最後一個$days', + 'other': '$days', + }); + return '$frequency$months的$_temp0'; + } } diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index b5d2a105..01360583 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -84,19 +84,51 @@ String recurrenceRuleSummary( } else if ((recurrence.frequency == RecurrenceFrequency.monthly || recurrence.frequency == RecurrenceFrequency.yearly) && recurrence.byMonthDay.isNotEmpty) { - final monthDays = recurrence.byMonthDay + final monthDayValues = recurrence.byMonthDay .map( (day) => l10n.repeatMonthDayValue(_localizedDayNumber(context, day)), ) - .join(l10n.repeatMonthDayListSeparator); + .toList(growable: false); if (recurrence.frequency == RecurrenceFrequency.yearly && recurrence.byMonth.isNotEmpty) { - final months = recurrence.byMonth - .map((month) => _localizedInlineMonth(context, month)) - .join(l10n.repeatMonthDayListSeparator); - summary = l10n.repeatYearlyOnMonthDaysSummary(summary, months, monthDays); + 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, + months, + monthDays, + ), + (1, _) => l10n.repeatYearlyOnMonthDaysSummary( + summary, + months, + monthDays, + ), + (_, 1) => l10n.repeatYearlyInMonthsOnMonthDaySummary( + summary, + months, + monthDays, + ), + _ => l10n.repeatYearlyInMonthsOnMonthDaysSummary( + summary, + months, + monthDays, + ), + }; yearlyMonthPlacedInSummary = true; } else { + final monthDays = monthDayValues.join(l10n.repeatMonthDayListSeparator); final monthDaysSummary = recurrence.byMonthDay.length == 1 ? l10n.repeatOnMonthDaysSummary(monthDays) : l10n.repeatOnMonthDaysSummaryMultiple(monthDays); @@ -106,15 +138,27 @@ String recurrenceRuleSummary( final days = _localizedOrdinalDaySummary(context, recurrence.byDay); if (recurrence.frequency == RecurrenceFrequency.yearly && recurrence.byMonth.isNotEmpty) { - final months = recurrence.byMonth - .map((month) => _localizedInlineMonth(context, month)) - .join(l10n.repeatMonthDayListSeparator); - summary = l10n.repeatYearlyOnOrdinalSummary( - summary, - months, - _ordinalPositionKey(position), - days, + final monthValues = recurrence.byMonth + .map((month) => _localizedYearlyMonth(context, month)) + .toList(growable: false); + final months = _localizedRecurrenceList( + monthValues, + pair: l10n.repeatYearlyMonthListPair, + start: l10n.repeatYearlyMonthListStart, ); + summary = monthValues.length == 1 + ? l10n.repeatYearlyOnOrdinalSummary( + summary, + months, + _ordinalPositionKey(position), + days, + ) + : l10n.repeatYearlyInMonthsOnOrdinalSummary( + summary, + months, + _ordinalPositionKey(position), + days, + ); yearlyMonthPlacedInSummary = true; } else { final ordinalSummary = l10n.repeatOnOrdinalSummary( @@ -127,8 +171,15 @@ String recurrenceRuleSummary( if (recurrence.frequency == RecurrenceFrequency.yearly && recurrence.byMonth.isNotEmpty && !yearlyMonthPlacedInSummary) { - summary = - '$summary ${l10n.repeatInMonthsSummary(recurrence.byMonth.map((month) => _localizedInlineMonth(context, month)).join(', '))}'; + 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!)}'; @@ -669,12 +720,51 @@ String _localizedDayNumber(BuildContext context, int day) { 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 _localizedOrdinal(BuildContext context, int value) => switch (value) { 1 => context.l10n.repeatFirst, 2 => context.l10n.repeatSecond, diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 3922309c..7b6480dd 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -102,6 +102,9 @@ void main() { 'repeatSummarySeparator', 'repeatMonthDayValue', 'repeatMonthDayListSeparator', + 'repeatYearlyMonthDayListStart', + 'repeatYearlyMonthListStart', + 'repeatYearlyMonthValue', }; final failures = []; @@ -501,6 +504,90 @@ void main() { rawRules: [], isSupported: true, ); + const yearlyMonthDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9], + byMonthDay: [1, 15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsMonthDayRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9, 10], + byMonthDay: [15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsMonthDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9, 10], + byMonthDay: [1, 15], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsOrdinalRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: ['MO'], + byMonth: [9, 10], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyThreeMonthsAndDaysRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [8, 9, 10], + byMonthDay: [1, 15, 20], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + const yearlyMonthsOnlyRule = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: [], + byMonth: [9, 10], + byMonthDay: [], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); Future summary(Locale locale, RecurrenceRule rule) async { var value = ''; @@ -520,6 +607,189 @@ void main() { return value; } + const expectedYearlySummaries = >{ + 'ar': [ + 'سنويًا في يوم 15 من سبتمبر', + 'سنويًا في الأيام 1 و15 من سبتمبر', + 'سنويًا في يوم 15 من أشهر سبتمبر وأكتوبر', + 'سنويًا في الأيام 1 و15 من أشهر سبتمبر وأكتوبر', + 'سنويًا في أول يوم الاثنين من سبتمبر', + 'سنويًا في أول يوم الاثنين من أشهر سبتمبر وأكتوبر', + ], + 'de': [ + 'Jährlich am 15. Sept.', + 'Jährlich an den Tagen 1 und 15 im Sept.', + 'Jährlich jeweils am 15. in Sept. und Okt.', + 'Jährlich jeweils an den Tagen 1 und 15 in Sept. und Okt.', + 'Jährlich am ersten Montag im Sept.', + 'Jährlich jeweils am ersten Montag in Sept. und Okt.', + ], + 'en': [ + 'Yearly on Sep 15', + 'Yearly on days 1 and 15 of Sep', + 'Yearly on day 15 of Sep and Oct', + 'Yearly on days 1 and 15 of Sep and Oct', + 'Yearly on the first Monday of Sep', + 'Yearly on the first Monday of Sep and Oct', + ], + 'es': [ + 'Anual el día 15 de sept', + 'Anual los días 1 y 15 de sept', + 'Anual el día 15 de sept y oct', + 'Anual los días 1 y 15 de sept y oct', + 'Anual el primer lunes de sept', + 'Anual el primer lunes de sept y oct', + ], + 'et': [ + 'Iga aasta septembris 15. päeval', + 'Iga aasta septembris 1. ja 15. päeval', + 'Iga aasta septembris ja oktoobris 15. päeval', + 'Iga aasta septembris ja oktoobris 1. ja 15. päeval', + 'Iga aasta septembris: esimene esmaspäev', + 'Iga aasta septembris ja oktoobris: esimene esmaspäev', + ], + 'fa': [ + 'سالانه در روز ۱۵ ماه سپتامبر', + 'سالانه در روزهای ۱ و ۱۵ ماه سپتامبر', + 'سالانه در روز ۱۵ ماه‌های سپتامبر و اکتبر', + 'سالانه در روزهای ۱ و ۱۵ ماه‌های سپتامبر و اکتبر', + 'سالانه در اولین دوشنبه ماه سپتامبر', + 'سالانه در اولین دوشنبه ماه‌های سپتامبر و اکتبر', + ], + 'fi': [ + 'Vuosittain syyskuun 15. päivänä', + 'Vuosittain syyskuun päivinä 1 ja 15', + 'Vuosittain syyskuun ja lokakuun 15. päivänä', + 'Vuosittain syyskuun ja lokakuun päivinä 1 ja 15', + 'Vuosittain syyskuun ensimmäisenä maanantaina', + 'Vuosittain syyskuun ja lokakuun ensimmäisenä maanantaina', + ], + 'fr': [ + 'Annuel le 15 sept.', + 'Annuel les jours 1 et 15 de sept.', + 'Annuel le 15 de sept. et oct.', + 'Annuel les jours 1 et 15 de sept. et oct.', + 'Annuel le premier lundi de sept.', + 'Annuel le premier lundi de sept. et oct.', + ], + 'hi': [ + 'हर वर्ष सित॰ की 15 तारीख को', + 'हर वर्ष सित॰ की 1 और 15 तारीखों को', + 'हर वर्ष सित॰ और अक्तू॰ की 15 तारीख को', + 'हर वर्ष सित॰ और अक्तू॰ की 1 और 15 तारीखों को', + 'हर वर्ष सित॰ के पहले सोमवार को', + 'हर वर्ष सित॰ और अक्तू॰ के पहले सोमवार को', + ], + 'it': [ + 'Ogni anno il giorno 15 di set', + 'Ogni anno nei giorni 1 e 15 di set', + 'Ogni anno il giorno 15 di set e ott', + 'Ogni anno nei giorni 1 e 15 di set e ott', + 'Ogni anno il primo lunedì di set', + 'Ogni anno il primo lunedì di set e ott', + ], + 'ja': [ + '毎年9月15日', + '毎年9月の1日と15日', + '毎年9月と10月の15日', + '毎年9月と10月の1日と15日', + '毎年9月の第1月曜日', + '毎年9月と10月の第1月曜日', + ], + 'ko': [ + '매년 9월 15일', + '매년 9월의 1일과 15일', + '매년 9월과 10월의 15일', + '매년 9월과 10월의 1일과 15일', + '매년 9월의 첫 번째 월요일', + '매년 9월과 10월의 첫 번째 월요일', + ], + 'pt': [ + 'Anualmente no dia 15 de set.', + 'Anualmente nos dias 1 e 15 de set.', + 'Anualmente no dia 15 de set. e out.', + 'Anualmente nos dias 1 e 15 de set. e out.', + 'Anualmente na primeira ocorrência de segunda-feira de set.', + 'Anualmente na primeira ocorrência de segunda-feira de set. e out.', + ], + 'ru': [ + 'Ежегодно: сент., 15-го числа', + 'Ежегодно: сент., 1-го и 15-го числа', + 'Ежегодно: сент. и окт., 15-го числа', + 'Ежегодно: сент. и окт., 1-го и 15-го числа', + 'Ежегодно: сент., в первый понедельник', + 'Ежегодно: сент. и окт., в первый понедельник', + ], + 'vi': [ + 'Hằng năm vào ngày 15 thg 9', + 'Hằng năm vào các ngày 1 và 15 của thg 9', + 'Hằng năm vào ngày 15 của thg 9 và thg 10', + 'Hằng năm vào các ngày 1 và 15 của thg 9 và thg 10', + 'Hằng năm vào Thứ Hai đầu tiên của thg 9', + 'Hằng năm vào Thứ Hai đầu tiên của thg 9 và thg 10', + ], + 'zh': [ + '每年9月15日', + '每年9月的1日和15日', + '每年9月和10月的15日', + '每年9月和10月的1日和15日', + '每年9月的第一个星期一', + '每年9月和10月的第一个星期一', + ], + 'zh-Hans': [ + '每年9月15日', + '每年9月的1日和15日', + '每年9月和10月的15日', + '每年9月和10月的1日和15日', + '每年9月的第一个星期一', + '每年9月和10月的第一个星期一', + ], + 'zh-Hant': [ + '每年9月15日', + '每年9月的1日和15日', + '每年9月和10月的15日', + '每年9月和10月的1日和15日', + '每年9月的第一個星期一', + '每年9月和10月的第一個星期一', + ], + }; + + for (final locale in AppLocalizations.supportedLocales) { + final values = [ + await summary(locale, yearlyMonthDayRule), + await summary(locale, yearlyMonthDaysRule), + await summary(locale, yearlyMonthsMonthDayRule), + await summary(locale, yearlyMonthsMonthDaysRule), + await summary(locale, yearlyOrdinalRule), + await summary(locale, yearlyMonthsOrdinalRule), + ]; + expect( + values, + expectedYearlySummaries[locale.toLanguageTag()], + reason: locale.toLanguageTag(), + ); + } + expect( + await summary(const Locale('en'), yearlyThreeMonthsAndDaysRule), + 'Yearly on days 1, 15 and 20 of Aug, Sep and Oct', + ); + expect( + await summary(const Locale('ja'), yearlyThreeMonthsAndDaysRule), + '毎年8月、9月と10月の1日、15日と20日', + ); + expect( + await summary(const Locale('en'), yearlyMonthsOnlyRule), + 'Yearly in Sep and Oct', + ); + expect( + await summary(const Locale('et'), yearlyMonthsOnlyRule), + 'Iga aasta septembris ja oktoobris', + ); + expect( + await summary(const Locale('fi'), yearlyMonthsOnlyRule), + 'Vuosittain syyskuun ja lokakuun aikana', + ); + expect( await summary(const Locale('de'), ordinalRule), 'Monatlich am ersten Montag', @@ -597,6 +867,70 @@ void main() { expect(AppLocalizationsAr().moreItems(12), contains('${fsi}12$pdi')); }); + test('yearly recurrence month forms cover every month', () { + const monthKeys = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', + ]; + const estonian = [ + 'jaanuaris', + 'veebruaris', + 'märtsis', + 'aprillis', + 'mais', + 'juunis', + 'juulis', + 'augustis', + 'septembris', + 'oktoobris', + 'novembris', + 'detsembris', + ]; + const finnish = [ + 'tammikuun', + 'helmikuun', + 'maaliskuun', + 'huhtikuun', + 'toukokuun', + 'kesäkuun', + 'heinäkuun', + 'elokuun', + 'syyskuun', + 'lokakuun', + 'marraskuun', + 'joulukuun', + ]; + final et = lookupAppLocalizations(const Locale('et')); + final fi = lookupAppLocalizations(const Locale('fi')); + + for (var index = 0; index < monthKeys.length; index += 1) { + expect( + et.repeatYearlyMonthValue('fallback', monthKeys[index]), + estonian[index], + ); + expect( + fi.repeatYearlyMonthValue('fallback', monthKeys[index]), + finnish[index], + ); + } + expect( + lookupAppLocalizations( + const Locale('en'), + ).repeatYearlyMonthValue('Sep', 'sep'), + 'Sep', + ); + }); + test('both Chinese scripts are generated and supported', () { const simplified = Locale.fromSubtags( languageCode: 'zh', From 2b788a5d01ea840ddf2f1f8ff5f92ad401d6a03d Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 18:05:51 -0700 Subject: [PATCH 19/69] feat: update settings screen to default to system page and enhance diagnostics layout --- lib/l10n/app_ar.arb | 4 +- lib/l10n/app_de.arb | 4 +- lib/l10n/app_en.arb | 13 +- lib/l10n/app_es.arb | 4 +- lib/l10n/app_et.arb | 4 +- lib/l10n/app_fa.arb | 4 +- lib/l10n/app_fi.arb | 4 +- lib/l10n/app_fr.arb | 4 +- lib/l10n/app_hi.arb | 4 +- lib/l10n/app_it.arb | 4 +- lib/l10n/app_ja.arb | 4 +- lib/l10n/app_ko.arb | 4 +- lib/l10n/app_pt.arb | 4 +- lib/l10n/app_ru.arb | 4 +- lib/l10n/app_vi.arb | 4 +- lib/l10n/app_zh.arb | 4 +- lib/l10n/app_zh_Hans.arb | 4 +- lib/l10n/app_zh_Hant.arb | 4 +- lib/l10n/generated/app_localizations.dart | 18 ++- lib/l10n/generated/app_localizations_ar.dart | 9 +- lib/l10n/generated/app_localizations_de.dart | 9 +- lib/l10n/generated/app_localizations_en.dart | 9 +- lib/l10n/generated/app_localizations_es.dart | 9 +- lib/l10n/generated/app_localizations_et.dart | 9 +- lib/l10n/generated/app_localizations_fa.dart | 9 +- lib/l10n/generated/app_localizations_fi.dart | 9 +- lib/l10n/generated/app_localizations_fr.dart | 9 +- lib/l10n/generated/app_localizations_hi.dart | 9 +- lib/l10n/generated/app_localizations_it.dart | 9 +- lib/l10n/generated/app_localizations_ja.dart | 9 +- lib/l10n/generated/app_localizations_ko.dart | 9 +- lib/l10n/generated/app_localizations_pt.dart | 9 +- lib/l10n/generated/app_localizations_ru.dart | 9 +- lib/l10n/generated/app_localizations_vi.dart | 9 +- lib/l10n/generated/app_localizations_zh.dart | 27 +++- .../presentation/settings_screen.dart | 73 +++++----- test/app/localization_audit_test.dart | 1 + test/app/native_ui_audit_test.dart | 3 +- .../presentation/settings_screen_test.dart | 126 +++++++++++++++++- 39 files changed, 389 insertions(+), 75 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 4c54052e..b5c3e1cc 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -377,7 +377,8 @@ "synced": "تمت المزامنة", "account": "الحساب", "sync": "المزامنة", - "manualFullSync": "مزامنة كاملة يدويًا", + "forceFullResync": "فرض إعادة مزامنة كاملة", + "forceFullResyncDescription": "إعادة تحميل جميع البيانات بالكامل من كل حساب متصل. استخدم هذا الخيار فقط لاستكشاف مشكلات المزامنة وإصلاحها.", "runInBackgroundWhenClosed": "متابعة التشغيل عند إغلاق النافذة", "showTrayIcon": "إظهار أيقونة شريط النظام", "startMinimizedToTray": "البدء مصغّرًا في شريط النظام", @@ -404,6 +405,7 @@ "appearance": "المظهر", "theme": "السمة", "themeSystem": "النظام", + "settingsSystem": "النظام", "themeLight": "فاتحة", "themeDark": "داكنة", "themeFamily": "عائلة السمات", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c9062db9..7de0530f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -419,7 +419,8 @@ "synced": "Synchronisiert", "account": "Konto", "sync": "Synchronisierung", - "manualFullSync": "Manuelle vollständige Synchronisierung", + "forceFullResync": "Vollständige Neusynchronisierung erzwingen", + "forceFullResyncDescription": "Lädt alle Daten aus jedem verbundenen Konto vollständig neu. Verwenden Sie diese Option nur zur Behebung von Synchronisierungsproblemen.", "runInBackgroundWhenClosed": "Nach dem Schließen des Fensters weiter ausführen", "showTrayIcon": "Symbol im Benachrichtigungsbereich anzeigen", "startMinimizedToTray": "Minimiert im Benachrichtigungsbereich starten", @@ -446,6 +447,7 @@ "appearance": "Darstellung", "theme": "Design", "themeSystem": "Systemstandard", + "settingsSystem": "System", "themeLight": "Hell", "themeDark": "Dunkel", "themeFamily": "Designfamilie", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 75e46093..19e953cf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -795,7 +795,14 @@ "synced": "Synced", "account": "Account", "sync": "Sync", - "manualFullSync": "Manual full sync", + "forceFullResync": "Force full resync", + "@forceFullResync": { + "description": "Troubleshooting action that forces a complete resynchronization of every connected account." + }, + "forceFullResyncDescription": "Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.", + "@forceFullResyncDescription": { + "description": "Explanation shown above the force-full-resync troubleshooting action in Diagnostics." + }, "runInBackgroundWhenClosed": "Continue running when the window is closed", "showTrayIcon": "Show tray icon", "startMinimizedToTray": "Start minimized to the tray", @@ -829,6 +836,10 @@ "appearance": "Appearance", "theme": "Theme", "themeSystem": "System", + "settingsSystem": "System", + "@settingsSystem": { + "description": "Standalone noun used for the System section in the Settings sidebar and page heading." + }, "themeLight": "Light", "themeDark": "Dark", "themeFamily": "Theme family", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index aec2af6c..369c3991 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -419,7 +419,8 @@ "synced": "Sincronizada", "account": "Cuenta", "sync": "Sincronización", - "manualFullSync": "Sincronización completa manual", + "forceFullResync": "Forzar una resincronización completa", + "forceFullResyncDescription": "Vuelve a cargar por completo los datos de todas las cuentas conectadas. Esta opción solo debe usarse para solucionar problemas de sincronización.", "runInBackgroundWhenClosed": "Seguir ejecutándose al cerrar la ventana", "showTrayIcon": "Mostrar el icono en la bandeja del sistema", "startMinimizedToTray": "Iniciar minimizado en la bandeja del sistema", @@ -446,6 +447,7 @@ "appearance": "Apariencia", "theme": "Tema", "themeSystem": "Sistema", + "settingsSystem": "Sistema", "themeLight": "Claro", "themeDark": "Oscuro", "themeFamily": "Familia de temas", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 333c6d23..35e86369 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -478,7 +478,8 @@ "synced": "Sünkroonitud", "account": "Konto", "sync": "Sünkroonimine", - "manualFullSync": "Käsitsi täielik sünkroonimine", + "forceFullResync": "Sunni täielik uuesti sünkroonimine", + "forceFullResyncDescription": "Laadib kõigi ühendatud kontode andmed täielikult uuesti. Kasutage seda ainult sünkroonimisprobleemide tõrkeotsinguks.", "runInBackgroundWhenClosed": "Jätka töötamist, kui aken on suletud", "showTrayIcon": "Kuva süsteemisalve ikoon", "startMinimizedToTray": "Käivita minimeerituna süsteemisalves", @@ -512,6 +513,7 @@ "appearance": "Välimus", "theme": "Kujundus", "themeSystem": "Süsteem", + "settingsSystem": "Süsteem", "themeLight": "Hele", "themeDark": "Tume", "themeFamily": "Kujunduse perekond", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index f02e50e3..41448d74 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -377,7 +377,8 @@ "synced": "همگام‌شده", "account": "حساب", "sync": "همگام‌سازی", - "manualFullSync": "همگام‌سازی کامل دستی", + "forceFullResync": "اجبار به همگام‌سازی مجدد کامل", + "forceFullResyncDescription": "همهٔ داده‌های هر حساب متصل را به‌طور کامل دوباره بارگیری می‌کند. فقط برای عیب‌یابی مشکلات همگام‌سازی از این گزینه استفاده کنید.", "runInBackgroundWhenClosed": "ادامهٔ اجرا پس از بسته شدن پنجره", "showTrayIcon": "نمایش نماد سینی سیستم", "startMinimizedToTray": "شروع به‌صورت کوچک‌شده در سینی سیستم", @@ -404,6 +405,7 @@ "appearance": "ظاهر", "theme": "پوسته", "themeSystem": "سیستم", + "settingsSystem": "سیستم", "themeLight": "روشن", "themeDark": "تیره", "themeFamily": "خانوادهٔ پوسته", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 80600eb1..47e2f08b 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -377,7 +377,8 @@ "synced": "Synkronoitu", "account": "Tili", "sync": "Synkronointi", - "manualFullSync": "Manuaalinen täysi synkronointi", + "forceFullResync": "Pakota täydellinen uudelleensynkronointi", + "forceFullResyncDescription": "Lataa kaikkien yhdistettyjen tilien tiedot kokonaan uudelleen. Käytä tätä vain synkronointiongelmien vianmääritykseen.", "runInBackgroundWhenClosed": "Jatka toimintaa, kun ikkuna suljetaan", "showTrayIcon": "Näytä ilmoitusalueen kuvake", "startMinimizedToTray": "Käynnistä pienennettynä ilmoitusalueelle", @@ -404,6 +405,7 @@ "appearance": "Ulkoasu", "theme": "Teema", "themeSystem": "Järjestelmä", + "settingsSystem": "Järjestelmä", "themeLight": "Vaalea", "themeDark": "Tumma", "themeFamily": "Teemaperhe", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cbae6a5b..f7d3a904 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -419,7 +419,8 @@ "synced": "Synchronisé", "account": "Compte", "sync": "Synchronisation", - "manualFullSync": "Synchronisation complète manuelle", + "forceFullResync": "Forcer une resynchronisation complète", + "forceFullResyncDescription": "Recharge entièrement les données de chaque compte connecté. N’utilisez cette option que pour résoudre des problèmes de synchronisation.", "runInBackgroundWhenClosed": "Continuer à s’exécuter après la fermeture de la fenêtre", "showTrayIcon": "Afficher l’icône dans la zone de notification", "startMinimizedToTray": "Démarrer réduit dans la zone de notification", @@ -446,6 +447,7 @@ "appearance": "Apparence", "theme": "Thème", "themeSystem": "Système", + "settingsSystem": "Système", "themeLight": "Clair", "themeDark": "Sombre", "themeFamily": "Famille de thèmes", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 90a07a1e..de3d46e7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -377,7 +377,8 @@ "synced": "सिंक किया गया", "account": "खाता", "sync": "सिंक", - "manualFullSync": "मैन्युअल पूर्ण सिंक", + "forceFullResync": "पूर्ण पुनः सिंक करें", + "forceFullResyncDescription": "हर कनेक्ट किए गए खाते से सभी डेटा पूरी तरह फिर से लोड करता है। इसका उपयोग केवल सिंक संबंधी समस्याओं के निवारण के लिए करें।", "runInBackgroundWhenClosed": "विंडो बंद होने पर भी चलते रहें", "showTrayIcon": "ट्रे आइकन दिखाएँ", "startMinimizedToTray": "ट्रे में मिनिमाइज़ होकर शुरू करें", @@ -404,6 +405,7 @@ "appearance": "दिखावट", "theme": "थीम", "themeSystem": "सिस्टम", + "settingsSystem": "सिस्टम", "themeLight": "हल्की", "themeDark": "गहरी", "themeFamily": "थीम परिवार", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 80cfe967..92e76313 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -377,7 +377,8 @@ "synced": "Sincronizzato", "account": "Profilo", "sync": "Sincronizzazione", - "manualFullSync": "Sincronizzazione completa manuale", + "forceFullResync": "Forza una risincronizzazione completa", + "forceFullResyncDescription": "Ricarica completamente i dati di ogni account connesso. Usa questa opzione solo per risolvere problemi di sincronizzazione.", "runInBackgroundWhenClosed": "Continua a funzionare quando la finestra è chiusa", "showTrayIcon": "Mostra icona nell’area di notifica", "startMinimizedToTray": "Avvia ridotto a icona nell’area di notifica", @@ -404,6 +405,7 @@ "appearance": "Aspetto", "theme": "Tema", "themeSystem": "Sistema", + "settingsSystem": "Sistema", "themeLight": "Chiaro", "themeDark": "Scuro", "themeFamily": "Famiglia di temi", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d696f0f4..90a91bc2 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -377,7 +377,8 @@ "synced": "同期済み", "account": "アカウント", "sync": "同期", - "manualFullSync": "手動ですべて同期", + "forceFullResync": "完全な再同期を強制", + "forceFullResyncDescription": "接続されているすべてのアカウントからデータを完全に再読み込みします。同期の問題を解決する場合にのみ使用してください。", "runInBackgroundWhenClosed": "ウィンドウを閉じてもバックグラウンドで実行を続ける", "showTrayIcon": "トレイアイコンを表示", "startMinimizedToTray": "トレイに最小化して起動", @@ -404,6 +405,7 @@ "appearance": "外観", "theme": "テーマ", "themeSystem": "システム", + "settingsSystem": "システム", "themeLight": "ライト", "themeDark": "ダーク", "themeFamily": "テーマファミリー", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index f290055f..3f623535 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -377,7 +377,8 @@ "synced": "동기화됨", "account": "계정", "sync": "동기화", - "manualFullSync": "수동 전체 동기화", + "forceFullResync": "전체 재동기화 강제 실행", + "forceFullResyncDescription": "연결된 모든 계정의 데이터를 완전히 다시 불러옵니다. 동기화 문제를 해결할 때만 사용하세요.", "runInBackgroundWhenClosed": "창을 닫아도 계속 실행", "showTrayIcon": "트레이 아이콘 표시", "startMinimizedToTray": "트레이에 최소화하여 시작", @@ -404,6 +405,7 @@ "appearance": "화면 모양", "theme": "테마", "themeSystem": "시스템", + "settingsSystem": "시스템", "themeLight": "라이트", "themeDark": "다크", "themeFamily": "테마 계열", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index ec8626e7..e38aacb6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -377,7 +377,8 @@ "synced": "Sincronizado", "account": "Conta", "sync": "Sincronização", - "manualFullSync": "Sincronização completa manual", + "forceFullResync": "Forçar nova sincronização completa", + "forceFullResyncDescription": "Recarrega completamente os dados de todas as contas ligadas. Utilize esta opção apenas para resolver problemas de sincronização.", "runInBackgroundWhenClosed": "Continuar em execução quando a janela for fechada", "showTrayIcon": "Mostrar ícone na área de notificação", "startMinimizedToTray": "Iniciar minimizado na área de notificação", @@ -404,6 +405,7 @@ "appearance": "Aspeto", "theme": "Tema", "themeSystem": "Sistema", + "settingsSystem": "Sistema", "themeLight": "Claro", "themeDark": "Escuro", "themeFamily": "Família de temas", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1aa922e0..1793b11b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -377,7 +377,8 @@ "synced": "Синхронизировано", "account": "Аккаунт", "sync": "Синхронизация", - "manualFullSync": "Полная синхронизация вручную", + "forceFullResync": "Принудительная полная синхронизация", + "forceFullResyncDescription": "Полностью загружает заново данные всех подключённых аккаунтов. Используйте эту функцию только для устранения проблем с синхронизацией.", "runInBackgroundWhenClosed": "Продолжать работу после закрытия окна", "showTrayIcon": "Показывать значок в области уведомлений", "startMinimizedToTray": "Запускать свёрнутым в область уведомлений", @@ -404,6 +405,7 @@ "appearance": "Внешний вид", "theme": "Тема", "themeSystem": "Системная", + "settingsSystem": "Система", "themeLight": "Светлая", "themeDark": "Тёмная", "themeFamily": "Семейство тем", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index a7dae317..c28a2839 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -377,7 +377,8 @@ "synced": "Đã đồng bộ", "account": "Tài khoản", "sync": "Đồng bộ", - "manualFullSync": "Đồng bộ toàn bộ thủ công", + "forceFullResync": "Buộc đồng bộ hóa lại toàn bộ", + "forceFullResyncDescription": "Tải lại toàn bộ dữ liệu từ mọi tài khoản đã kết nối. Chỉ sử dụng tùy chọn này để khắc phục sự cố đồng bộ hóa.", "runInBackgroundWhenClosed": "Tiếp tục chạy khi đóng cửa sổ", "showTrayIcon": "Hiện biểu tượng khay hệ thống", "startMinimizedToTray": "Khởi động thu nhỏ vào khay hệ thống", @@ -404,6 +405,7 @@ "appearance": "Giao diện", "theme": "Chủ đề", "themeSystem": "Hệ thống", + "settingsSystem": "Hệ thống", "themeLight": "Sáng", "themeDark": "Tối", "themeFamily": "Họ chủ đề", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4ba1601a..995a0f16 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -377,7 +377,8 @@ "synced": "已同步", "account": "帐户", "sync": "同步", - "manualFullSync": "手动完整同步", + "forceFullResync": "强制完全重新同步", + "forceFullResyncDescription": "从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。", "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", "showTrayIcon": "显示托盘图标", "startMinimizedToTray": "启动时最小化到托盘", @@ -404,6 +405,7 @@ "appearance": "外观", "theme": "主题", "themeSystem": "系统", + "settingsSystem": "系统", "themeLight": "浅色", "themeDark": "深色", "themeFamily": "主题系列", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index d1f20792..0a3ef78e 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -377,7 +377,8 @@ "synced": "已同步", "account": "帐户", "sync": "同步", - "manualFullSync": "手动完整同步", + "forceFullResync": "强制完全重新同步", + "forceFullResyncDescription": "从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。", "runInBackgroundWhenClosed": "窗口关闭后继续在后台运行", "showTrayIcon": "显示托盘图标", "startMinimizedToTray": "启动时最小化到托盘", @@ -404,6 +405,7 @@ "appearance": "外观", "theme": "主题", "themeSystem": "系统", + "settingsSystem": "系统", "themeLight": "浅色", "themeDark": "深色", "themeFamily": "主题系列", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 06d2a1a7..e4e74fd8 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -377,7 +377,8 @@ "synced": "已同步", "account": "帳戶", "sync": "同步", - "manualFullSync": "手動完整同步", + "forceFullResync": "強制完整重新同步", + "forceFullResyncDescription": "從每個已連結的帳號完整重新載入所有資料。僅在疑難排解同步問題時使用此功能。", "runInBackgroundWhenClosed": "視窗關閉後繼續在背景執行", "showTrayIcon": "顯示系統匣圖示", "startMinimizedToTray": "啟動時最小化至系統匣", @@ -404,6 +405,7 @@ "appearance": "外觀", "theme": "主題", "themeSystem": "系統", + "settingsSystem": "系統", "themeLight": "淺色", "themeDark": "深色", "themeFamily": "主題系列", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index cbc80294..5fe0b68c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2832,11 +2832,17 @@ abstract class AppLocalizations { /// **'Sync'** String get sync; - /// No description provided for @manualFullSync. + /// Troubleshooting action that forces a complete resynchronization of every connected account. /// /// In en, this message translates to: - /// **'Manual full sync'** - String get manualFullSync; + /// **'Force full resync'** + String get forceFullResync; + + /// Explanation shown above the force-full-resync troubleshooting action in Diagnostics. + /// + /// In en, this message translates to: + /// **'Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.'** + String get forceFullResyncDescription; /// No description provided for @runInBackgroundWhenClosed. /// @@ -2994,6 +3000,12 @@ abstract class AppLocalizations { /// **'System'** String get themeSystem; + /// Standalone noun used for the System section in the Settings sidebar and page heading. + /// + /// In en, this message translates to: + /// **'System'** + String get settingsSystem; + /// No description provided for @themeLight. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 2c299e0c..11afca8e 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1564,7 +1564,11 @@ class AppLocalizationsAr extends AppLocalizations { String get sync => 'المزامنة'; @override - String get manualFullSync => 'مزامنة كاملة يدويًا'; + String get forceFullResync => 'فرض إعادة مزامنة كاملة'; + + @override + String get forceFullResyncDescription => + 'إعادة تحميل جميع البيانات بالكامل من كل حساب متصل. استخدم هذا الخيار فقط لاستكشاف مشكلات المزامنة وإصلاحها.'; @override String get runInBackgroundWhenClosed => 'متابعة التشغيل عند إغلاق النافذة'; @@ -1648,6 +1652,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get themeSystem => 'النظام'; + @override + String get settingsSystem => 'النظام'; + @override String get themeLight => 'فاتحة'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 8833a0fe..dd75593f 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1562,7 +1562,11 @@ class AppLocalizationsDe extends AppLocalizations { String get sync => 'Synchronisierung'; @override - String get manualFullSync => 'Manuelle vollständige Synchronisierung'; + String get forceFullResync => 'Vollständige Neusynchronisierung erzwingen'; + + @override + String get forceFullResyncDescription => + 'Lädt alle Daten aus jedem verbundenen Konto vollständig neu. Verwenden Sie diese Option nur zur Behebung von Synchronisierungsproblemen.'; @override String get runInBackgroundWhenClosed => @@ -1651,6 +1655,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get themeSystem => 'Systemstandard'; + @override + String get settingsSystem => 'System'; + @override String get themeLight => 'Hell'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 5cbc5d8b..691e78e4 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1550,7 +1550,11 @@ class AppLocalizationsEn extends AppLocalizations { String get sync => 'Sync'; @override - String get manualFullSync => 'Manual full sync'; + String get forceFullResync => 'Force full resync'; + + @override + String get forceFullResyncDescription => + 'Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.'; @override String get runInBackgroundWhenClosed => @@ -1635,6 +1639,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get themeSystem => 'System'; + @override + String get settingsSystem => 'System'; + @override String get themeLight => 'Light'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index cdec0da7..05c59f5f 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1565,7 +1565,11 @@ class AppLocalizationsEs extends AppLocalizations { String get sync => 'Sincronización'; @override - String get manualFullSync => 'Sincronización completa manual'; + String get forceFullResync => 'Forzar una resincronización completa'; + + @override + String get forceFullResyncDescription => + 'Vuelve a cargar por completo los datos de todas las cuentas conectadas. Esta opción solo debe usarse para solucionar problemas de sincronización.'; @override String get runInBackgroundWhenClosed => @@ -1654,6 +1658,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get themeSystem => 'Sistema'; + @override + String get settingsSystem => 'Sistema'; + @override String get themeLight => 'Claro'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 3ee17c95..ff752aeb 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1553,7 +1553,11 @@ class AppLocalizationsEt extends AppLocalizations { String get sync => 'Sünkroonimine'; @override - String get manualFullSync => 'Käsitsi täielik sünkroonimine'; + String get forceFullResync => 'Sunni täielik uuesti sünkroonimine'; + + @override + String get forceFullResyncDescription => + 'Laadib kõigi ühendatud kontode andmed täielikult uuesti. Kasutage seda ainult sünkroonimisprobleemide tõrkeotsinguks.'; @override String get runInBackgroundWhenClosed => @@ -1638,6 +1642,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get themeSystem => 'Süsteem'; + @override + String get settingsSystem => 'Süsteem'; + @override String get themeLight => 'Hele'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 66195666..3215dea9 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1572,7 +1572,11 @@ class AppLocalizationsFa extends AppLocalizations { String get sync => 'همگام‌سازی'; @override - String get manualFullSync => 'همگام‌سازی کامل دستی'; + String get forceFullResync => 'اجبار به همگام‌سازی مجدد کامل'; + + @override + String get forceFullResyncDescription => + 'همهٔ داده‌های هر حساب متصل را به‌طور کامل دوباره بارگیری می‌کند. فقط برای عیب‌یابی مشکلات همگام‌سازی از این گزینه استفاده کنید.'; @override String get runInBackgroundWhenClosed => 'ادامهٔ اجرا پس از بسته شدن پنجره'; @@ -1656,6 +1660,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get themeSystem => 'سیستم'; + @override + String get settingsSystem => 'سیستم'; + @override String get themeLight => 'روشن'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index d1791c2e..e7b75a26 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1559,7 +1559,11 @@ class AppLocalizationsFi extends AppLocalizations { String get sync => 'Synkronointi'; @override - String get manualFullSync => 'Manuaalinen täysi synkronointi'; + String get forceFullResync => 'Pakota täydellinen uudelleensynkronointi'; + + @override + String get forceFullResyncDescription => + 'Lataa kaikkien yhdistettyjen tilien tiedot kokonaan uudelleen. Käytä tätä vain synkronointiongelmien vianmääritykseen.'; @override String get runInBackgroundWhenClosed => @@ -1645,6 +1649,9 @@ class AppLocalizationsFi extends AppLocalizations { @override String get themeSystem => 'Järjestelmä'; + @override + String get settingsSystem => 'Järjestelmä'; + @override String get themeLight => 'Vaalea'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index ebf46cb3..e680c04f 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1563,7 +1563,11 @@ class AppLocalizationsFr extends AppLocalizations { String get sync => 'Synchronisation'; @override - String get manualFullSync => 'Synchronisation complète manuelle'; + String get forceFullResync => 'Forcer une resynchronisation complète'; + + @override + String get forceFullResyncDescription => + 'Recharge entièrement les données de chaque compte connecté. N’utilisez cette option que pour résoudre des problèmes de synchronisation.'; @override String get runInBackgroundWhenClosed => @@ -1651,6 +1655,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get themeSystem => 'Système'; + @override + String get settingsSystem => 'Système'; + @override String get themeLight => 'Clair'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index d9214f9b..d5589936 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1557,7 +1557,11 @@ class AppLocalizationsHi extends AppLocalizations { String get sync => 'सिंक'; @override - String get manualFullSync => 'मैन्युअल पूर्ण सिंक'; + String get forceFullResync => 'पूर्ण पुनः सिंक करें'; + + @override + String get forceFullResyncDescription => + 'हर कनेक्ट किए गए खाते से सभी डेटा पूरी तरह फिर से लोड करता है। इसका उपयोग केवल सिंक संबंधी समस्याओं के निवारण के लिए करें।'; @override String get runInBackgroundWhenClosed => 'विंडो बंद होने पर भी चलते रहें'; @@ -1641,6 +1645,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get themeSystem => 'सिस्टम'; + @override + String get settingsSystem => 'सिस्टम'; + @override String get themeLight => 'हल्की'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 5d0cb058..28f2498e 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1565,7 +1565,11 @@ class AppLocalizationsIt extends AppLocalizations { String get sync => 'Sincronizzazione'; @override - String get manualFullSync => 'Sincronizzazione completa manuale'; + String get forceFullResync => 'Forza una risincronizzazione completa'; + + @override + String get forceFullResyncDescription => + 'Ricarica completamente i dati di ogni account connesso. Usa questa opzione solo per risolvere problemi di sincronizzazione.'; @override String get runInBackgroundWhenClosed => @@ -1653,6 +1657,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get themeSystem => 'Sistema'; + @override + String get settingsSystem => 'Sistema'; + @override String get themeLight => 'Chiaro'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 2cacad8c..b652d568 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1528,7 +1528,11 @@ class AppLocalizationsJa extends AppLocalizations { String get sync => '同期'; @override - String get manualFullSync => '手動ですべて同期'; + String get forceFullResync => '完全な再同期を強制'; + + @override + String get forceFullResyncDescription => + '接続されているすべてのアカウントからデータを完全に再読み込みします。同期の問題を解決する場合にのみ使用してください。'; @override String get runInBackgroundWhenClosed => 'ウィンドウを閉じてもバックグラウンドで実行を続ける'; @@ -1611,6 +1615,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get themeSystem => 'システム'; + @override + String get settingsSystem => 'システム'; + @override String get themeLight => 'ライト'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 5ad220a7..ae63520d 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1530,7 +1530,11 @@ class AppLocalizationsKo extends AppLocalizations { String get sync => '동기화'; @override - String get manualFullSync => '수동 전체 동기화'; + String get forceFullResync => '전체 재동기화 강제 실행'; + + @override + String get forceFullResyncDescription => + '연결된 모든 계정의 데이터를 완전히 다시 불러옵니다. 동기화 문제를 해결할 때만 사용하세요.'; @override String get runInBackgroundWhenClosed => '창을 닫아도 계속 실행'; @@ -1613,6 +1617,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get themeSystem => '시스템'; + @override + String get settingsSystem => '시스템'; + @override String get themeLight => '라이트'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 9a15b764..88b07940 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1564,7 +1564,11 @@ class AppLocalizationsPt extends AppLocalizations { String get sync => 'Sincronização'; @override - String get manualFullSync => 'Sincronização completa manual'; + String get forceFullResync => 'Forçar nova sincronização completa'; + + @override + String get forceFullResyncDescription => + 'Recarrega completamente os dados de todas as contas ligadas. Utilize esta opção apenas para resolver problemas de sincronização.'; @override String get runInBackgroundWhenClosed => @@ -1651,6 +1655,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get themeSystem => 'Sistema'; + @override + String get settingsSystem => 'Sistema'; + @override String get themeLight => 'Claro'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index d79f14bd..e246980f 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1571,7 +1571,11 @@ class AppLocalizationsRu extends AppLocalizations { String get sync => 'Синхронизация'; @override - String get manualFullSync => 'Полная синхронизация вручную'; + String get forceFullResync => 'Принудительная полная синхронизация'; + + @override + String get forceFullResyncDescription => + 'Полностью загружает заново данные всех подключённых аккаунтов. Используйте эту функцию только для устранения проблем с синхронизацией.'; @override String get runInBackgroundWhenClosed => @@ -1657,6 +1661,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get themeSystem => 'Системная'; + @override + String get settingsSystem => 'Система'; + @override String get themeLight => 'Светлая'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index a6c0d4a1..6890547f 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1555,7 +1555,11 @@ class AppLocalizationsVi extends AppLocalizations { String get sync => 'Đồng bộ'; @override - String get manualFullSync => 'Đồng bộ toàn bộ thủ công'; + String get forceFullResync => 'Buộc đồng bộ hóa lại toàn bộ'; + + @override + String get forceFullResyncDescription => + 'Tải lại toàn bộ dữ liệu từ mọi tài khoản đã kết nối. Chỉ sử dụng tùy chọn này để khắc phục sự cố đồng bộ hóa.'; @override String get runInBackgroundWhenClosed => 'Tiếp tục chạy khi đóng cửa sổ'; @@ -1640,6 +1644,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get themeSystem => 'Hệ thống'; + @override + String get settingsSystem => 'Hệ thống'; + @override String get themeLight => 'Sáng'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index e48a59da..7e111399 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1516,7 +1516,11 @@ class AppLocalizationsZh extends AppLocalizations { String get sync => '同步'; @override - String get manualFullSync => '手动完整同步'; + String get forceFullResync => '强制完全重新同步'; + + @override + String get forceFullResyncDescription => + '从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。'; @override String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; @@ -1598,6 +1602,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get themeSystem => '系统'; + @override + String get settingsSystem => '系统'; + @override String get themeLight => '浅色'; @@ -3861,7 +3868,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get sync => '同步'; @override - String get manualFullSync => '手动完整同步'; + String get forceFullResync => '强制完全重新同步'; + + @override + String get forceFullResyncDescription => + '从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。'; @override String get runInBackgroundWhenClosed => '窗口关闭后继续在后台运行'; @@ -3943,6 +3954,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get themeSystem => '系统'; + @override + String get settingsSystem => '系统'; + @override String get themeLight => '浅色'; @@ -6207,7 +6221,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get sync => '同步'; @override - String get manualFullSync => '手動完整同步'; + String get forceFullResync => '強制完整重新同步'; + + @override + String get forceFullResyncDescription => + '從每個已連結的帳號完整重新載入所有資料。僅在疑難排解同步問題時使用此功能。'; @override String get runInBackgroundWhenClosed => '視窗關閉後繼續在背景執行'; @@ -6289,6 +6307,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get themeSystem => '系統'; + @override + String get settingsSystem => '系統'; + @override String get themeLight => '淺色'; diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 13d9491e..26559816 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -43,7 +43,7 @@ final _settingsLogger = RedactingLogger(Logger('SettingsScreen')); const _systemLocaleTag = 'system'; class SettingsScreen extends ConsumerStatefulWidget { - const SettingsScreen({super.key, this.initialPage = SettingsPage.accounts}); + const SettingsScreen({super.key, this.initialPage = SettingsPage.system}); final SettingsPage initialPage; @@ -177,16 +177,23 @@ class _SettingsScreenState extends ConsumerState { ], ), SettingsPage.system => BusyMaxGroupedList( - title: l10n.themeSystem, + title: l10n.settingsSystem, filled: true, children: [ - BusyMaxActionRow( - title: l10n.manualFullSync, - leading: const Icon(YaruIcons.sync), - enabled: accounts.isNotEmpty, - onTap: accounts.isEmpty - ? null - : () => _fullSync(context, ref, accounts), + BusyMaxComboRow( + title: l10n.currentLocale, + leading: const Icon(Icons.language), + values: [ + _systemLocaleTag, + for (final option in busyMaxLocaleOptions) option.tag, + ], + selected: settings.localeTag ?? _systemLocaleTag, + labelFor: (tag) => tag == _systemLocaleTag + ? l10n.themeSystem + : busyMaxLocaleEndonym(tag), + onSelected: (tag) => settingsController.setLocaleTag( + tag == _systemLocaleTag ? null : tag, + ), ), BusyMaxSwitchRow( title: l10n.showTrayIcon, @@ -227,21 +234,6 @@ class _SettingsScreenState extends ConsumerState { labelFor: (value) => _themeModeLabel(context, value), onSelected: themeController.setThemeMode, ), - BusyMaxComboRow( - title: l10n.currentLocale, - leading: const Icon(Icons.language), - values: [ - _systemLocaleTag, - for (final option in busyMaxLocaleOptions) option.tag, - ], - selected: settings.localeTag ?? _systemLocaleTag, - labelFor: (tag) => tag == _systemLocaleTag - ? l10n.themeSystem - : busyMaxLocaleEndonym(tag), - onSelected: (tag) => settingsController.setLocaleTag( - tag == _systemLocaleTag ? null : tag, - ), - ), ], ), SettingsPage.notifications => BusyMaxGroupedList( @@ -329,7 +321,27 @@ class _SettingsScreenState extends ConsumerState { ), ], ), - SettingsPage.diagnostics => const DiagnosticsPanel(scrollable: false), + SettingsPage.diagnostics => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMaxGroupedList( + title: l10n.sync, + description: l10n.forceFullResyncDescription, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.forceFullResync, + leading: const Icon(YaruIcons.sync), + enabled: accounts.isNotEmpty, + onTap: accounts.isEmpty + ? null + : () => _forceFullResync(context, ref, accounts), + ), + ], + ), + const DiagnosticsPanel(scrollable: false), + ], + ), }; return Scaffold( @@ -544,7 +556,7 @@ class _SettingsScreenState extends ConsumerState { return; } if (action == BusyMaxHeaderBarAction.settings) { - _selectPage(SettingsPage.accounts); + _selectPage(SettingsPage.system); return; } if (action == BusyMaxHeaderBarAction.keyboardShortcuts) { @@ -822,7 +834,7 @@ class _SettingsScreenState extends ConsumerState { } } - Future _fullSync( + Future _forceFullResync( BuildContext context, WidgetRef ref, List accounts, @@ -1018,9 +1030,9 @@ class _SettingsFallbackHeader extends StatelessWidget { } enum SettingsPage { + system, accounts, schedule, - system, notifications, privacy, diagnostics, @@ -1028,12 +1040,13 @@ enum SettingsPage { SettingsPage settingsPageFromRouteValue(String? value) { return switch (value) { + 'accounts' => SettingsPage.accounts, 'schedule' => SettingsPage.schedule, 'system' => SettingsPage.system, 'notifications' => SettingsPage.notifications, 'privacy' => SettingsPage.privacy, 'diagnostics' => SettingsPage.diagnostics, - _ => SettingsPage.accounts, + _ => SettingsPage.system, }; } @@ -1044,7 +1057,7 @@ String _settingsPageLabel(BuildContext context, SettingsPage page) { return switch (page) { SettingsPage.accounts => l10n.accounts, SettingsPage.schedule => l10n.scheduleSettings, - SettingsPage.system => l10n.themeSystem, + SettingsPage.system => l10n.settingsSystem, SettingsPage.notifications => l10n.notifications, SettingsPage.privacy => l10n.privacy, SettingsPage.diagnostics => l10n.diagnostics, diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 7b6480dd..b057f372 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -105,6 +105,7 @@ void main() { 'repeatYearlyMonthDayListStart', 'repeatYearlyMonthListStart', 'repeatYearlyMonthValue', + 'settingsSystem', }; final failures = []; diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 1cde78d3..83034ac0 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -241,7 +241,8 @@ void main() { expect(router, isNot(contains("path: '/diagnostics'"))); expect(router, isNot(contains('DiagnosticsScreen'))); expect(settings, contains('SettingsPage.system')); - expect(settings, contains('l10n.manualFullSync')); + expect(settings, contains('l10n.forceFullResync')); + expect(settings, contains('l10n.forceFullResyncDescription')); expect(settings, contains('l10n.currentLocale')); expect(settings, isNot(contains('SettingsPage.sync'))); expect(settings, isNot(contains('SettingsPage.appearance'))); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 831fd944..b46da078 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -52,6 +52,42 @@ void main() { .setMockMethodCallHandler(_nativeMenuChannel, null); }); + test('Settings defaults generic routes to System', () { + expect(const SettingsScreen().initialPage, SettingsPage.system); + expect(settingsPageFromRouteValue(null), SettingsPage.system); + expect(settingsPageFromRouteValue('unknown'), SettingsPage.system); + expect(settingsPageFromRouteValue('system'), SettingsPage.system); + expect(settingsPageFromRouteValue('accounts'), SettingsPage.accounts); + }); + + testWidgets('Settings opens the System page by default', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); + + await _pumpDefaultSettings( + tester, + container, + logicalSize: const Size(1000, 700), + ); + + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-system')), + ) + .selected, + isTrue, + ); + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Add Google account'), findsNothing); + }); + testWidgets('Settings fallback header uses the semantic title style', ( tester, ) async { @@ -504,12 +540,49 @@ void main() { expect(find.text('Theme'), findsOneWidget); expect(find.text('Current locale'), findsOneWidget); - expect(find.text('Manual full sync'), findsOneWidget); + expect(find.text('Force full resync'), findsNothing); expect(find.text('Launch at login'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Current locale')).dy, + lessThan(tester.getTopLeft(find.text('Show tray icon')).dy), + ); expect(find.text('Theme family'), findsNothing); expect(find.text('Add Google account'), findsNothing); }); + testWidgets('Russian System section uses a standalone noun', (tester) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + buildConfig: _configuredBuildConfig, + activeAccountIdOverride: null, + ); + addTearDown(container.dispose); + + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + locale: const Locale('ru'), + ); + + final systemNavigation = find.byKey( + const ValueKey('settings-navigation-system'), + ); + expect( + find.descendant(of: systemNavigation, matching: find.text('Система')), + findsOneWidget, + ); + expect(find.text('Системная'), findsNothing); + + await tester.tap(systemNavigation); + await tester.pumpAndSettle(); + + expect(find.text('Система'), findsNWidgets(2)); + expect(find.text('Системная'), findsWidgets); + }); + testWidgets('Settings workspace uses the native window surface', ( tester, ) async { @@ -569,6 +642,15 @@ void main() { ); expect(find.byType(YaruNavigationRail), findsNothing); expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + final navigationTiles = tester + .widgetList( + find.byType(BusyMaxSidebarNavigationTile), + ) + .toList(growable: false); + expect( + navigationTiles.first.key, + const ValueKey('settings-navigation-system'), + ); final accountsTile = tester.widget( find.byKey(const ValueKey('settings-navigation-accounts')), ); @@ -634,6 +716,14 @@ void main() { await tester.tap(find.text('Diagnostics')); await tester.pumpAndSettle(); + expect(find.text('Force full resync'), findsOneWidget); + expect( + find.text( + 'Completely reload data from every connected account. Use this only ' + 'to troubleshoot synchronization problems.', + ), + findsOneWidget, + ); expect(find.text('Google Tasks API'), findsOneWidget); expect(find.text('Accounts'), findsOneWidget); expect(find.text('System'), findsOneWidget); @@ -943,6 +1033,8 @@ Future _pumpSettings( ProviderContainer container, { Size? logicalSize, TextScaler? textScaler, + Locale locale = const Locale('en'), + SettingsPage initialPage = SettingsPage.accounts, }) async { if (logicalSize != null) { tester.view.devicePixelRatio = 1; @@ -951,17 +1043,37 @@ Future _pumpSettings( addTearDown(tester.view.resetPhysicalSize); } final settings = textScaler == null - ? const SettingsScreen() + ? SettingsScreen(initialPage: initialPage) : Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: textScaler), - child: const SettingsScreen(), + child: SettingsScreen(initialPage: initialPage), ), ); await tester.pumpWidget( UncontrolledProviderScope( container: container, - child: localizedTestApp(child: settings), + child: localizedTestApp(locale: locale, child: settings), + ), + ); + await tester.pumpAndSettle(); +} + +Future _pumpDefaultSettings( + WidgetTester tester, + ProviderContainer container, { + Size? logicalSize, +}) async { + if (logicalSize != null) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = logicalSize; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + } + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp(child: const SettingsScreen()), ), ); await tester.pumpAndSettle(); @@ -975,7 +1087,11 @@ Future _pumpRoutedSettings( final router = GoRouter( initialLocation: initialLocation, routes: [ - GoRoute(path: '/settings', builder: (_, _) => const SettingsScreen()), + GoRoute( + path: '/settings', + builder: (_, _) => + const SettingsScreen(initialPage: SettingsPage.accounts), + ), GoRoute( path: '/schedule', builder: (_, _) => const Text('schedule route'), From d61adea6fb4863bdbc664eea0af6d8f9bc4e76a4 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 18:48:40 -0700 Subject: [PATCH 20/69] feat: enhance diagnostics section with additional sync information and spacing --- .../features/settings/presentation/settings_screen.dart | 7 +++++-- .../settings/presentation/settings_screen_test.dart | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 26559816..b988e93c 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -324,9 +324,11 @@ class _SettingsScreenState extends ConsumerState { SettingsPage.diagnostics => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Text(l10n.sync, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: BusyMaxSpacing.sm), + Text(l10n.forceFullResyncDescription), BusyMaxGroupedList( - title: l10n.sync, - description: l10n.forceFullResyncDescription, + key: const ValueKey('diagnostics-full-resync-section'), filled: true, children: [ BusyMaxActionRow( @@ -339,6 +341,7 @@ class _SettingsScreenState extends ConsumerState { ), ], ), + const SizedBox(height: BusyMaxSpacing.lg), const DiagnosticsPanel(scrollable: false), ], ), diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index b46da078..37270f1c 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -725,6 +725,15 @@ void main() { findsOneWidget, ); expect(find.text('Google Tasks API'), findsOneWidget); + expect( + tester.widget(find.text('Sync')).style, + tester.widget(find.text('Google Tasks API')).style, + ); + final resyncSection = tester.getRect( + find.byKey(const ValueKey('diagnostics-full-resync-section')), + ); + final diagnosticsContent = tester.getRect(find.text('Google Tasks API')); + expect(diagnosticsContent.top - resyncSection.bottom, BusyMaxSpacing.lg); expect(find.text('Accounts'), findsOneWidget); expect(find.text('System'), findsOneWidget); expect(find.text('Add Google account'), findsNothing); From 2269350063d12ef80ce287337ba783a4ff966c5b Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 19:14:17 -0700 Subject: [PATCH 21/69] fix translations --- lib/l10n/app_ar.arb | 25 +-- lib/l10n/app_de.arb | 11 +- lib/l10n/app_en.arb | 44 +++- lib/l10n/app_es.arb | 11 +- lib/l10n/app_et.arb | 11 +- lib/l10n/app_fa.arb | 11 +- lib/l10n/app_fi.arb | 11 +- lib/l10n/app_fr.arb | 11 +- lib/l10n/app_hi.arb | 11 +- lib/l10n/app_it.arb | 11 +- lib/l10n/app_ja.arb | 11 +- lib/l10n/app_ko.arb | 11 +- lib/l10n/app_pt.arb | 11 +- lib/l10n/app_ru.arb | 11 +- lib/l10n/app_vi.arb | 11 +- lib/l10n/app_zh.arb | 11 +- lib/l10n/app_zh_Hans.arb | 11 +- lib/l10n/app_zh_Hant.arb | 11 +- lib/l10n/generated/app_localizations.dart | 32 ++- lib/l10n/generated/app_localizations_ar.dart | 138 ++++++++++--- lib/l10n/generated/app_localizations_de.dart | 45 +++- lib/l10n/generated/app_localizations_en.dart | 45 +++- lib/l10n/generated/app_localizations_es.dart | 45 +++- lib/l10n/generated/app_localizations_et.dart | 45 +++- lib/l10n/generated/app_localizations_fa.dart | 45 +++- lib/l10n/generated/app_localizations_fi.dart | 45 +++- lib/l10n/generated/app_localizations_fr.dart | 45 +++- lib/l10n/generated/app_localizations_hi.dart | 45 +++- lib/l10n/generated/app_localizations_it.dart | 45 +++- lib/l10n/generated/app_localizations_ja.dart | 44 +++- lib/l10n/generated/app_localizations_ko.dart | 44 +++- lib/l10n/generated/app_localizations_pt.dart | 45 +++- lib/l10n/generated/app_localizations_ru.dart | 47 ++++- lib/l10n/generated/app_localizations_vi.dart | 44 +++- lib/l10n/generated/app_localizations_zh.dart | 132 +++++++++++- .../presentation/recurrence_editor.dart | 50 +++-- test/app/localization_audit_test.dart | 193 +++++++++++++++++- 37 files changed, 1237 insertions(+), 182 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b5c3e1cc..de449448 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -651,15 +651,16 @@ "repeatAnyDay": "اليوم", "repeatWeekday": "يوم من أيام الأسبوع", "repeatWeekendDay": "يوم عطلة نهاية الأسبوع", - "repeatEveryDays": "كل {count} أيام", - "repeatEveryWeeks": "كل {count} أسابيع", - "repeatEveryMonths": "كل {count} أشهر", - "repeatEveryYears": "كل {count} سنوات", + "repeatOrdinalDaySummary": "{dayKey, select, MO{اثنين} TU{ثلاثاء} WE{أربعاء} TH{خميس} FR{جمعة} SA{سبت} SU{أحد} day{يوم} weekday{يوم من أيام الأسبوع} weekend{يوم من عطلة نهاية الأسبوع} other{{day}}}", + "repeatEveryDays": "{count, plural, one{كل يوم} two{كل يومين} few{كل {count} أيام} many{كل {count} يومًا} other{كل {count} يوم}}", + "repeatEveryWeeks": "{count, plural, one{كل أسبوع} two{كل أسبوعين} few{كل {count} أسابيع} many{كل {count} أسبوعًا} other{كل {count} أسبوع}}", + "repeatEveryMonths": "{count, plural, one{كل شهر} two{كل شهرين} few{كل {count} أشهر} many{كل {count} شهرًا} other{كل {count} شهر}}", + "repeatEveryYears": "{count, plural, one{كل سنة} two{كل سنتين} few{كل {count} سنوات} many{كل {count} سنة} other{كل {count} سنة}}", "repeatOnDaysSummary": "في {days}", "repeatOnMonthDaysSummary": "في اليوم {days} من الشهر", - "repeatOnOrdinalSummary": "{position, select, first{في {days} الأول} second{في {days} الثاني} third{في {days} الثالث} fourth{في {days} الرابع} fifth{في {days} الخامس} secondToLast{في {days} ما قبل الأخير} last{في {days} الأخير} other{في {days}}}", + "repeatOnOrdinalSummary": "{position, select, first{في أول {days}} second{في ثاني {days}} third{في ثالث {days}} fourth{في رابع {days}} fifth{في خامس {days}} secondToLast{في {days} قبل الأخير} last{في آخر {days}} other{في {days}}}", "repeatInMonthsSummary": "في {months}", - "repeatTimesSummary": "{count} مرات", + "repeatTimesSummary": "{count, plural, one{مرة واحدة} two{مرتين} few{{count} مرات} many{{count} مرة} other{{count} مرة}}", "repeatUntilSummary": "حتى {date}", "unsupportedRecurrencePreserved": "تستخدم قاعدة التكرار هذه خيارات لا يغيّرها هذا المحرر.", "recurrenceUnsupportedByProvider": "لا يمكن استخدام هذا التكرار مع {provider}.", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} و{second}", + "repeatWeekdayListStart": "{first}، {rest}", + "repeatMonthDayListPair": "{first} و{second}", + "repeatMonthDayListStart": "{first}، {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} و{second}", "repeatYearlyMonthDayListStart": "{first}، {rest}", @@ -905,7 +906,7 @@ "repeatYearlyOnMonthDaySummary": "{frequency} في يوم {day} من {month}", "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} في يوم {day} من أشهر {months}", "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} في الأيام {days} من أشهر {months}", - "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{في أول يوم {days} من أشهر {months}} second{في ثاني يوم {days} من أشهر {months}} third{في ثالث يوم {days} من أشهر {months}} fourth{في رابع يوم {days} من أشهر {months}} fifth{في خامس يوم {days} من أشهر {months}} secondToLast{في يوم {days} قبل الأخير من أشهر {months}} last{في آخر يوم {days} من أشهر {months}} other{في يوم {days} من أشهر {months}}}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{في أول {days} من أشهر {months}} second{في ثاني {days} من أشهر {months}} third{في ثالث {days} من أشهر {months}} fourth{في رابع {days} من أشهر {months}} fifth{في خامس {days} من أشهر {months}} secondToLast{في {days} قبل الأخير من أشهر {months}} last{في آخر {days} من أشهر {months}} other{في {days} من أشهر {months}}}", "repeatYearlyOnMonthDaysSummary": "{frequency} في الأيام {days} من {month}", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", @@ -915,7 +916,7 @@ "days": {"type": "String"} } }, - "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{في أول يوم {days} من {month}} second{في ثاني يوم {days} من {month}} third{في ثالث يوم {days} من {month}} fourth{في رابع يوم {days} من {month}} fifth{في خامس يوم {days} من {month}} secondToLast{في يوم {days} قبل الأخير من {month}} last{في آخر يوم {days} من {month}} other{في يوم {days} من {month}}}", + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{في أول {days} من {month}} second{في ثاني {days} من {month}} third{في ثالث {days} من {month}} fourth{في رابع {days} من {month}} fifth{في خامس {days} من {month}} secondToLast{في {days} قبل الأخير من {month}} last{في آخر {days} من {month}} other{في {days} من {month}}}", "@repeatYearlyOnOrdinalSummary": { "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7de0530f..e6ac26cb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -714,6 +714,7 @@ "repeatAnyDay": "Tag", "repeatWeekday": "Wochentag", "repeatWeekendDay": "Wochenendtag", + "repeatOrdinalDaySummary": "{dayKey, select, MO{Montag} TU{Dienstag} WE{Mittwoch} TH{Donnerstag} FR{Freitag} SA{Samstag} SU{Sonntag} day{Tag} weekday{Wochentag} weekend{Wochenendtag} other{{day}}}", "repeatEveryDays": "Alle {count} Tage", "repeatEveryWeeks": "Alle {count} Wochen", "repeatEveryMonths": "Alle {count} Monate", @@ -722,7 +723,7 @@ "repeatOnMonthDaysSummary": "am {days}. Tag", "repeatOnOrdinalSummary": "{position, select, first{am ersten {days}} second{am zweiten {days}} third{am dritten {days}} fourth{am vierten {days}} fifth{am fünften {days}} secondToLast{am vorletzten {days}} last{am letzten {days}} other{an {days}}}", "repeatInMonthsSummary": "in den Monaten {months}", - "repeatTimesSummary": "{count}-mal", + "repeatTimesSummary": "{count, plural, one{{count} Mal} other{{count}-mal}}", "repeatUntilSummary": "bis {date}", "unsupportedRecurrencePreserved": "Diese Wiederholungsregel verwendet Optionen, die dieser Editor nicht ändert.", "recurrenceUnsupportedByProvider": "Diese Wiederholung kann nicht mit {provider} verwendet werden.", @@ -956,10 +957,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} und {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} und {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} und {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 19e953cf..751d9fa5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -641,6 +641,14 @@ "repeatAnyDay": "Day", "repeatWeekday": "Weekday", "repeatWeekendDay": "Weekend day", + "repeatOrdinalDaySummary": "{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} day{day} weekday{weekday} weekend{weekend day} other{{day}}}", + "@repeatOrdinalDaySummary": { + "description": "Sentence-form day label used inside ordinal recurrence summaries. {dayKey} is MO through SU, day, weekday, or weekend; {day} is the locale-formatted weekday fallback.", + "placeholders": { + "dayKey": {"type": "String"}, + "day": {"type": "String"} + } + }, "repeatEveryDays": "Every {count} days", "@repeatEveryDays": { "placeholders": { @@ -712,7 +720,7 @@ } } }, - "repeatTimesSummary": "{count} times", + "repeatTimesSummary": "{count, plural, one{{count} time} other{{count} times}}", "@repeatTimesSummary": { "description": "Sentence fragment summarizing a finite recurrence count.", "placeholders": { @@ -1276,9 +1284,37 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." + "repeatWeekdayListPair": "{first} and {second}", + "@repeatWeekdayListPair": { + "description": "Joins the final two weekday names in a recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatWeekdayListStart": "{first}, {rest}", + "@repeatWeekdayListStart": { + "description": "Prepends a weekday name to an already localized recurrence weekday list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } + }, + "repeatMonthDayListPair": "{first} and {second}", + "@repeatMonthDayListPair": { + "description": "Joins the final two day-of-month values in a monthly recurrence summary.", + "placeholders": { + "first": {"type": "String"}, + "second": {"type": "String"} + } + }, + "repeatMonthDayListStart": "{first}, {rest}", + "@repeatMonthDayListStart": { + "description": "Prepends a day-of-month value to an already localized monthly recurrence day list.", + "placeholders": { + "first": {"type": "String"}, + "rest": {"type": "String"} + } }, "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "@repeatYearlyMonthValue": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 369c3991..32b8fc4f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -714,6 +714,7 @@ "repeatAnyDay": "Día", "repeatWeekday": "Día laborable", "repeatWeekendDay": "Día de fin de semana", + "repeatOrdinalDaySummary": "{dayKey, select, MO{lunes} TU{martes} WE{miércoles} TH{jueves} FR{viernes} SA{sábado} SU{domingo} day{día} weekday{día laborable} weekend{día de fin de semana} other{{day}}}", "repeatEveryDays": "Cada {count} días", "repeatEveryWeeks": "Cada {count} semanas", "repeatEveryMonths": "Cada {count} meses", @@ -722,7 +723,7 @@ "repeatOnMonthDaysSummary": "el día {days}", "repeatOnOrdinalSummary": "{position, select, first{el primer {days}} second{el segundo {days}} third{el tercer {days}} fourth{el cuarto {days}} fifth{el quinto {days}} secondToLast{el penúltimo {days}} last{el último {days}} other{en {days}}}", "repeatInMonthsSummary": "en {months}", - "repeatTimesSummary": "{count} veces", + "repeatTimesSummary": "{count, plural, one{{count} vez} other{{count} veces}}", "repeatUntilSummary": "hasta {date}", "unsupportedRecurrencePreserved": "Esta regla de repetición usa opciones que este editor no modifica.", "recurrenceUnsupportedByProvider": "Esta repetición no se puede usar con {provider}.", @@ -956,10 +957,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} y {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} y {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} y {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 35e86369..18ce25c3 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -829,6 +829,7 @@ "repeatAnyDay": "Päev", "repeatWeekday": "Tööpäev", "repeatWeekendDay": "Nädalavahetuse päev", + "repeatOrdinalDaySummary": "{dayKey, select, MO{esmaspäev} TU{teisipäev} WE{kolmapäev} TH{neljapäev} FR{reede} SA{laupäev} SU{pühapäev} day{päev} weekday{tööpäev} weekend{nädalavahetuse päev} other{{day}}}", "repeatEveryDays": "Iga {count} päeva järel", "repeatEveryWeeks": "Iga {count} nädala järel", "repeatEveryMonths": "Iga {count} kuu järel", @@ -837,7 +838,7 @@ "repeatOnMonthDaysSummary": "{days} päeval", "repeatOnOrdinalSummary": "{position, select, first{esimene {days}} second{teine {days}} third{kolmas {days}} fourth{neljas {days}} fifth{viies {days}} secondToLast{eelviimane {days}} last{viimane {days}} other{{days}}}", "repeatInMonthsSummary": "{months}", - "repeatTimesSummary": "{count} korda", + "repeatTimesSummary": "{count, plural, one{{count} kord} other{{count} korda}}", "repeatUntilSummary": "kuni kuupäevani {date}", "unsupportedRecurrencePreserved": "See kordusreegel kasutab valikuid, mida see redaktor ei muuda.", "recurrenceUnsupportedByProvider": "Seda kordust ei saa teenusega {provider} kasutada.", @@ -1071,10 +1072,10 @@ } } }, - "repeatMonthDayListSeparator": " ja ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} ja {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} ja {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, jan{jaanuaris} feb{veebruaris} mar{märtsis} apr{aprillis} may{mais} jun{juunis} jul{juulis} aug{augustis} sep{septembris} oct{oktoobris} nov{novembris} dec{detsembris} other{{month}}}", "repeatYearlyMonthDayListPair": "{first} ja {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 41448d74..ca449726 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -715,6 +715,7 @@ "repeatAnyDay": "روز", "repeatWeekday": "روز هفته", "repeatWeekendDay": "روز آخر هفته", + "repeatOrdinalDaySummary": "{dayKey, select, MO{دوشنبه} TU{سه‌شنبه} WE{چهارشنبه} TH{پنجشنبه} FR{جمعه} SA{شنبه} SU{یکشنبه} day{روز} weekday{روز هفته} weekend{روز آخر هفته} other{{day}}}", "repeatEveryDays": "هر {count} روز", "repeatEveryWeeks": "هر {count} هفته", "repeatEveryMonths": "هر {count} ماه", @@ -723,7 +724,7 @@ "repeatOnMonthDaysSummary": "در روز {days} ماه", "repeatOnOrdinalSummary": "{position, select, first{در اولین {days}} second{در دومین {days}} third{در سومین {days}} fourth{در چهارمین {days}} fifth{در پنجمین {days}} secondToLast{در یکی‌مانده‌به‌آخرین {days}} last{در آخرین {days}} other{در {days}}}", "repeatInMonthsSummary": "در {months}", - "repeatTimesSummary": "{count} بار", + "repeatTimesSummary": "{count, plural, one{{count} بار} other{{count} بار}}", "repeatUntilSummary": "تا {date}", "unsupportedRecurrencePreserved": "این قانون تکرار از گزینه‌هایی استفاده می‌کند که این ویرایشگر تغییر نمی‌دهد.", "recurrenceUnsupportedByProvider": "این تکرار را نمی‌توان با {provider} استفاده کرد.", @@ -957,10 +958,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} و {second}", + "repeatWeekdayListStart": "{first}، {rest}", + "repeatMonthDayListPair": "{first} و {second}", + "repeatMonthDayListStart": "{first}، {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} و {second}", "repeatYearlyMonthDayListStart": "{first}، {rest}", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 47e2f08b..b52e6ee7 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "Päivä", "repeatWeekday": "Arkipäivä", "repeatWeekendDay": "Viikonlopun päivä", + "repeatOrdinalDaySummary": "{dayKey, select, MO{maanantaina} TU{tiistaina} WE{keskiviikkona} TH{torstaina} FR{perjantaina} SA{lauantaina} SU{sunnuntaina} day{päivänä} weekday{arkipäivänä} weekend{viikonlopun päivänä} other{{day}}}", "repeatEveryDays": "Joka {count}. päivä", "repeatEveryWeeks": "Joka {count}. viikko", "repeatEveryMonths": "Joka {count}. kuukausi", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "kuukauden päivinä {days}", "repeatOnOrdinalSummary": "{position, select, first{ensimmäisenä {days}} second{toisena {days}} third{kolmantena {days}} fourth{neljäntenä {days}} fifth{viidentenä {days}} secondToLast{toiseksi viimeisenä {days}} last{viimeisenä {days}} other{päivinä {days}}}", "repeatInMonthsSummary": "{months} aikana", - "repeatTimesSummary": "{count} kertaa", + "repeatTimesSummary": "{count, plural, one{{count} kerran} other{{count} kertaa}}", "repeatUntilSummary": "{date} asti", "unsupportedRecurrencePreserved": "Tämä toistosääntö käyttää asetuksia, joita tämä muokkain ei muuta.", "recurrenceUnsupportedByProvider": "Tätä toistoa ei voi käyttää palvelussa {provider}.", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} ja {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} ja {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, jan{tammikuun} feb{helmikuun} mar{maaliskuun} apr{huhtikuun} may{toukokuun} jun{kesäkuun} jul{heinäkuun} aug{elokuun} sep{syyskuun} oct{lokakuun} nov{marraskuun} dec{joulukuun} other{{month}}}", "repeatYearlyMonthDayListPair": "{first} ja {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f7d3a904..fb4e23d5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -714,6 +714,7 @@ "repeatAnyDay": "Jour", "repeatWeekday": "Jour de semaine", "repeatWeekendDay": "Jour du week-end", + "repeatOrdinalDaySummary": "{dayKey, select, MO{lundi} TU{mardi} WE{mercredi} TH{jeudi} FR{vendredi} SA{samedi} SU{dimanche} day{jour} weekday{jour de semaine} weekend{jour du week-end} other{{day}}}", "repeatEveryDays": "Tous les {count} jours", "repeatEveryWeeks": "Toutes les {count} semaines", "repeatEveryMonths": "Tous les {count} mois", @@ -722,7 +723,7 @@ "repeatOnMonthDaysSummary": "le {days} du mois", "repeatOnOrdinalSummary": "{position, select, first{le premier {days}} second{le deuxième {days}} third{le troisième {days}} fourth{le quatrième {days}} fifth{le cinquième {days}} secondToLast{l’avant-dernier {days}} last{le dernier {days}} other{les {days}}}", "repeatInMonthsSummary": "en {months}", - "repeatTimesSummary": "{count} fois", + "repeatTimesSummary": "{count, plural, one{{count} fois} other{{count} fois}}", "repeatUntilSummary": "jusqu’au {date}", "unsupportedRecurrencePreserved": "Cette règle de récurrence utilise des options que cet éditeur ne modifie pas.", "recurrenceUnsupportedByProvider": "Cette récurrence ne peut pas être utilisée avec {provider}.", @@ -956,10 +957,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} et {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} et {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} et {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index de3d46e7..7566f637 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "दिन", "repeatWeekday": "सप्ताह का दिन", "repeatWeekendDay": "सप्ताहांत का दिन", + "repeatOrdinalDaySummary": "{dayKey, select, MO{सोमवार} TU{मंगलवार} WE{बुधवार} TH{गुरुवार} FR{शुक्रवार} SA{शनिवार} SU{रविवार} day{दिन} weekday{सप्ताह का दिन} weekend{सप्ताहांत का दिन} other{{day}}}", "repeatEveryDays": "हर {count} दिन", "repeatEveryWeeks": "हर {count} सप्ताह", "repeatEveryMonths": "हर {count} महीने", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "के {days}वें दिन", "repeatOnOrdinalSummary": "{position, select, first{के पहले {days} को} second{के दूसरे {days} को} third{के तीसरे {days} को} fourth{के चौथे {days} को} fifth{के पाँचवें {days} को} secondToLast{के अंतिम से दूसरे {days} को} last{के अंतिम {days} को} other{के {days} को}}", "repeatInMonthsSummary": "{months} में", - "repeatTimesSummary": "{count} बार", + "repeatTimesSummary": "{count, plural, one{{count} बार} other{{count} बार}}", "repeatUntilSummary": "{date} तक", "unsupportedRecurrencePreserved": "यह दोहराव नियम ऐसे विकल्पों का उपयोग करता है जिन्हें यह संपादक नहीं बदलता।", "recurrenceUnsupportedByProvider": "इस पुनरावृत्ति का उपयोग {provider} के साथ नहीं किया जा सकता।", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} और {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} और {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} और {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 92e76313..6435ce21 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "Giorno", "repeatWeekday": "Giorno feriale", "repeatWeekendDay": "Giorno del fine settimana", + "repeatOrdinalDaySummary": "{dayKey, select, MO{lunedì} TU{martedì} WE{mercoledì} TH{giovedì} FR{venerdì} SA{sabato} SU{domenica} day{giorno} weekday{giorno feriale} weekend{giorno del fine settimana} other{{day}}}", "repeatEveryDays": "Ogni {count} giorni", "repeatEveryWeeks": "Ogni {count} settimane", "repeatEveryMonths": "Ogni {count} mesi", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "il giorno {days}", "repeatOnOrdinalSummary": "{position, select, first{il primo {days}} second{il secondo {days}} third{il terzo {days}} fourth{il quarto {days}} fifth{il quinto {days}} secondToLast{il penultimo {days}} last{l’ultimo {days}} other{nei giorni {days}}}", "repeatInMonthsSummary": "nei mesi {months}", - "repeatTimesSummary": "{count} volte", + "repeatTimesSummary": "{count, plural, one{{count} volta} other{{count} volte}}", "repeatUntilSummary": "fino al {date}", "unsupportedRecurrencePreserved": "Questa regola di ricorrenza usa opzioni che questo editor non modifica.", "recurrenceUnsupportedByProvider": "Questa ricorrenza non può essere usata con {provider}.", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} e {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} e {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} e {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 90a91bc2..3f08f290 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "日", "repeatWeekday": "平日", "repeatWeekendDay": "週末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{月曜日} TU{火曜日} WE{水曜日} TH{木曜日} FR{金曜日} SA{土曜日} SU{日曜日} day{日} weekday{平日} weekend{週末} other{{day}}}", "repeatEveryDays": "{count} 日ごと", "repeatEveryWeeks": "{count} 週間ごと", "repeatEveryMonths": "{count} か月ごと", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第1{days}} second{第2{days}} third{第3{days}} fourth{第4{days}} fifth{第5{days}} secondToLast{最後から2番目の{days}} last{最後の{days}} other{{days}}}", "repeatInMonthsSummary": "{months} に", - "repeatTimesSummary": "{count} 回", + "repeatTimesSummary": "{count, plural, other{{count} 回}}", "repeatUntilSummary": "{date} まで", "unsupportedRecurrencePreserved": "この繰り返しルールには、このエディターで変更されないオプションが含まれています。", "recurrenceUnsupportedByProvider": "この繰り返しは {provider} では使用できません。", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": "、", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first}、{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first}と{second}", "repeatYearlyMonthDayListStart": "{first}、{rest}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3f623535..4239112e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "요일", "repeatWeekday": "평일", "repeatWeekendDay": "주말", + "repeatOrdinalDaySummary": "{dayKey, select, MO{월요일} TU{화요일} WE{수요일} TH{목요일} FR{금요일} SA{토요일} SU{일요일} day{날} weekday{평일} weekend{주말} other{{day}}}", "repeatEveryDays": "{count}일마다", "repeatEveryWeeks": "{count}주마다", "repeatEveryMonths": "{count}개월마다", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{첫 번째 {days}} second{두 번째 {days}} third{세 번째 {days}} fourth{네 번째 {days}} fifth{다섯 번째 {days}} secondToLast{끝에서 두 번째 {days}} last{마지막 {days}} other{{days}}}", "repeatInMonthsSummary": "{months}에", - "repeatTimesSummary": "{count}회", + "repeatTimesSummary": "{count, plural, other{{count}회}}", "repeatUntilSummary": "{date}까지", "unsupportedRecurrencePreserved": "이 반복 규칙에는 이 편집기에서 변경하지 않는 옵션이 사용됩니다.", "recurrenceUnsupportedByProvider": "이 반복은 {provider}에서 사용할 수 없습니다.", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first}과 {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first}과 {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first}과 {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index e38aacb6..694d354f 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "Dia", "repeatWeekday": "Dia útil", "repeatWeekendDay": "Dia do fim de semana", + "repeatOrdinalDaySummary": "{dayKey, select, MO{segunda-feira} TU{terça-feira} WE{quarta-feira} TH{quinta-feira} FR{sexta-feira} SA{sábado} SU{domingo} day{dia} weekday{dia útil} weekend{dia do fim de semana} other{{day}}}", "repeatEveryDays": "A cada {count} dias", "repeatEveryWeeks": "A cada {count} semanas", "repeatEveryMonths": "A cada {count} meses", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "no dia {days}", "repeatOnOrdinalSummary": "{position, select, first{na primeira ocorrência de {days}} second{na segunda ocorrência de {days}} third{na terceira ocorrência de {days}} fourth{na quarta ocorrência de {days}} fifth{na quinta ocorrência de {days}} secondToLast{na penúltima ocorrência de {days}} last{na última ocorrência de {days}} other{em {days}}}", "repeatInMonthsSummary": "em {months}", - "repeatTimesSummary": "{count} vezes", + "repeatTimesSummary": "{count, plural, one{{count} vez} other{{count} vezes}}", "repeatUntilSummary": "até {date}", "unsupportedRecurrencePreserved": "Esta regra de recorrência utiliza opções que este editor não altera.", "recurrenceUnsupportedByProvider": "Esta recorrência não pode ser usada com {provider}.", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} e {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} e {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} e {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1793b11b..0b6078ba 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "День", "repeatWeekday": "Будний день", "repeatWeekendDay": "Выходной день", + "repeatOrdinalDaySummary": "{dayKey, select, MO{понедельник} TU{вторник} WE{среду} TH{четверг} FR{пятницу} SA{субботу} SU{воскресенье} day{день} weekday{будний день} weekend{выходной день} other{{day}}}", "repeatEveryDays": "Каждые {count} дн.", "repeatEveryWeeks": "Каждые {count} нед.", "repeatEveryMonths": "Каждые {count} мес.", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "{days} числа месяца", "repeatOnOrdinalSummary": "{position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", "repeatInMonthsSummary": "в месяцы {months}", - "repeatTimesSummary": "{count} раз", + "repeatTimesSummary": "{count, plural, one{{count} раз} few{{count} раза} many{{count} раз} other{{count} раза}}", "repeatUntilSummary": "до {date}", "unsupportedRecurrencePreserved": "Это правило повторения использует параметры, которые этот редактор не изменяет.", "recurrenceUnsupportedByProvider": "Это повторение нельзя использовать с {provider}.", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": " и ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} и {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} и {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} и {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index c28a2839..08f0ad16 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "Ngày", "repeatWeekday": "Ngày trong tuần", "repeatWeekendDay": "Ngày cuối tuần", + "repeatOrdinalDaySummary": "{dayKey, select, MO{Thứ Hai} TU{Thứ Ba} WE{Thứ Tư} TH{Thứ Năm} FR{Thứ Sáu} SA{Thứ Bảy} SU{Chủ Nhật} day{ngày} weekday{ngày trong tuần} weekend{ngày cuối tuần} other{{day}}}", "repeatEveryDays": "Mỗi {count} ngày", "repeatEveryWeeks": "Mỗi {count} tuần", "repeatEveryMonths": "Mỗi {count} tháng", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "vào ngày {days} trong tháng", "repeatOnOrdinalSummary": "{position, select, first{vào {days} đầu tiên} second{vào {days} thứ hai} third{vào {days} thứ ba} fourth{vào {days} thứ tư} fifth{vào {days} thứ năm} secondToLast{vào {days} áp chót} last{vào {days} cuối cùng} other{vào {days}}}", "repeatInMonthsSummary": "vào {months}", - "repeatTimesSummary": "{count} lần", + "repeatTimesSummary": "{count, plural, other{{count} lần}}", "repeatUntilSummary": "đến {date}", "unsupportedRecurrencePreserved": "Quy tắc lặp lại này sử dụng các tùy chọn mà trình chỉnh sửa này không thay đổi.", "recurrenceUnsupportedByProvider": "Không thể sử dụng kiểu lặp lại này với {provider}.", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": ", ", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first} và {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} và {second}", + "repeatMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first} và {second}", "repeatYearlyMonthDayListStart": "{first}, {rest}", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 995a0f16..8df6b96c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "日期", "repeatWeekday": "工作日", "repeatWeekendDay": "周末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} day{日期} weekday{工作日} weekend{周末} other{{day}}}", "repeatEveryDays": "每 {count} 天", "repeatEveryWeeks": "每 {count} 周", "repeatEveryMonths": "每 {count} 个月", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "重复 {count} 次", + "repeatTimesSummary": "{count, plural, other{重复 {count} 次}}", "repeatUntilSummary": "直到 {date}", "unsupportedRecurrencePreserved": "此重复规则使用了编辑器不会更改的选项。", "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": "、", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first}和{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first}和{second}", "repeatYearlyMonthDayListStart": "{first}、{rest}", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 0a3ef78e..d1ceade4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "日期", "repeatWeekday": "工作日", "repeatWeekendDay": "周末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} day{日期} weekday{工作日} weekend{周末} other{{day}}}", "repeatEveryDays": "每 {count} 天", "repeatEveryWeeks": "每 {count} 周", "repeatEveryMonths": "每 {count} 个月", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第一个{days}} second{第二个{days}} third{第三个{days}} fourth{第四个{days}} fifth{第五个{days}} secondToLast{倒数第二个{days}} last{最后一个{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "重复 {count} 次", + "repeatTimesSummary": "{count, plural, other{重复 {count} 次}}", "repeatUntilSummary": "直到 {date}", "unsupportedRecurrencePreserved": "此重复规则使用了编辑器不会更改的选项。", "recurrenceUnsupportedByProvider": "此重复规则不能用于 {provider}。", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": "、", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first}和{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first}和{second}", "repeatYearlyMonthDayListStart": "{first}、{rest}", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index e4e74fd8..764f2de4 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -651,6 +651,7 @@ "repeatAnyDay": "日期", "repeatWeekday": "工作日", "repeatWeekendDay": "週末", + "repeatOrdinalDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} day{日期} weekday{工作日} weekend{週末} other{{day}}}", "repeatEveryDays": "每 {count} 天", "repeatEveryWeeks": "每 {count} 週", "repeatEveryMonths": "每 {count} 個月", @@ -659,7 +660,7 @@ "repeatOnMonthDaysSummary": "{days}", "repeatOnOrdinalSummary": "{position, select, first{第一個{days}} second{第二個{days}} third{第三個{days}} fourth{第四個{days}} fifth{第五個{days}} secondToLast{倒數第二個{days}} last{最後一個{days}} other{{days}}}", "repeatInMonthsSummary": "在 {months}", - "repeatTimesSummary": "重複 {count} 次", + "repeatTimesSummary": "{count, plural, other{重複 {count} 次}}", "repeatUntilSummary": "直到 {date}", "unsupportedRecurrencePreserved": "此重複規則使用了編輯器不會更改的選項。", "recurrenceUnsupportedByProvider": "此重複規則無法用於 {provider}。", @@ -893,10 +894,10 @@ } } }, - "repeatMonthDayListSeparator": "、", - "@repeatMonthDayListSeparator": { - "description": "Separator between multiple localized recurrence day values." - }, + "repeatWeekdayListPair": "{first}和{second}", + "repeatWeekdayListStart": "{first}、{rest}", + "repeatMonthDayListPair": "{first}、{second}", + "repeatMonthDayListStart": "{first}、{rest}", "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", "repeatYearlyMonthDayListPair": "{first}和{second}", "repeatYearlyMonthDayListStart": "{first}、{rest}", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 5fe0b68c..e0352ebe 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2526,6 +2526,12 @@ abstract class AppLocalizations { /// **'Weekend day'** String get repeatWeekendDay; + /// Sentence-form day label used inside ordinal recurrence summaries. {dayKey} is MO through SU, day, weekday, or weekend; {day} is the locale-formatted weekday fallback. + /// + /// In en, this message translates to: + /// **'{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} day{day} weekday{weekday} weekend{weekend day} other{{day}}}'** + String repeatOrdinalDaySummary(String dayKey, String day); + /// No description provided for @repeatEveryDays. /// /// In en, this message translates to: @@ -2577,7 +2583,7 @@ abstract class AppLocalizations { /// Sentence fragment summarizing a finite recurrence count. /// /// In en, this message translates to: - /// **'{count} times'** + /// **'{count, plural, one{{count} time} other{{count} times}}'** String repeatTimesSummary(int count); /// Sentence fragment for the recurrence end date. @@ -4134,11 +4140,29 @@ abstract class AppLocalizations { /// **'{day}'** String repeatMonthDayValue(String day); - /// Separator between multiple localized recurrence day values. + /// Joins the final two weekday names in a recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatWeekdayListPair(String first, String second); + + /// Prepends a weekday name to an already localized recurrence weekday list. + /// + /// In en, this message translates to: + /// **'{first}, {rest}'** + String repeatWeekdayListStart(String first, String rest); + + /// Joins the final two day-of-month values in a monthly recurrence summary. + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String repeatMonthDayListPair(String first, String second); + + /// Prepends a day-of-month value to an already localized monthly recurrence day list. /// /// In en, this message translates to: - /// **', '** - String get repeatMonthDayListSeparator; + /// **'{first}, {rest}'** + String repeatMonthDayListStart(String first, String rest); /// Month value used in yearly recurrence sentences. Locales may select an inflected form with {monthKey}; {month} is the CLDR abbreviated month fallback. /// diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 11afca8e..e022335e 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1372,24 +1372,78 @@ class AppLocalizationsAr extends AppLocalizations { @override String get repeatWeekendDay => 'يوم عطلة نهاية الأسبوع'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'اثنين', + 'TU': 'ثلاثاء', + 'WE': 'أربعاء', + 'TH': 'خميس', + 'FR': 'جمعة', + 'SA': 'سبت', + 'SU': 'أحد', + 'day': 'يوم', + 'weekday': 'يوم من أيام الأسبوع', + 'weekend': 'يوم من عطلة نهاية الأسبوع', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { - return 'كل $count أيام'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $count يوم', + many: 'كل $count يومًا', + few: 'كل $count أيام', + two: 'كل يومين', + one: 'كل يوم', + ); + return '$_temp0'; } @override String repeatEveryWeeks(int count) { - return 'كل $count أسابيع'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $count أسبوع', + many: 'كل $count أسبوعًا', + few: 'كل $count أسابيع', + two: 'كل أسبوعين', + one: 'كل أسبوع', + ); + return '$_temp0'; } @override String repeatEveryMonths(int count) { - return 'كل $count أشهر'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $count شهر', + many: 'كل $count شهرًا', + few: 'كل $count أشهر', + two: 'كل شهرين', + one: 'كل شهر', + ); + return '$_temp0'; } @override String repeatEveryYears(int count) { - return 'كل $count سنوات'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'كل $count سنة', + many: 'كل $count سنة', + few: 'كل $count سنوات', + two: 'كل سنتين', + one: 'كل سنة', + ); + return '$_temp0'; } @override @@ -1405,13 +1459,13 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatOnOrdinalSummary(String position, String days) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'في $days الأول', - 'second': 'في $days الثاني', - 'third': 'في $days الثالث', - 'fourth': 'في $days الرابع', - 'fifth': 'في $days الخامس', - 'secondToLast': 'في $days ما قبل الأخير', - 'last': 'في $days الأخير', + 'first': 'في أول $days', + 'second': 'في ثاني $days', + 'third': 'في ثالث $days', + 'fourth': 'في رابع $days', + 'fifth': 'في خامس $days', + 'secondToLast': 'في $days قبل الأخير', + 'last': 'في آخر $days', 'other': 'في $days', }); return '$_temp0'; @@ -1424,7 +1478,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count مرات'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count مرة', + many: '$count مرة', + few: '$count مرات', + two: 'مرتين', + one: 'مرة واحدة', + ); + return '$_temp0'; } @override @@ -2336,7 +2399,24 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first و$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { @@ -2408,14 +2488,14 @@ class AppLocalizationsAr extends AppLocalizations { String days, ) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'في أول يوم $days من $month', - 'second': 'في ثاني يوم $days من $month', - 'third': 'في ثالث يوم $days من $month', - 'fourth': 'في رابع يوم $days من $month', - 'fifth': 'في خامس يوم $days من $month', - 'secondToLast': 'في يوم $days قبل الأخير من $month', - 'last': 'في آخر يوم $days من $month', - 'other': 'في يوم $days من $month', + 'first': 'في أول $days من $month', + 'second': 'في ثاني $days من $month', + 'third': 'في ثالث $days من $month', + 'fourth': 'في رابع $days من $month', + 'fifth': 'في خامس $days من $month', + 'secondToLast': 'في $days قبل الأخير من $month', + 'last': 'في آخر $days من $month', + 'other': 'في $days من $month', }); return '$frequency $_temp0'; } @@ -2428,14 +2508,14 @@ class AppLocalizationsAr extends AppLocalizations { String days, ) { String _temp0 = intl.Intl.selectLogic(position, { - 'first': 'في أول يوم $days من أشهر $months', - 'second': 'في ثاني يوم $days من أشهر $months', - 'third': 'في ثالث يوم $days من أشهر $months', - 'fourth': 'في رابع يوم $days من أشهر $months', - 'fifth': 'في خامس يوم $days من أشهر $months', - 'secondToLast': 'في يوم $days قبل الأخير من أشهر $months', - 'last': 'في آخر يوم $days من أشهر $months', - 'other': 'في يوم $days من أشهر $months', + 'first': 'في أول $days من أشهر $months', + 'second': 'في ثاني $days من أشهر $months', + 'third': 'في ثالث $days من أشهر $months', + 'fourth': 'في رابع $days من أشهر $months', + 'fifth': 'في خامس $days من أشهر $months', + 'secondToLast': 'في $days قبل الأخير من أشهر $months', + 'last': 'في آخر $days من أشهر $months', + 'other': 'في $days من أشهر $months', }); return '$frequency $_temp0'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index dd75593f..5be31ce7 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1370,6 +1370,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeatWeekendDay => 'Wochenendtag'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Montag', + 'TU': 'Dienstag', + 'WE': 'Mittwoch', + 'TH': 'Donnerstag', + 'FR': 'Freitag', + 'SA': 'Samstag', + 'SU': 'Sonntag', + 'day': 'Tag', + 'weekday': 'Wochentag', + 'weekend': 'Wochenendtag', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Alle $count Tage'; @@ -1422,7 +1440,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count-mal'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count-mal', + one: '$count Mal', + ); + return '$_temp0'; } @override @@ -2343,7 +2367,24 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first und $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 691e78e4..ef7e995e 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1358,6 +1358,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeatWeekendDay => 'Weekend day'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'day': 'day', + 'weekday': 'weekday', + 'weekend': 'weekend day', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Every $count days'; @@ -1410,7 +1428,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count times'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count times', + one: '$count time', + ); + return '$_temp0'; } @override @@ -2316,7 +2340,24 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 05c59f5f..6be1df43 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1373,6 +1373,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get repeatWeekendDay => 'Día de fin de semana'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lunes', + 'TU': 'martes', + 'WE': 'miércoles', + 'TH': 'jueves', + 'FR': 'viernes', + 'SA': 'sábado', + 'SU': 'domingo', + 'day': 'día', + 'weekday': 'día laborable', + 'weekend': 'día de fin de semana', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Cada $count días'; @@ -1425,7 +1443,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count veces'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count veces', + one: '$count vez', + ); + return '$_temp0'; } @override @@ -2345,7 +2369,24 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first y $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index ff752aeb..445fd01a 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1361,6 +1361,24 @@ class AppLocalizationsEt extends AppLocalizations { @override String get repeatWeekendDay => 'Nädalavahetuse päev'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'esmaspäev', + 'TU': 'teisipäev', + 'WE': 'kolmapäev', + 'TH': 'neljapäev', + 'FR': 'reede', + 'SA': 'laupäev', + 'SU': 'pühapäev', + 'day': 'päev', + 'weekday': 'tööpäev', + 'weekend': 'nädalavahetuse päev', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Iga $count päeva järel'; @@ -1413,7 +1431,13 @@ class AppLocalizationsEt extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count korda'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count korda', + one: '$count kord', + ); + return '$_temp0'; } @override @@ -2322,7 +2346,24 @@ class AppLocalizationsEt extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ' ja '; + String repeatWeekdayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 3215dea9..895d8134 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1380,6 +1380,24 @@ class AppLocalizationsFa extends AppLocalizations { @override String get repeatWeekendDay => 'روز آخر هفته'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'دوشنبه', + 'TU': 'سه‌شنبه', + 'WE': 'چهارشنبه', + 'TH': 'پنجشنبه', + 'FR': 'جمعه', + 'SA': 'شنبه', + 'SU': 'یکشنبه', + 'day': 'روز', + 'weekday': 'روز هفته', + 'weekend': 'روز آخر هفته', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'هر $count روز'; @@ -1432,7 +1450,13 @@ class AppLocalizationsFa extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count بار'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count بار', + one: '$count بار', + ); + return '$_temp0'; } @override @@ -2356,7 +2380,24 @@ class AppLocalizationsFa extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first، $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first و $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first، $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index e7b75a26..892c64f8 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1367,6 +1367,24 @@ class AppLocalizationsFi extends AppLocalizations { @override String get repeatWeekendDay => 'Viikonlopun päivä'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'maanantaina', + 'TU': 'tiistaina', + 'WE': 'keskiviikkona', + 'TH': 'torstaina', + 'FR': 'perjantaina', + 'SA': 'lauantaina', + 'SU': 'sunnuntaina', + 'day': 'päivänä', + 'weekday': 'arkipäivänä', + 'weekend': 'viikonlopun päivänä', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Joka $count. päivä'; @@ -1419,7 +1437,13 @@ class AppLocalizationsFi extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count kertaa'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count kertaa', + one: '$count kerran', + ); + return '$_temp0'; } @override @@ -2333,7 +2357,24 @@ class AppLocalizationsFi extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first ja $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index e680c04f..a619aa33 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1371,6 +1371,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeatWeekendDay => 'Jour du week-end'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lundi', + 'TU': 'mardi', + 'WE': 'mercredi', + 'TH': 'jeudi', + 'FR': 'vendredi', + 'SA': 'samedi', + 'SU': 'dimanche', + 'day': 'jour', + 'weekday': 'jour de semaine', + 'weekend': 'jour du week-end', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Tous les $count jours'; @@ -1423,7 +1441,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count fois'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count fois', + one: '$count fois', + ); + return '$_temp0'; } @override @@ -2344,7 +2368,24 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first et $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index d5589936..29d3f78f 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1365,6 +1365,24 @@ class AppLocalizationsHi extends AppLocalizations { @override String get repeatWeekendDay => 'सप्ताहांत का दिन'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'सोमवार', + 'TU': 'मंगलवार', + 'WE': 'बुधवार', + 'TH': 'गुरुवार', + 'FR': 'शुक्रवार', + 'SA': 'शनिवार', + 'SU': 'रविवार', + 'day': 'दिन', + 'weekday': 'सप्ताह का दिन', + 'weekend': 'सप्ताहांत का दिन', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'हर $count दिन'; @@ -1417,7 +1435,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count बार'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count बार', + one: '$count बार', + ); + return '$_temp0'; } @override @@ -2325,7 +2349,24 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first और $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 28f2498e..d864489e 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1373,6 +1373,24 @@ class AppLocalizationsIt extends AppLocalizations { @override String get repeatWeekendDay => 'Giorno del fine settimana'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lunedì', + 'TU': 'martedì', + 'WE': 'mercoledì', + 'TH': 'giovedì', + 'FR': 'venerdì', + 'SA': 'sabato', + 'SU': 'domenica', + 'day': 'giorno', + 'weekday': 'giorno feriale', + 'weekend': 'giorno del fine settimana', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Ogni $count giorni'; @@ -1425,7 +1443,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count volte'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count volte', + one: '$count volta', + ); + return '$_temp0'; } @override @@ -2342,7 +2366,24 @@ class AppLocalizationsIt extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index b652d568..02ab8c68 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1336,6 +1336,24 @@ class AppLocalizationsJa extends AppLocalizations { @override String get repeatWeekendDay => '週末'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '月曜日', + 'TU': '火曜日', + 'WE': '水曜日', + 'TH': '木曜日', + 'FR': '金曜日', + 'SA': '土曜日', + 'SU': '日曜日', + 'day': '日', + 'weekday': '平日', + 'weekend': '週末', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return '$count 日ごと'; @@ -1388,7 +1406,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count 回'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 回', + ); + return '$_temp0'; } @override @@ -2278,7 +2301,24 @@ class AppLocalizationsJa extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => '、'; + String repeatWeekdayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index ae63520d..57e53d46 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1338,6 +1338,24 @@ class AppLocalizationsKo extends AppLocalizations { @override String get repeatWeekendDay => '주말'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '월요일', + 'TU': '화요일', + 'WE': '수요일', + 'TH': '목요일', + 'FR': '금요일', + 'SA': '토요일', + 'SU': '일요일', + 'day': '날', + 'weekday': '평일', + 'weekend': '주말', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return '$count일마다'; @@ -1390,7 +1408,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count회'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count회', + ); + return '$_temp0'; } @override @@ -2280,7 +2303,24 @@ class AppLocalizationsKo extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 88b07940..779d2c90 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1372,6 +1372,24 @@ class AppLocalizationsPt extends AppLocalizations { @override String get repeatWeekendDay => 'Dia do fim de semana'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'segunda-feira', + 'TU': 'terça-feira', + 'WE': 'quarta-feira', + 'TH': 'quinta-feira', + 'FR': 'sexta-feira', + 'SA': 'sábado', + 'SU': 'domingo', + 'day': 'dia', + 'weekday': 'dia útil', + 'weekend': 'dia do fim de semana', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'A cada $count dias'; @@ -1424,7 +1442,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count vezes'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count vezes', + one: '$count vez', + ); + return '$_temp0'; } @override @@ -2342,7 +2366,24 @@ class AppLocalizationsPt extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first e $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index e246980f..749a9ca7 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1379,6 +1379,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get repeatWeekendDay => 'Выходной день'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'понедельник', + 'TU': 'вторник', + 'WE': 'среду', + 'TH': 'четверг', + 'FR': 'пятницу', + 'SA': 'субботу', + 'SU': 'воскресенье', + 'day': 'день', + 'weekday': 'будний день', + 'weekend': 'выходной день', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Каждые $count дн.'; @@ -1431,7 +1449,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count раз'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count раза', + many: '$count раз', + few: '$count раза', + one: '$count раз', + ); + return '$_temp0'; } @override @@ -2347,7 +2373,24 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ' и '; + String repeatWeekdayListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first и $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 6890547f..70f3bbf2 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1363,6 +1363,24 @@ class AppLocalizationsVi extends AppLocalizations { @override String get repeatWeekendDay => 'Ngày cuối tuần'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Thứ Hai', + 'TU': 'Thứ Ba', + 'WE': 'Thứ Tư', + 'TH': 'Thứ Năm', + 'FR': 'Thứ Sáu', + 'SA': 'Thứ Bảy', + 'SU': 'Chủ Nhật', + 'day': 'ngày', + 'weekday': 'ngày trong tuần', + 'weekend': 'ngày cuối tuần', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return 'Mỗi $count ngày'; @@ -1415,7 +1433,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '$count lần'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count lần', + ); + return '$_temp0'; } @override @@ -2322,7 +2345,24 @@ class AppLocalizationsVi extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => ', '; + String repeatWeekdayListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first và $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 7e111399..10577861 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1326,6 +1326,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get repeatWeekendDay => '周末'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'day': '日期', + 'weekday': '工作日', + 'weekend': '周末', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return '每 $count 天'; @@ -1378,7 +1396,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatTimesSummary(int count) { - return '重复 $count 次'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '重复 $count 次', + ); + return '$_temp0'; } @override @@ -2255,7 +2278,24 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get repeatMonthDayListSeparator => '、'; + String repeatWeekdayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { @@ -3678,6 +3718,24 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get repeatWeekendDay => '周末'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'day': '日期', + 'weekday': '工作日', + 'weekend': '周末', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return '每 $count 天'; @@ -3730,7 +3788,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String repeatTimesSummary(int count) { - return '重复 $count 次'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '重复 $count 次', + ); + return '$_temp0'; } @override @@ -4607,7 +4670,24 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { } @override - String get repeatMonthDayListSeparator => '、'; + String repeatWeekdayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { @@ -6030,6 +6110,24 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get repeatWeekendDay => '週末'; + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'day': '日期', + 'weekday': '工作日', + 'weekend': '週末', + 'other': '$day', + }); + return '$_temp0'; + } + @override String repeatEveryDays(int count) { return '每 $count 天'; @@ -6082,7 +6180,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String repeatTimesSummary(int count) { - return '重複 $count 次'; + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '重複 $count 次', + ); + return '$_temp0'; } @override @@ -6960,7 +7063,24 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { } @override - String get repeatMonthDayListSeparator => '、'; + String repeatWeekdayListPair(String first, String second) { + return '$first和$second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first、$rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first、$second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first、$rest'; + } @override String repeatYearlyMonthValue(String month, String monthKey) { diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index 01360583..1d0d51ac 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -76,9 +76,14 @@ String recurrenceRuleSummary( var yearlyMonthPlacedInSummary = false; if (recurrence.frequency == RecurrenceFrequency.weekly && recurrence.byDay.isNotEmpty) { - final days = recurrence.byDay + final dayValues = recurrence.byDay .map((day) => _localizedInlineWeekday(context, day)) - .join(', '); + .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 || @@ -128,7 +133,11 @@ String recurrenceRuleSummary( }; yearlyMonthPlacedInSummary = true; } else { - final monthDays = monthDayValues.join(l10n.repeatMonthDayListSeparator); + final monthDays = _localizedRecurrenceList( + monthDayValues, + pair: l10n.repeatMonthDayListPair, + start: l10n.repeatMonthDayListStart, + ); final monthDaysSummary = recurrence.byMonthDay.length == 1 ? l10n.repeatOnMonthDaysSummary(monthDays) : l10n.repeatOnMonthDaysSummaryMultiple(monthDays); @@ -804,27 +813,38 @@ String _localizedOrdinalDayChoice(BuildContext context, List days) { if (_sameDaySet(days, _ordinalDayChoices[9].days)) { return context.l10n.repeatWeekendDay; } - return days + final dayValues = days .map( (day) => _localizedStandaloneWeekday(context, day, abbreviated: false), ) - .join(', '); + .toList(growable: false); + return _localizedRecurrenceList( + dayValues, + pair: context.l10n.repeatWeekdayListPair, + start: context.l10n.repeatWeekdayListStart, + ); } String _localizedOrdinalDaySummary(BuildContext context, List days) { + final dayKey = _ordinalDayChoiceKey(days); if (days.length == 1) { - return _localizedInlineWeekday(context, days.single); - } - if (_sameDaySet(days, _ordinalDayChoices[7].days)) { - return context.l10n.repeatAnyDay; - } - if (_sameDaySet(days, _ordinalDayChoices[8].days)) { - return context.l10n.repeatWeekday; + return context.l10n.repeatOrdinalDaySummary( + dayKey, + _localizedInlineWeekday(context, days.single), + ); } - if (_sameDaySet(days, _ordinalDayChoices[9].days)) { - return context.l10n.repeatWeekendDay; + if (dayKey == 'day' || dayKey == 'weekday' || dayKey == 'weekend') { + return context.l10n.repeatOrdinalDaySummary(dayKey, ''); } - return days.map((day) => _localizedInlineWeekday(context, day)).join(', '); + 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(dayKey, localizedDays); } bool _sameDaySet(List left, List right) { diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index b057f372..e0b62d99 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -101,7 +101,8 @@ void main() { 'taskUrl', 'repeatSummarySeparator', 'repeatMonthDayValue', - 'repeatMonthDayListSeparator', + 'repeatWeekdayListStart', + 'repeatMonthDayListStart', 'repeatYearlyMonthDayListStart', 'repeatYearlyMonthListStart', 'repeatYearlyMonthValue', @@ -614,8 +615,8 @@ void main() { 'سنويًا في الأيام 1 و15 من سبتمبر', 'سنويًا في يوم 15 من أشهر سبتمبر وأكتوبر', 'سنويًا في الأيام 1 و15 من أشهر سبتمبر وأكتوبر', - 'سنويًا في أول يوم الاثنين من سبتمبر', - 'سنويًا في أول يوم الاثنين من أشهر سبتمبر وأكتوبر', + 'سنويًا في أول اثنين من سبتمبر', + 'سنويًا في أول اثنين من أشهر سبتمبر وأكتوبر', ], 'de': [ 'Jährlich am 15. Sept.', @@ -799,7 +800,7 @@ void main() { expect(await summary(const Locale('ja'), singleMonthDayRule), '毎月15日'); expect(await summary(const Locale('ja'), monthDaysRule), '毎月1日、15日'); expect(await summary(const Locale('ko'), singleMonthDayRule), '매월 15일'); - expect(await summary(const Locale('ko'), monthDaysRule), '매월 1일, 15일'); + expect(await summary(const Locale('ko'), monthDaysRule), '매월 1일과 15일'); expect( await summary(const Locale('et'), ordinalRule), 'Iga kuu esimene esmaspäev', @@ -855,6 +856,190 @@ void main() { expect(persianMonthDay, isNot(contains('15'))); }); + testWidgets( + 'recurrence plurals, ordinal labels, and lists follow locale grammar', + (tester) async { + RecurrenceRule rule({ + RecurrenceFrequency frequency = RecurrenceFrequency.daily, + int interval = 1, + List byDay = const [], + List byMonth = const [], + List byMonthDay = const [], + int? bySetPosition, + int? count, + }) => RecurrenceRule( + frequency: frequency, + interval: interval, + byDay: byDay, + byMonth: byMonth, + byMonthDay: byMonthDay, + bySetPosition: bySetPosition, + count: count, + untilRaw: null, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); + + Future summary(Locale locale, RecurrenceRule recurrence) async { + var value = ''; + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + value = recurrenceRuleSummary(context, recurrence); + return const SizedBox(); + }, + ), + ), + ); + return value; + } + + const singularCountSummaries = { + 'en': 'Daily · 1 time', + 'es': 'Diaria · 1 vez', + 'et': 'Iga päev · 1 kord', + 'fi': 'Päivittäin · 1 kerran', + 'it': 'Ogni giorno · 1 volta', + 'pt': 'Diariamente · 1 vez', + 'ru': 'Ежедневно · 1 раз', + 'ar': 'يوميًا · مرة واحدة', + }; + for (final entry in singularCountSummaries.entries) { + expect( + await summary(Locale(entry.key), rule(count: 1)), + entry.value, + reason: entry.key, + ); + } + expect( + await summary(const Locale('ru'), rule(count: 2)), + 'Ежедневно · 2 раза', + ); + expect( + await summary(const Locale('ar'), rule(count: 2)), + 'يوميًا · مرتين', + ); + expect( + await summary(const Locale('ar'), rule(count: 3)), + 'يوميًا · 3 مرات', + ); + expect( + await summary(const Locale('ar'), rule(count: 11)), + 'يوميًا · 11 مرة', + ); + + const arabicIntervals = >{ + RecurrenceFrequency.daily: ['كل يومين', 'كل 3 أيام', 'كل 11 يومًا'], + RecurrenceFrequency.weekly: [ + 'كل أسبوعين', + 'كل 3 أسابيع', + 'كل 11 أسبوعًا', + ], + RecurrenceFrequency.monthly: ['كل شهرين', 'كل 3 أشهر', 'كل 11 شهرًا'], + RecurrenceFrequency.yearly: ['كل سنتين', 'كل 3 سنوات', 'كل 11 سنة'], + }; + for (final entry in arabicIntervals.entries) { + for (var index = 0; index < 3; index += 1) { + final interval = const [2, 3, 11][index]; + expect( + await summary( + const Locale('ar'), + rule(frequency: entry.key, interval: interval), + ), + entry.value[index], + reason: '${entry.key.name}: $interval', + ); + } + } + + const allDays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; + const weekdays = ['MO', 'TU', 'WE', 'TH', 'FR']; + const weekend = ['SA', 'SU']; + final ordinalChoices = , List>{ + allDays: ['شهريًا في أول يوم', 'Kuukausittain ensimmäisenä päivänä'], + weekdays: [ + 'شهريًا في أول يوم من أيام الأسبوع', + 'Kuukausittain ensimmäisenä arkipäivänä', + ], + weekend: [ + 'شهريًا في أول يوم من عطلة نهاية الأسبوع', + 'Kuukausittain ensimmäisenä viikonlopun päivänä', + ], + }; + for (final entry in ordinalChoices.entries) { + final recurrence = rule( + frequency: RecurrenceFrequency.monthly, + byDay: entry.key, + bySetPosition: 1, + ); + expect(await summary(const Locale('ar'), recurrence), entry.value[0]); + expect(await summary(const Locale('fi'), recurrence), entry.value[1]); + } + expect( + await summary( + const Locale('ar'), + rule( + frequency: RecurrenceFrequency.monthly, + byDay: const ['MO'], + bySetPosition: 1, + ), + ), + 'شهريًا في أول اثنين', + ); + expect( + await summary( + const Locale('ar'), + rule( + frequency: RecurrenceFrequency.yearly, + byDay: allDays, + byMonth: const [9], + bySetPosition: 1, + ), + ), + 'سنويًا في أول يوم من سبتمبر', + ); + + final weekly = rule( + frequency: RecurrenceFrequency.weekly, + byDay: const ['MO', 'WE'], + ); + expect(await summary(const Locale('ja'), weekly), '毎週月曜日、水曜日'); + expect(await summary(const Locale('zh'), weekly), '每周在 星期一和星期三'); + expect( + await summary(const Locale('ar'), weekly), + 'أسبوعيًا في الاثنين والأربعاء', + ); + + final monthly = rule( + frequency: RecurrenceFrequency.monthly, + byMonthDay: const [1, 15], + ); + expect( + await summary(const Locale('en'), monthly), + 'Monthly on days 1 and 15', + ); + expect(await summary(const Locale('ja'), monthly), '毎月1日、15日'); + expect(await summary(const Locale('zh'), monthly), '每月1日、15日'); + }, + ); + + test('recurrence summaries do not use hard-coded list separators', () { + final source = File( + 'lib/src/features/recurrence/presentation/recurrence_editor.dart', + ).readAsStringSync(); + + expect(source, isNot(contains(".join(', ')"))); + expect(source, isNot(contains('repeatMonthDayListSeparator'))); + expect(source, contains('repeatWeekdayListPair')); + expect(source, contains('repeatMonthDayListPair')); + }); + test('Persian dynamic numbers use Persian digits', () { const fsi = '\u2068'; const pdi = '\u2069'; From 00ed65f3117c2c26cf20b62df79e97e9c27044db Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 30 Aug 2026 20:38:47 -0700 Subject: [PATCH 22/69] feat: enhance localization support with additional Portuguese and improved recurrence summaries --- l10n.yaml | 4 + lib/l10n/app_ar.arb | 130 +- lib/l10n/app_de.arb | 10 +- lib/l10n/app_en.arb | 114 +- lib/l10n/app_es.arb | 10 +- lib/l10n/app_et.arb | 56 +- lib/l10n/app_fa.arb | 128 +- lib/l10n/app_fi.arb | 24 +- lib/l10n/app_fr.arb | 38 +- lib/l10n/app_hi.arb | 36 +- lib/l10n/app_it.arb | 8 +- lib/l10n/app_ja.arb | 6 + lib/l10n/app_ko.arb | 28 +- lib/l10n/app_pt.arb | 928 +----- lib/l10n/app_pt_PT.arb | 935 ++++++ lib/l10n/app_ru.arb | 8 +- lib/l10n/app_vi.arb | 6 + lib/l10n/app_zh.arb | 90 +- lib/l10n/app_zh_Hans.arb | 90 +- lib/l10n/app_zh_Hant.arb | 6 + lib/l10n/generated/app_localizations.dart | 70 + lib/l10n/generated/app_localizations_ar.dart | 354 ++- lib/l10n/generated/app_localizations_de.dart | 145 +- lib/l10n/generated/app_localizations_en.dart | 232 +- lib/l10n/generated/app_localizations_es.dart | 145 +- lib/l10n/generated/app_localizations_et.dart | 174 +- lib/l10n/generated/app_localizations_fa.dart | 275 +- lib/l10n/generated/app_localizations_fi.dart | 194 +- lib/l10n/generated/app_localizations_fr.dart | 173 +- lib/l10n/generated/app_localizations_hi.dart | 202 +- lib/l10n/generated/app_localizations_it.dart | 174 +- lib/l10n/generated/app_localizations_ja.dart | 170 +- lib/l10n/generated/app_localizations_ko.dart | 192 +- lib/l10n/generated/app_localizations_pt.dart | 2804 ++++++++++++++++- lib/l10n/generated/app_localizations_ru.dart | 208 +- lib/l10n/generated/app_localizations_vi.dart | 170 +- lib/l10n/generated/app_localizations_zh.dart | 678 +++- lib/src/app/busymax_app.dart | 2 +- .../desktop_notification_service.dart | 2 +- .../domain/event_recurrence_codec.dart | 1 + .../recurrence/domain/recurrence_rule.dart | 7 + .../presentation/recurrence_editor.dart | 91 +- lib/src/l10n/app_locale.dart | 19 +- lib/src/l10n/locale_resolution.dart | 3 +- linux/io.busystack.busymax.desktop | 4 +- linux/io.busystack.busymax.metainfo.xml | 6 +- test/app/localization_audit_test.dart | 314 +- test/app/localized_formatters_test.dart | 10 +- .../desktop_notification_service_test.dart | 2 +- .../domain/event_recurrence_codec_test.dart | 32 + 50 files changed, 7543 insertions(+), 1965 deletions(-) create mode 100644 lib/l10n/app_pt_PT.arb diff --git a/l10n.yaml b/l10n.yaml index 80ed2b76..c160ef61 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,6 +3,10 @@ template-arb-file: app_en.arb output-dir: lib/l10n/generated output-localization-file: app_localizations.dart nullable-getter: false +# Flutter requires a base `pt` fallback before it will generate `pt_PT`. +# The base stub is not exposed by BusyMax; target-catalog parity is enforced by +# test/app/localization_audit_test.dart. +untranslated-messages-file: .dart_tool/pt_fallback_untranslated.json header: | // coverage:ignore-file // GENERATED CODE - DO NOT MODIFY BY HAND diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index de449448..88b88ab9 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,5 +1,11 @@ { "@@locale": "ar", + "repeatWeeklyDaySummary": "{dayKey, select, MO{الاثنين} TU{الثلاثاء} WE{الأربعاء} TH{الخميس} FR{الجمعة} SA{السبت} SU{الأحد} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "في يومي {first} و{second} من الشهر", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} في يومي {firstDay} و{secondDay} من {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} في اليوم {day} من شهري {firstMonth} و{secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} في يومي {firstDay} و{secondDay} من شهري {firstMonth} و{secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} في الأيام {days} من شهري {firstMonth} و{secondMonth}", "appTitle": "BusyMax", "connectGoogleAccount": "اربط حسابات Google أو Microsoft أو تقويم Apple iCloud أو Nextcloud.", "googlePermissionsConsentNotice": "في شاشة أذونات Google، حدّد أذونات التقويم والمهام معًا.", @@ -45,7 +51,7 @@ }, "calendarManagementUnsupported": "لا يدعم هذا المزوّد إدارة التقويمات في BusyMax.", "primaryCalendarCannotDelete": "لا يمكن حذف التقويم الأساسي.", - "calendarCreateFailed": "تعذّر إنشاء التقويم: {error}", + "calendarCreateFailed": "تعذّر إنشاء التقويم: ⁨{error}⁩", "calendarCreatedRefreshPending": "تم إنشاء التقويم، لكن تعذّر على BusyMax تحديث الحساب. سيظهر بعد المزامنة التالية.", "@calendarCreateFailed": { "placeholders": { @@ -54,7 +60,7 @@ } } }, - "calendarUpdateFailed": "تعذّر تحديث التقويم: {error}", + "calendarUpdateFailed": "تعذّر تحديث التقويم: ⁨{error}⁩", "@calendarUpdateFailed": { "placeholders": { "error": { @@ -62,7 +68,7 @@ } } }, - "calendarDeleteFailed": "تعذّر حذف التقويم: {error}", + "calendarDeleteFailed": "تعذّر حذف التقويم: ⁨{error}⁩", "@calendarDeleteFailed": { "placeholders": { "error": { @@ -77,7 +83,7 @@ "showInSchedule": "إظهار في الجدول", "noCalendarsSynced": "لم تتم مزامنة أي تقويمات بعد.", "allDay": "طوال اليوم", - "moreItems": "+⁨{count}⁩ عناصر أخرى", + "moreItems": "{count, plural, one{+عنصر واحد آخر} two{+عنصران آخران} few{+⁨{count}⁩ عناصر أخرى} many{+⁨{count}⁩ عنصرًا آخر} other{+⁨{count}⁩ عنصر آخر}}", "noEventsOrTasks": "لا توجد أحداث أو مهام", "scheduleLoading": "جارٍ تحميل الجدول...", "scheduleUnavailable": "الجدول غير متاح", @@ -98,7 +104,7 @@ "trayCalendarEvent": "حدث في التقويم", "trayUntitledEvent": "حدث بلا عنوان", "trayNothingElseToday": "لا شيء آخر اليوم", - "trayTasksDueToday": "{count, plural, =0{لا توجد مهام مستحقة اليوم} =1{مهمة واحدة مستحقة اليوم} =2{مهمتان مستحقتان اليوم} few{{count} مهام مستحقة اليوم} many{{count} مهمة مستحقة اليوم} other{{count} مهمة مستحقة اليوم}}", + "trayTasksDueToday": "{count, plural, =0{لا توجد مهام مستحقة اليوم} =1{مهمة واحدة مستحقة اليوم} =2{مهمتان مستحقتان اليوم} few{⁨{count}⁩ مهام مستحقة اليوم} many{⁨{count}⁩ مهمة مستحقة اليوم} other{⁨{count}⁩ مهمة مستحقة اليوم}}", "@trayTasksDueToday": { "placeholders": { "count": { @@ -112,7 +118,7 @@ "trayNotConnected": "غير متصل", "trayNotYetSynced": "لم تتم المزامنة بعد", "trayLastSyncedJustNow": "تمت المزامنة للتو", - "trayLastSyncedMinutesAgo": "{count, plural, =1{تمت المزامنة قبل دقيقة واحدة} other{تمت المزامنة قبل {count} دقائق}}", + "trayLastSyncedMinutesAgo": "{count, plural, one{تمت المزامنة قبل دقيقة واحدة} two{تمت المزامنة قبل دقيقتين} few{تمت المزامنة قبل ⁨{count}⁩ دقائق} many{تمت المزامنة قبل ⁨{count}⁩ دقيقة} other{تمت المزامنة قبل ⁨{count}⁩ دقيقة}}", "@trayLastSyncedMinutesAgo": { "placeholders": { "count": { @@ -120,7 +126,7 @@ } } }, - "trayLastSyncedHoursAgo": "{count, plural, =1{تمت المزامنة قبل ساعة واحدة} other{تمت المزامنة قبل {count} ساعات}}", + "trayLastSyncedHoursAgo": "{count, plural, one{تمت المزامنة قبل ساعة واحدة} two{تمت المزامنة قبل ساعتين} few{تمت المزامنة قبل ⁨{count}⁩ ساعات} many{تمت المزامنة قبل ⁨{count}⁩ ساعة} other{تمت المزامنة قبل ⁨{count}⁩ ساعة}}", "@trayLastSyncedHoursAgo": { "placeholders": { "count": { @@ -128,7 +134,7 @@ } } }, - "trayLastSyncedDaysAgo": "{count, plural, =1{تمت المزامنة قبل يوم واحد} other{تمت المزامنة قبل {count} أيام}}", + "trayLastSyncedDaysAgo": "{count, plural, one{تمت المزامنة قبل يوم واحد} two{تمت المزامنة قبل يومين} few{تمت المزامنة قبل ⁨{count}⁩ أيام} many{تمت المزامنة قبل ⁨{count}⁩ يومًا} other{تمت المزامنة قبل ⁨{count}⁩ يوم}}", "@trayLastSyncedDaysAgo": { "placeholders": { "count": { @@ -164,7 +170,7 @@ "doesNotRepeat": "لا يتكرر", "defaultReminder": "التذكير الافتراضي", "guests": "المدعوون", - "noGuests": "لا يوجد ضيوف", + "noGuests": "لا يوجد مدعوون", "description": "الوصف", "availabilityShowAs": "التوفر / إظهار كـ", "busy": "مشغول", @@ -195,7 +201,7 @@ "sensitivityPersonal": "شخصي", "tasks": "المهام", "allTasks": "كل المهام", - "tasksInList": "المهام في ⁨{title}⁩", + "tasksInList": "المهام في ⁨⁨{title}⁩⁩", "taskLists": "قوائم المهام", "navigation": "التنقل", "mainMenu": "القائمة الرئيسية", @@ -250,7 +256,7 @@ "feedbackRateLimitedError": "أُرسلت ملاحظات كثيرة جدًا من هذه الشبكة. انتظر وحاول مرة أخرى.", "feedbackRejectedError": "رفض الخادم الإرسال. راجع الحقول وحاول مرة أخرى.", "feedbackServerError": "يتعذر على BusyStack قبول ملاحظاتك الآن. لم تُمسح ملاحظاتك؛ حاول مرة أخرى.", - "feedbackSuccess": "تم إرسال الملاحظات. المرجع: ⁨{id}⁩", + "feedbackSuccess": "تم إرسال الملاحظات. المرجع: ⁨⁨{id}⁩⁩", "toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه", "showSidebar": "إظهار اللوحة الجانبية", "hideSidebar": "إخفاء اللوحة الجانبية", @@ -265,7 +271,7 @@ "removeAccount": "إزالة الحساب…", "removingAccount": "جارٍ إزالة الحساب…", "removeAccountDescription": "إيقاف المزامنة وإزالة بيانات هذا الحساب من هذا الجهاز.", - "removeAccountTitle": "إزالة ⁨{account}⁩ من BusyMax؟", + "removeAccountTitle": "إزالة ⁨⁨{account}⁩⁩ من BusyMax؟", "removeAccountConfirmation": "سيؤدي هذا إلى حذف المهام والتقويمات والأحداث والتذكيرات والتغييرات غير المتصلة المخزنة مؤقتًا من هذا الجهاز. ستُفقد التغييرات التي لم تتم مزامنتها، ولن تُحذف نسخ التقويمات والأحداث وقوائم المهام والمهام لدى موفّر الخدمة.", "revokeGoogleAccess": "إلغاء وصول BusyMax إلى حساب Google هذا أيضًا", "revokeGoogleAccessDescription": "ستحتاج إلى منح الوصول مرة أخرى قبل إعادة الاتصال.", @@ -282,7 +288,7 @@ "deleteList": "حذف القائمة", "builtInMicrosoftList": "مدمجة", "builtInMicrosoftListCannotRenameDelete": "لا يمكن إعادة تسمية قوائم Microsoft To Do المدمجة أو حذفها.", - "deleteListConfirmation": "حذف \"⁨{title}⁩\" من Google Tasks؟", + "deleteListConfirmation": "حذف \"⁨⁨{title}⁩⁩\" من Google Tasks؟", "deleteEvent": "حذف الحدث", "title": "العنوان", "create": "إنشاء", @@ -292,9 +298,9 @@ "refreshAll": "تحديث الكل", "listRefreshed": "تم تحديث القائمة.", "allTasksRefreshed": "تم تحديث جميع الحسابات.", - "exportedFile": "تم التصدير إلى ⁨{path}⁩", - "exportFailed": "فشل التصدير: ⁨{error}⁩", - "refreshFailed": "فشل التحديث: ⁨{error}⁩", + "exportedFile": "تم التصدير إلى ⁨⁨{path}⁩⁩", + "exportFailed": "فشل التصدير: ⁨⁨{error}⁩⁩", + "refreshFailed": "فشل التحديث: ⁨⁨{error}⁩⁩", "selectOrCreateTaskList": "اختر قائمة مهام أو أنشئ واحدة للبدء.", "signInToViewTasks": "سجّل الدخول لعرض المهام.", "noTasks": "لا توجد مهام.", @@ -307,8 +313,8 @@ "upcoming": "القادمة", "noDate": "بلا تاريخ", "completed": "مكتملة", - "duePrefix": "مستحقة في ⁨{date}⁩", - "dateTimeDisplay": "⁨{date}⁩ · ⁨{time}⁩", + "duePrefix": "مستحقة في ⁨⁨{date}⁩⁩", + "dateTimeDisplay": "⁨⁨{date}⁩⁩ · ⁨⁨{time}⁩⁩", "taskDetails": "تفاصيل المهمة", "editTask": "تعديل المهمة", "noTaskSelected": "لم يتم تحديد مهمة.", @@ -363,7 +369,7 @@ "moveToTop": "نقل إلى الأعلى", "deleteTask": "حذف المهمة", "newSubtask": "مهمة فرعية جديدة", - "deleteTaskConfirmation": "حذف «⁨{title}⁩»؟", + "deleteTaskConfirmation": "حذف «⁨⁨{title}⁩⁩»؟", "metadata": "البيانات الوصفية", "id": "المعرّف", "etag": "ETag", @@ -387,7 +393,7 @@ "launchAtLoginFailed": "تعذر تحديث إعداد التشغيل عند تسجيل الدخول.", "requiresTrayIcon": "يتطلب أيقونة شريط النظام.", "syncComplete": "اكتملت المزامنة.", - "syncFailed": "فشلت المزامنة: ⁨{error}⁩", + "syncFailed": "فشلت المزامنة: ⁨⁨{error}⁩⁩", "notifySyncFailures": "إشعارات عند فشل المزامنة", "notifyConflicts": "إشعارات عند حدوث تعارضات", "notifyDueToday": "إشعارات المهام المستحقة اليوم", @@ -418,7 +424,7 @@ "diagnostics": "التشخيصات", "apiInspectorDisabled": "إظهار فاحص API", "googleTasksApi": "واجهة Google Tasks API", - "discoveryRevision": "مراجعة Discovery: ⁨{revision}⁩", + "discoveryRevision": "مراجعة Discovery: ⁨⁨{revision}⁩⁩", "implementedMethods": "الطرق المنفذة", "supportsTasksScopes": "يدعم نطاقَي tasks وtasks.readonly", "requiresTasksScope": "يتطلب نطاق tasks", @@ -426,8 +432,8 @@ "signInToInspectPendingOperations": "سجّل الدخول لفحص العمليات المعلّقة.", "noBlockedPendingOperations": "لا توجد عمليات معلّقة محظورة.", "operationActions": "إجراءات العملية", - "pendingOpListId": "القائمة=⁨{id}⁩", - "pendingOpTaskId": "المهمة=⁨{id}⁩", + "pendingOpListId": "القائمة=⁨⁨{id}⁩⁩", + "pendingOpTaskId": "المهمة=⁨⁨{id}⁩⁩", "pendingOpAttempts": "المحاولات=⁨{count}⁩", "retry": "إعادة المحاولة", "discard": "تجاهل", @@ -439,9 +445,9 @@ "discardPendingOperationConfirmation": "سيؤدي ذلك إلى إزالة العملية المحلية المحظورة. ستُحدّث البيانات من Google Tasks في المزامنة التالية.", "pendingOperationDiscarded": "تم تجاهل العملية المعلّقة.", "syncFailureNotificationTitle": "فشلت مزامنة BusyMax", - "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. ⁨{message}⁩", + "syncFailureNotificationBody": "فشلت المزامنة في الخلفية. ⁨⁨{message}⁩⁩", "conflictNotificationTitle": "تعارض في مزامنة BusyMax", - "conflictNotificationBody": "تم حظر تغيير محلي معلق. ⁨{summary}⁩", + "conflictNotificationBody": "تم حظر تغيير محلي معلق. ⁨⁨{summary}⁩⁩", "dueTodayNotificationTitle": "المهام المستحقة اليوم", "dueTodayNotificationBody": "{count, plural, =0{لا توجد مهام مستحقة اليوم.} =1{هناك مهمة واحدة مستحقة اليوم.} =2{هناك مهمتان مستحقتان اليوم.} few{هناك ⁨{count}⁩ مهام مستحقة اليوم.} many{هناك ⁨{count}⁩ مهمة مستحقة اليوم.} other{هناك ⁨{count}⁩ مهمة مستحقة اليوم.}}", "eventReminderNotificationTitle": "تذكير بحدث", @@ -503,12 +509,12 @@ "keepServerVersion": "الاحتفاظ بإصدار الخادم", "reapplyLocalChange": "مراجعة التغيير المحلي وإعادة تطبيقه", "duplicateLocalItem": "تكرار كعنصر جديد", - "deleteCalendarConfirmation": "حذف «⁨{title}⁩»؟", + "deleteCalendarConfirmation": "حذف «⁨⁨{title}⁩⁩»؟", "davConnectionState": "حالة الاتصال", "davConnected": "متصل", "davConnecting": "جارٍ الاتصال…", "davSignedOut": "تم تسجيل الخروج", - "davLastSuccessfulSync": "آخر مزامنة ناجحة: {time}", + "davLastSuccessfulSync": "آخر مزامنة ناجحة: ⁨{time}⁩", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -518,7 +524,7 @@ }, "davNeverSynced": "لم تتم المزامنة بعد", "refreshCollections": "تحديث التقويمات وقوائم المهام", - "nextcloudServerHost": "الخادم: {host}", + "nextcloudServerHost": "الخادم: ⁨{host}⁩", "@nextcloudServerHost": { "placeholders": { "host": { @@ -531,7 +537,7 @@ "collectionSupportsEventsAndTasks": "الأحداث والمهام", "writableCollection": "قابل للكتابة", "sharedCollection": "مشترك", - "collectionLastSynced": "آخر مزامنة: {time}", + "collectionLastSynced": "آخر مزامنة: ⁨{time}⁩", "@collectionLastSynced": { "placeholders": { "time": { @@ -539,7 +545,7 @@ } } }, - "collectionSyncError": "مشكلة في المزامنة: {code}", + "collectionSyncError": "مشكلة في المزامنة: ⁨{code}⁩", "@collectionSyncError": { "placeholders": { "code": { @@ -548,7 +554,7 @@ } }, "syncConflicts": "تعارضات المزامنة", - "remoteChangedAt": "تم التغيير على الخادم في: ⁨{time}⁩", + "remoteChangedAt": "تم التغيير على الخادم في: ⁨⁨{time}⁩⁩", "@remoteChangedAt": { "placeholders": { "time": { @@ -556,7 +562,7 @@ } } }, - "localPendingEdit": "تعديل محلي: {summary}", + "localPendingEdit": "تعديل محلي: ⁨{summary}⁩", "@localPendingEdit": { "placeholders": { "summary": { @@ -573,19 +579,19 @@ "thisAndFutureMoveUnavailable": "لا يمكن نقل هذا الحدث والأحداث التالية بأمان. اختر هذا الحدث أو السلسلة كاملة.", "entireSeriesMoveUnavailable": "قاعدة التكرار غير متوفرة محليًا. انقل هذا الحدث بدلاً من ذلك.", "copyEventAndDeleteOriginal": "هل تريد نسخ الحدث وحذف الأصل؟", - "copyEventMoveWarning": "لا يستطيع BusyMax نقل هذا الحدث مباشرةً من {source} إلى {destination}. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.", + "copyEventMoveWarning": "لا يستطيع BusyMax نقل هذا الحدث مباشرةً من ⁨{source}⁩ إلى ⁨{destination}⁩. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.", "copyAndDelete": "نسخ وحذف", "chooseRecurringEventScope": "اختر ما إذا كان هذا التغيير ينطبق على السلسلة بأكملها، أو هذا الحدث فقط، أو هذا الحدث والأحداث التالية.", "taskDueBeforeStart": "يجب ألا يكون موعد الاستحقاق قبل وقت البدء.", "taskStartDueTimeModeMismatch": "عيّن وقتًا لكل من البدء والاستحقاق، أو اجعل المهمة طوال اليوم.", - "taskListCreateFailed": "تعذّر إنشاء قائمة المهام: {error}", - "taskListRenameFailed": "تعذّر إعادة تسمية قائمة المهام: {error}", - "taskListDeleteFailed": "تعذّر حذف قائمة المهام: {error}", + "taskListCreateFailed": "تعذّر إنشاء قائمة المهام: ⁨{error}⁩", + "taskListRenameFailed": "تعذّر إعادة تسمية قائمة المهام: ⁨{error}⁩", + "taskListDeleteFailed": "تعذّر حذف قائمة المهام: ⁨{error}⁩", "unshare": "إلغاء المشاركة", "readOnlyTaskListCannotRename": "قائمة المهام هذه للقراءة فقط ولا يمكن إعادة تسميتها.", "taskListCannotDelete": "لا يمكن حذف قائمة المهام هذه باستخدام أذوناتك الحالية.", - "deleteTaskListConfirmation": "حذف \"{title}\" وجميع مهامها؟", - "unshareTaskListConfirmation": "إلغاء مشاركة \"{title}\" من هذا الحساب؟", + "deleteTaskListConfirmation": "حذف \"⁨{title}⁩\" وجميع مهامها؟", + "unshareTaskListConfirmation": "إلغاء مشاركة \"⁨{title}⁩\" من هذا الحساب؟", "taskStatus": "الحالة", "taskStatusNone": "بلا حالة", "taskStatusNeedsAction": "تحتاج إلى إجراء", @@ -661,12 +667,12 @@ "repeatOnOrdinalSummary": "{position, select, first{في أول {days}} second{في ثاني {days}} third{في ثالث {days}} fourth{في رابع {days}} fifth{في خامس {days}} secondToLast{في {days} قبل الأخير} last{في آخر {days}} other{في {days}}}", "repeatInMonthsSummary": "في {months}", "repeatTimesSummary": "{count, plural, one{مرة واحدة} two{مرتين} few{{count} مرات} many{{count} مرة} other{{count} مرة}}", - "repeatUntilSummary": "حتى {date}", + "repeatUntilSummary": "حتى ⁨{date}⁩", "unsupportedRecurrencePreserved": "تستخدم قاعدة التكرار هذه خيارات لا يغيّرها هذا المحرر.", - "recurrenceUnsupportedByProvider": "لا يمكن استخدام هذا التكرار مع {provider}.", + "recurrenceUnsupportedByProvider": "لا يمكن استخدام هذا التكرار مع ⁨{provider}⁩.", "duplicateTask": "تكرار المهمة", "taskDuplicated": "تم تكرار المهمة.", - "taskDuplicateFailed": "تعذّر تكرار المهمة: {error}", + "taskDuplicateFailed": "تعذّر تكرار المهمة: ⁨{error}⁩", "hideSubtasks": "إخفاء المهام الفرعية", "hideClosedSubtasks": "إخفاء المهام الفرعية المغلقة", "reminderUnit": "الوحدة", @@ -677,24 +683,24 @@ "addTeamsMeeting": "إضافة اجتماع Microsoft Teams", "onlineMeetingAdded": "تمت إضافة الاجتماع عبر الإنترنت", "requestResponses": "طلب الردود", - "requestResponsesDescription": "اطلب من الضيوف الرد على الدعوة.", - "hideGuestList": "إخفاء قائمة الضيوف", - "hideGuestListDescription": "لا يمكن للضيوف رؤية المدعوين الآخرين.", + "requestResponsesDescription": "اطلب من المدعوين الرد على الدعوة.", + "hideGuestList": "إخفاء قائمة المدعوين", + "hideGuestListDescription": "لا يمكن للمدعوين رؤية المدعوين الآخرين.", "allowNewTimeProposals": "السماح باقتراح أوقات جديدة", - "allowNewTimeProposalsDescription": "يمكن للضيوف اقتراح وقت مختلف للاجتماع.", - "notifyGuestsTitle": "إبلاغ الضيوف؟", - "notifyGuestsSaveMessage": "يضم هذا الاجتماع ضيوفًا. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟", - "notifyGuestsDeleteMessage": "يضم هذا الاجتماع ضيوفًا. هل تريد إرسال إلغاء عند حذفه؟", + "allowNewTimeProposalsDescription": "يمكن للمدعوين اقتراح وقت مختلف للاجتماع.", + "notifyGuestsTitle": "إبلاغ المدعوين؟", + "notifyGuestsSaveMessage": "يضم هذا الاجتماع مدعوين. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟", + "notifyGuestsDeleteMessage": "يضم هذا الاجتماع مدعوين. هل تريد إرسال إلغاء عند حذفه؟", "sendUpdates": "إرسال التحديثات", "sendCancellation": "إرسال الإلغاء", "doNotSend": "عدم الإرسال", "microsoftNotifyGuestsSaveTitle": "حفظ الاجتماع؟", - "microsoftNotifyGuestsSaveMessage": "سترسل Microsoft الدعوات أو تحديثات الحدث إلى الضيوف.", + "microsoftNotifyGuestsSaveMessage": "سترسل Microsoft الدعوات أو تحديثات الحدث إلى المدعوين.", "microsoftNotifyGuestsDeleteTitle": "حذف الاجتماع؟", - "microsoftNotifyGuestsDeleteMessage": "سترسل Microsoft إلغاءً إلى الضيوف.", + "microsoftNotifyGuestsDeleteMessage": "سترسل Microsoft إلغاءً إلى المدعوين.", "organizer": "المنظّم", "yourResponse": "ردك", - "guestResponses": "ردود الضيوف", + "guestResponses": "ردود المدعوين", "respond": "الرد", "acceptInvitation": "قبول", "tentativeInvitation": "مبدئي", @@ -706,7 +712,7 @@ "responseNeedsAction": "بانتظار الرد", "responseNotResponded": "لم يتم الرد", "responseOrganizer": "المنظّم", - "invitationResponseFailed": "تعذّر إرسال ردك: {error}", + "invitationResponseFailed": "تعذّر إرسال ردك: ⁨{error}⁩", "@invitationResponseFailed": { "placeholders": { "error": { @@ -719,7 +725,7 @@ "setAction": "تعيين", "removeFromMyCalendars": "إزالة من تقاويمي", "removeAction": "إزالة", - "removeCalendarConfirmation": "هل تريد إزالة \"{title}\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", + "removeCalendarConfirmation": "هل تريد إزالة \"⁨{title}⁩\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", "calendarCannotRemove": "لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.", "calendarPendingChangesPreventRemoval": "انتظر حتى تنتهي مزامنة التغييرات المعلقة لهذا التقويم قبل حذفه أو إزالته.", "calendarSubscriptions": "اشتراكات التقويم", @@ -737,7 +743,7 @@ "subscriptionHourly": "كل ساعة", "subscriptionSixHours": "كل ست ساعات", "subscriptionDaily": "يوميًا", - "subscriptionSafeOrigin": "المصدر: {origin}", + "subscriptionSafeOrigin": "المصدر: ⁨{origin}⁩", "@subscriptionSafeOrigin": { "placeholders": { "origin": { @@ -748,7 +754,7 @@ "subscriptionSafeOriginUnavailable": "أدخل عنوان URL صالحًا لمعاينة مصدره الآمن.", "subscriptionReadOnly": "اشتراك للقراءة فقط", "subscriptionNeverRefreshed": "لم يتم التحديث بعد", - "subscriptionLastRefresh": "آخر تحديث ناجح: {time}", + "subscriptionLastRefresh": "آخر تحديث ناجح: ⁨{time}⁩", "@subscriptionLastRefresh": { "placeholders": { "time": { @@ -756,7 +762,7 @@ } } }, - "subscriptionNextRefresh": "التحديث التالي: {time}", + "subscriptionNextRefresh": "التحديث التالي: ⁨{time}⁩", "@subscriptionNextRefresh": { "placeholders": { "time": { @@ -765,7 +771,7 @@ } }, "subscriptionStatusHealthy": "محدّث", - "subscriptionStatusIssue": "مشكلة في التحديث: {code}", + "subscriptionStatusIssue": "مشكلة في التحديث: ⁨{code}⁩", "@subscriptionStatusIssue": { "placeholders": { "code": { @@ -775,7 +781,7 @@ }, "refreshNow": "تحديث الآن", "unsubscribe": "إلغاء الاشتراك", - "unsubscribeCalendarTitle": "إلغاء الاشتراك من «{name}»؟", + "unsubscribeCalendarTitle": "إلغاء الاشتراك من «⁨{name}⁩»؟", "@unsubscribeCalendarTitle": { "placeholders": { "name": { @@ -785,7 +791,7 @@ }, "unsubscribeCalendarConfirmation": "يؤدي هذا إلى إزالة الاشتراك المحلي والأحداث المخزنة مؤقتًا. لن يتغير التقويم المنشور.", "addSubscriptionAction": "إضافة اشتراك", - "subscriptionOperationFailed": "فشل اشتراك التقويم: {error}", + "subscriptionOperationFailed": "فشل اشتراك التقويم: ⁨{error}⁩", "@subscriptionOperationFailed": { "placeholders": { "error": { @@ -814,7 +820,7 @@ } } }, - "importFieldsOmitted": "تم الاستبعاد عمدًا: {fields}", + "importFieldsOmitted": "تم الاستبعاد عمدًا: ⁨{fields}⁩", "@importFieldsOmitted": { "placeholders": { "fields": { @@ -850,7 +856,7 @@ } } }, - "importIcsFailed": "تعذّر استيراد ملف التقويم: {error}", + "importIcsFailed": "تعذّر استيراد ملف التقويم: ⁨{error}⁩", "@importIcsFailed": { "placeholders": { "error": { @@ -872,7 +878,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "في أيام الشهر {days}", + "repeatOnMonthDaysSummaryMultiple": "في الأيام {days} من الشهر", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e6ac26cb..9dd965b2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +1,11 @@ { "@@locale": "de", + "repeatWeeklyDaySummary": "{dayKey, select, MO{montags} TU{dienstags} WE{mittwochs} TH{donnerstags} FR{freitags} SA{samstags} SU{sonntags} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "an den Tagen {first} und {second} des Monats", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} an den Tagen {firstDay} und {secondDay} im {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} jeweils am {day}. im {firstMonth} und {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} jeweils an den Tagen {firstDay} und {secondDay} im {firstMonth} und {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} jeweils an den Tagen {days} im {firstMonth} und {secondMonth}", "appTitle": "BusyMax", "connectGoogleAccount": "Verbinden Sie Google-, Microsoft-, Apple-iCloud-Kalender- oder Nextcloud-Konten.", "googlePermissionsConsentNotice": "Wählen Sie auf dem Google-Berechtigungsbildschirm sowohl Kalender- als auch Aufgabenberechtigungen aus.", @@ -212,7 +218,7 @@ "visibilityPublic": "Öffentlich", "visibilityPrivate": "Privat", "visibilityConfidential": "Vertraulich", - "sensitivityNormal": "Gewöhnlich", + "sensitivityNormal": "Normal", "sensitivityPersonal": "Persönlich", "tasks": "Aufgaben", "allTasks": "Alle Aufgaben", @@ -719,7 +725,7 @@ "repeatEveryWeeks": "Alle {count} Wochen", "repeatEveryMonths": "Alle {count} Monate", "repeatEveryYears": "Alle {count} Jahre", - "repeatOnDaysSummary": "an {days}", + "repeatOnDaysSummary": "{days}", "repeatOnMonthDaysSummary": "am {days}. Tag", "repeatOnOrdinalSummary": "{position, select, first{am ersten {days}} second{am zweiten {days}} third{am dritten {days}} fourth{am vierten {days}} fifth{am fünften {days}} secondToLast{am vorletzten {days}} last{am letzten {days}} other{an {days}}}", "repeatInMonthsSummary": "in den Monaten {months}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 751d9fa5..79f50bb8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +1,35 @@ { "@@locale": "en", + "repeatWeeklyDaySummary": "{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} other{{day}}}", + "@repeatWeeklyDaySummary": { + "description": "Sentence-form weekday used inside a weekly recurrence summary. {dayKey} is MO through SU; {day} is the locale-formatted fallback.", + "placeholders": {"dayKey": {"type": "String"}, "day": {"type": "String"}} + }, + "repeatOnTwoMonthDaysSummary": "on days {first} and {second}", + "@repeatOnTwoMonthDaysSummary": { + "description": "Sentence fragment for a monthly recurrence on exactly two dates.", + "placeholders": {"first": {"type": "String"}, "second": {"type": "String"}} + }, + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} on days {firstDay} and {secondDay} of {month}", + "@repeatYearlyOnTwoMonthDaysSummary": { + "description": "Complete yearly recurrence summary for exactly two dates in one month.", + "placeholders": {"frequency": {"type": "String"}, "month": {"type": "String"}, "firstDay": {"type": "String"}, "secondDay": {"type": "String"}} + }, + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} on day {day} of {firstMonth} and {secondMonth}", + "@repeatYearlyInTwoMonthsOnMonthDaySummary": { + "description": "Complete yearly recurrence summary for one date in exactly two months.", + "placeholders": {"frequency": {"type": "String"}, "firstMonth": {"type": "String"}, "secondMonth": {"type": "String"}, "day": {"type": "String"}} + }, + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} on days {firstDay} and {secondDay} of {firstMonth} and {secondMonth}", + "@repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": { + "description": "Complete yearly recurrence summary for exactly two dates in exactly two months.", + "placeholders": {"frequency": {"type": "String"}, "firstMonth": {"type": "String"}, "secondMonth": {"type": "String"}, "firstDay": {"type": "String"}, "secondDay": {"type": "String"}} + }, + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} on days {days} of {firstMonth} and {secondMonth}", + "@repeatYearlyInTwoMonthsOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for three or more dates in exactly two months.", + "placeholders": {"frequency": {"type": "String"}, "firstMonth": {"type": "String"}, "secondMonth": {"type": "String"}, "days": {"type": "String"}} + }, "appTitle": "BusyMax", "connectGoogleAccount": "Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.", "@connectGoogleAccount": { @@ -45,7 +75,8 @@ "@calendarColorOption": { "placeholders": { "number": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -87,7 +118,8 @@ "@moreItems": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -115,7 +147,8 @@ "@trayTasksDueToday": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -129,7 +162,8 @@ "@trayLastSyncedMinutesAgo": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -137,7 +171,8 @@ "@trayLastSyncedHoursAgo": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -145,7 +180,8 @@ "@trayLastSyncedDaysAgo": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -241,7 +277,8 @@ "@reminderMinutesBefore": { "placeholders": { "minutes": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -250,7 +287,8 @@ "@reminderHoursBefore": { "placeholders": { "hours": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -258,7 +296,8 @@ "@reminderDaysBefore": { "placeholders": { "days": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -518,7 +557,8 @@ "@completionPercent": { "placeholders": { "percent": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -529,7 +569,8 @@ "@priorityHighValue": { "placeholders": { "priority": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -537,7 +578,8 @@ "@priorityMediumValue": { "placeholders": { "priority": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -545,7 +587,8 @@ "@priorityLowValue": { "placeholders": { "priority": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -597,7 +640,8 @@ "@relatedRemindersDescription": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -653,7 +697,8 @@ "@repeatEveryDays": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -661,7 +706,8 @@ "@repeatEveryWeeks": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -669,7 +715,8 @@ "@repeatEveryMonths": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -677,7 +724,8 @@ "@repeatEveryYears": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -725,7 +773,8 @@ "description": "Sentence fragment summarizing a finite recurrence count.", "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -895,7 +944,8 @@ "@pendingOpAttempts": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -934,7 +984,8 @@ "@dueTodayNotificationBody": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -959,7 +1010,8 @@ "@weekNumberTooltip": { "placeholders": { "number": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -968,7 +1020,8 @@ "@scheduleItemCount": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -1203,7 +1256,8 @@ "@importEventsFound": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -1211,7 +1265,8 @@ "@importInvalidEvents": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -1231,7 +1286,8 @@ "@importQueued": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -1239,7 +1295,8 @@ "@importDuplicatesSkipped": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -1247,7 +1304,8 @@ "@importUnsupportedSets": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 32b8fc4f..de2b10a3 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,5 +1,11 @@ { "@@locale": "es", + "repeatWeeklyDaySummary": "{dayKey, select, MO{lunes} TU{martes} WE{miércoles} TH{jueves} FR{viernes} SA{sábado} SU{domingo} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "los días {first} y {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} los días {firstDay} y {secondDay} de {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} el día {day} de {firstMonth} y {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} los días {firstDay} y {secondDay} de {firstMonth} y {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} los días {days} de {firstMonth} y {secondMonth}", "appTitle": "BusyMax", "connectGoogleAccount": "Conecta cuentas de Google, Microsoft, Calendario de Apple iCloud o Nextcloud.", "googlePermissionsConsentNotice": "En la pantalla de permisos de Google, selecciona los permisos de Calendario y Tareas.", @@ -212,8 +218,8 @@ "visibilityPublic": "Pública", "visibilityPrivate": "Privada", "visibilityConfidential": "Confidencial", - "sensitivityNormal": "Habitual", - "sensitivityPersonal": "Personalizada", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Personal", "tasks": "Tareas", "allTasks": "Todas las tareas", "tasksInList": "Tareas en {title}", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 18ce25c3..8f572879 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,22 +1,28 @@ { "@@locale": "et", + "repeatWeeklyDaySummary": "{dayKey, select, MO{esmaspäeviti} TU{teisipäeviti} WE{kolmapäeviti} TH{neljapäeviti} FR{reedeti} SA{laupäeviti} SU{pühapäeviti} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first} ja {second} päeval", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} {firstDay} ja {secondDay} päeval", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth} ja {secondMonth} {day} päeval", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {firstDay} ja {secondDay} päeval", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {days} päeval", "appTitle": "BusyMax", - "connectGoogleAccount": "Ühenda Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.", - "googlePermissionsConsentNotice": "Valige Google'i õiguste kuval nii kalendri kui ka ülesannete õigused.", + "connectGoogleAccount": "Ühendage Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.", + "googlePermissionsConsentNotice": "Valige Google’i õiguste kuval nii kalendri kui ka ülesannete õigused.", "googlePermissionsRequiredRetry": "Google Calendari ja Google Tasksi õigused on nõutavad. Proovige uuesti ja märkige mõlemad ruudud.", "finishSetup": "Lõpeta seadistamine", "continueSetup": "Jätka", "onboardingSetupTitle": "BusyMaxi seadistamine", "onboardingAccountsStepTitle": "Kontode ühendamine", - "onboardingAccountsStepDescription": "Lisa kõik kontod, mida soovid kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.", + "onboardingAccountsStepDescription": "Lisage kõik kontod, mida soovite kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.", "onboardingPreferencesStepTitle": "Süsteemiseadete valimine", "onboardingPreferencesStepDescription": "Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.", - "signInWithGoogle": "Logi Google'iga sisse", + "signInWithGoogle": "Logi Google’iga sisse", "signInWithMicrosoft": "Logi Microsoftiga sisse", "googleTasksProvider": "Google Tasks", "microsoftTodoProvider": "Microsoft To Do", "providerNotConfigured": "See teenusepakkuja pole seadistatud.", - "waitingForGoogleSignIn": "Google'isse sisselogimise ootel...", + "waitingForGoogleSignIn": "Google’isse sisselogimise ootel...", "waitingForMicrosoftSignIn": "Microsofti sisselogimise ootel...", "microsoftSignInNotConfigured": "Microsofti sisselogimine pole seadistatud. Määrake MICROSOFT_OAUTH_CLIENT_ID.", "cancel": "Tühista", @@ -299,7 +305,7 @@ "accounts": "Kontod", "currentAccount": "Praegune konto", "switchAccount": "Vaheta kontot", - "addGoogleAccount": "Lisa Google'i konto", + "addGoogleAccount": "Lisa Google’i konto", "addMicrosoftAccount": "Lisa Microsofti konto", "googleProvider": "Google", "microsoftProvider": "Microsoft", @@ -316,11 +322,11 @@ } }, "removeAccountConfirmation": "See kustutab sellest seadmest vahemällu salvestatud ülesanded, kalendrid, sündmused, meeldetuletused ja ootel võrguühenduseta muudatused. Sünkroonimata muudatused lähevad kaotsi. Teenusepakkuja kalendrite, sündmuste, ülesandeloendite ja ülesannete koopiaid ei kustutata.", - "revokeGoogleAccess": "Tühista ka BusyMaxi juurdepääs sellele Google'i kontole", + "revokeGoogleAccess": "Tühista ka BusyMaxi juurdepääs sellele Google’i kontole", "revokeGoogleAccessDescription": "Enne uuesti ühendamist peate juurdepääsu uuesti andma.", "removeAccountAction": "Eemalda konto", "removeAccountFailed": "Konto eemaldamist ei saanud lõpetada. Proovige uuesti.", - "accountRemovedGoogleRevokeFailed": "Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google'i kontole ei saanud tühistada. Saate selle oma Google'i kontol käsitsi tühistada.", + "accountRemovedGoogleRevokeFailed": "Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google’i kontole ei saanud tühistada. Saate selle oma Google’i kontol käsitsi tühistada.", "newTaskList": "Uus ülesandeloend", "signInToViewTaskLists": "Ülesandeloendite vaatamiseks logige sisse.", "noTaskListsSynced": "Ühtegi ülesandeloendit pole veel sünkroonitud.", @@ -484,7 +490,7 @@ "showTrayIcon": "Kuva süsteemisalve ikoon", "startMinimizedToTray": "Käivita minimeerituna süsteemisalves", "launchAtLogin": "Käivita sisselogimisel", - "launchAtLoginDescription": "Käivita BusyMax taustal, et meeldetuletused töötaksid pärast sisselogimist.", + "launchAtLoginDescription": "BusyMax käivitatakse taustal, et meeldetuletused töötaksid pärast sisselogimist.", "launchAtLoginFailed": "Sisselogimisel käivitamise seadet ei saanud uuendada.", "requiresTrayIcon": "Nõuab süsteemisalve ikooni.", "syncComplete": "Sünkroonimine on lõpetatud.", @@ -637,7 +643,7 @@ "searchLocations": "Otsi asukohti", "noLocationsFound": "Asukohti ei leitud", "requiredField": "See väli on kohustuslik.", - "providerConnectionDescription": "Ühenda kalendrid ja ülesanded ühega neist teenusepakkujatest.", + "providerConnectionDescription": "Ühendage kalendrid ja ülesanded ühega neist teenusepakkujatest.", "appleICloudProvider": "Apple iCloudi kalender", "nextcloudProvider": "Nextcloud", "appleICloudTasksProvider": "Apple iCloud", @@ -653,7 +659,7 @@ "appleAppSpecificPasswordResetWarning": "Apple’i konto parooli lähtestamine tühistab rakendusepõhised paroolid.", "connectNextcloudTitle": "Ühenda Nextcloud", "nextcloudServerUrl": "Nextcloudi server või CalDAV-aadress", - "nextcloudServerUrlHelp": "Sisesta Nextcloudi serveri URL või kleebi Nextcloudist kopeeritud peamine CalDAV-aadress.", + "nextcloudServerUrlHelp": "Sisestage Nextcloudi serveri URL või kleepige Nextcloudist kopeeritud peamine CalDAV-aadress.", "nextcloudBrowserAuthorizationHelp": "BusyMax avab brauseri. Kinnita juurdepääs seal ja naase seejärel BusyMaxi.", "connectAccountAction": "Ühenda", "cancelAccountConnection": "Tühista ühendus", @@ -748,14 +754,14 @@ "singleOccurrence": "See sündmus", "thisAndFollowingEvents": "See ja järgmised sündmused", "thisAndFutureUnavailable": "See teenusepakkuja ei toeta seda.", - "thisAndFutureMoveUnavailable": "Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Vali see sündmus või kogu sari.", + "thisAndFutureMoveUnavailable": "Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Valige see sündmus või kogu sari.", "entireSeriesMoveUnavailable": "Kordusreegel pole kohalikult saadaval. Teisalda selle asemel ainult see sündmus.", "copyEventAndDeleteOriginal": "Kas kopeerida sündmus ja kustutada algne?", "copyEventMoveWarning": "BusyMax ei saa seda sündmust otse kalendrist {source} kalendrisse {destination} teisaldada. Esmalt luuakse koopia ja algne sündmus kustutatakse alles pärast edukat kopeerimist. Sündmuse ID-d muutuvad; osalejate vastuseolekud võivad lähtestuda ning võidakse saata kutseid või tühistusi; konverentsilingid, manused, meeldetuletused, teenusepakkuja väljad ja korduse erandid ei pruugi üle kanduda.", "copyAndDelete": "Kopeeri ja kustuta", - "chooseRecurringEventScope": "Vali, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.", + "chooseRecurringEventScope": "Valige, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.", "taskDueBeforeStart": "Tähtaeg ei tohi olla enne algust.", - "taskStartDueTimeModeMismatch": "Määra nii algus- kui ka tähtaja kellaaeg või tee ülesanne kogu päeva ülesandeks.", + "taskStartDueTimeModeMismatch": "Määrake nii algus- kui ka tähtaja kellaaeg või tehke ülesanne kogu päeva ülesandeks.", "taskListCreateFailed": "Ülesandeloendi loomine ebaõnnestus: {error}", "taskListRenameFailed": "Ülesandeloendi ümbernimetamine ebaõnnestus: {error}", "taskListDeleteFailed": "Ülesandeloendi kustutamine ebaõnnestus: {error}", @@ -778,7 +784,7 @@ "priorityMediumValue": "Prioriteet {priority} · keskmine", "priorityLowValue": "Prioriteet {priority} · madal", "taskUrl": "Ülesande URL", - "invalidTaskUrl": "Sisesta absoluutne URL koos skeemiga.", + "invalidTaskUrl": "Sisestage absoluutne URL koos skeemiga.", "classification": "Klassifikatsioon", "classificationPublic": "Jagamisel näita kogu ülesannet", "classificationConfidential": "Jagamisel näita ainult hõivatust", @@ -834,7 +840,7 @@ "repeatEveryWeeks": "Iga {count} nädala järel", "repeatEveryMonths": "Iga {count} kuu järel", "repeatEveryYears": "Iga {count} aasta järel", - "repeatOnDaysSummary": "päevadel {days}", + "repeatOnDaysSummary": "{days}", "repeatOnMonthDaysSummary": "{days} päeval", "repeatOnOrdinalSummary": "{position, select, first{esimene {days}} second{teine {days}} third{kolmas {days}} fourth{neljas {days}} fifth{viies {days}} secondToLast{eelviimane {days}} last{viimane {days}} other{{days}}}", "repeatInMonthsSummary": "{months}", @@ -855,7 +861,7 @@ "addTeamsMeeting": "Lisa Microsoft Teamsi koosolek", "onlineMeetingAdded": "Veebikoosolek lisatud", "requestResponses": "Küsi vastuseid", - "requestResponsesDescription": "Palu külalistel kutsele vastata.", + "requestResponsesDescription": "Paluge külalistel kutsele vastata.", "hideGuestList": "Peida külaliste loend", "hideGuestListDescription": "Külalised ei näe, keda veel kutsuti.", "allowNewTimeProposals": "Luba uusi ajapakkumisi", @@ -897,19 +903,19 @@ "setAction": "Määra", "removeFromMyCalendars": "Eemalda minu kalendritest", "removeAction": "Eemalda", - "removeCalendarConfirmation": "Kas eemaldada „{title}” sinu Google'i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", + "removeCalendarConfirmation": "Kas eemaldada „{title}” teie Google’i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", "calendarCannotRemove": "Seda kalendrit ei saa sellest kontost kustutada ega eemaldada.", - "calendarPendingChangesPreventRemoval": "Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.", + "calendarPendingChangesPreventRemoval": "Enne kalendri kustutamist või eemaldamist oodake, kuni selle ootel muudatused on sünkroonitud.", "calendarSubscriptions": "Kalendritellimused", - "calendarSubscriptionsDescription": "Lisa kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.", + "calendarSubscriptionsDescription": "Lisage kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.", "addCalendarSubscription": "Lisa kalendritellimus", "subscriptionName": "Kohalik nimi", "subscriptionUrl": "Tellimuse URL", - "subscriptionUrlHelp": "Sisesta HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.", - "subscriptionUrlInvalid": "Sisesta kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.", + "subscriptionUrlHelp": "Sisestage HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.", + "subscriptionUrlInvalid": "Sisestage kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.", "subscriptionColor": "Kohalik värv", - "subscriptionColorHelp": "Kasuta kuuekohalist värvi, näiteks #3584E4.", - "subscriptionColorInvalid": "Sisesta kuuekohaline kuueteistkümnendsüsteemi värv.", + "subscriptionColorHelp": "Kasutage kuuekohalist värvi, näiteks #3584E4.", + "subscriptionColorInvalid": "Sisestage kuuekohaline kuueteistkümnendsüsteemi värv.", "subscriptionRefreshMode": "Värskendussagedus", "subscriptionAutomatic": "Automaatne", "subscriptionHourly": "Iga tunni järel", @@ -973,7 +979,7 @@ }, "subscriptions": "Tellimused", "calendarImport": "Kalendri import", - "calendarImportDescription": "Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need lisada.", + "calendarImportDescription": "Valige fail, vaadake selle sündmused üle ja valige seejärel kirjutatav kalender, kuhu need lisada.", "importIcsFile": "Impordi .ics-fail", "importIcsPreview": "Impordi kalendrisündmused", "importEventsFound": "Imporditavad sündmusekomplektid: {count}", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index ca449726..3e1093ef 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,5 +1,11 @@ { "@@locale": "fa", + "repeatWeeklyDaySummary": "{dayKey, select, MO{دوشنبه} TU{سه‌شنبه} WE{چهارشنبه} TH{پنجشنبه} FR{جمعه} SA{شنبه} SU{یکشنبه} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "در روزهای {first} و {second} ماه", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} در روزهای {firstDay} و {secondDay} ماه {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} در روز {day} ماه‌های {firstMonth} و {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} در روزهای {firstDay} و {secondDay} ماه‌های {firstMonth} و {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} در روزهای {days} ماه‌های {firstMonth} و {secondMonth}", "appTitle": "BusyMax", "connectGoogleAccount": "حساب‌های Google، Microsoft، Apple iCloud Calendar یا Nextcloud را متصل کنید.", "googlePermissionsConsentNotice": "در صفحهٔ مجوزهای Google، مجوزهای تقویم و کارها را هر دو انتخاب کنید.", @@ -39,13 +45,14 @@ "@calendarColorOption": { "placeholders": { "number": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, "calendarManagementUnsupported": "این ارائه‌دهنده از مدیریت تقویم در BusyMax پشتیبانی نمی‌کند.", "primaryCalendarCannotDelete": "تقویم اصلی را نمی‌توان حذف کرد.", - "calendarCreateFailed": "ایجاد تقویم ممکن نشد: {error}", + "calendarCreateFailed": "ایجاد تقویم ممکن نشد: ⁨{error}⁩", "calendarCreatedRefreshPending": "تقویم ایجاد شد، اما BusyMax نتوانست حساب را تازه‌سازی کند. پس از همگام‌سازی بعدی نمایش داده می‌شود.", "@calendarCreateFailed": { "placeholders": { @@ -54,7 +61,7 @@ } } }, - "calendarUpdateFailed": "به‌روزرسانی تقویم ممکن نشد: {error}", + "calendarUpdateFailed": "به‌روزرسانی تقویم ممکن نشد: ⁨{error}⁩", "@calendarUpdateFailed": { "placeholders": { "error": { @@ -62,7 +69,7 @@ } } }, - "calendarDeleteFailed": "حذف تقویم ممکن نشد: {error}", + "calendarDeleteFailed": "حذف تقویم ممکن نشد: ⁨{error}⁩", "@calendarDeleteFailed": { "placeholders": { "error": { @@ -102,7 +109,8 @@ "@trayTasksDueToday": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -116,7 +124,8 @@ "@trayLastSyncedMinutesAgo": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -124,7 +133,8 @@ "@trayLastSyncedHoursAgo": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -132,7 +142,8 @@ "@trayLastSyncedDaysAgo": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -164,7 +175,7 @@ "doesNotRepeat": "تکرار نمی‌شود", "defaultReminder": "یادآور پیش‌فرض", "guests": "مهمانان", - "noGuests": "مهمانی وجود ندارد", + "noGuests": "فهرست مهمانان خالی است", "description": "توضیحات", "availabilityShowAs": "وضعیت دسترسی / نمایش به‌عنوان", "busy": "مشغول", @@ -195,7 +206,7 @@ "sensitivityPersonal": "شخصی", "tasks": "کارها", "allTasks": "همهٔ کارها", - "tasksInList": "کارهای ⁨{title}⁩", + "tasksInList": "کارهای ⁨⁨{title}⁩⁩", "taskLists": "فهرست‌های کار", "navigation": "پیمایش", "mainMenu": "منوی اصلی", @@ -250,7 +261,7 @@ "feedbackRateLimitedError": "بازخوردهای بیش از حدی از این شبکه ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.", "feedbackRejectedError": "سرور ارسال را رد کرد. فیلدها را بررسی و دوباره تلاش کنید.", "feedbackServerError": "BusyStack اکنون نمی‌تواند بازخورد شما را بپذیرد. بازخورد شما پاک نشده است؛ دوباره تلاش کنید.", - "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: ⁨{id}⁩", + "feedbackSuccess": "بازخورد ارسال شد. شناسهٔ پیگیری: ⁨⁨{id}⁩⁩", "toggleSidebar": "نمایش یا پنهان کردن نوار کناری", "showSidebar": "نمایش پنل کناری", "hideSidebar": "پنهان کردن پنل کناری", @@ -265,7 +276,7 @@ "removeAccount": "حذف حساب…", "removingAccount": "در حال حذف حساب…", "removeAccountDescription": "همگام‌سازی را متوقف و داده‌های این حساب را از این دستگاه حذف کنید.", - "removeAccountTitle": "⁨{account}⁩ از BusyMax حذف شود؟", + "removeAccountTitle": "⁨⁨{account}⁩⁩ از BusyMax حذف شود؟", "removeAccountConfirmation": "این کار، کارها، تقویم‌ها، رویدادها، یادآورها و تغییرات آفلاین در انتظار را که در این دستگاه ذخیره شده‌اند حذف می‌کند. تغییرات همگام‌نشده از بین می‌روند. نسخه‌های تقویم‌ها، رویدادها، فهرست‌های کار و کارها نزد ارائه‌دهنده حذف نمی‌شوند.", "revokeGoogleAccess": "دسترسی BusyMax به این حساب Google نیز لغو شود", "revokeGoogleAccessDescription": "پیش از اتصال دوباره باید دسترسی را دوباره اعطا کنید.", @@ -282,7 +293,7 @@ "deleteList": "حذف فهرست", "builtInMicrosoftList": "داخلی", "builtInMicrosoftListCannotRenameDelete": "فهرست‌های داخلی Microsoft To Do را نمی‌توان تغییر نام داد یا حذف کرد.", - "deleteListConfirmation": "‏«⁨{title}⁩» از Google Tasks حذف شود؟", + "deleteListConfirmation": "‏«⁨⁨{title}⁩⁩» از Google Tasks حذف شود؟", "deleteEvent": "حذف رویداد", "title": "عنوان", "create": "ایجاد", @@ -292,9 +303,9 @@ "refreshAll": "تازه‌سازی همه", "listRefreshed": "فهرست تازه‌سازی شد.", "allTasksRefreshed": "همهٔ حساب‌ها تازه‌سازی شدند.", - "exportedFile": "در ⁨{path}⁩ خروجی گرفته شد", - "exportFailed": "خروجی گرفتن ناموفق بود: ⁨{error}⁩", - "refreshFailed": "تازه‌سازی ناموفق بود: ⁨{error}⁩", + "exportedFile": "در ⁨⁨{path}⁩⁩ خروجی گرفته شد", + "exportFailed": "خروجی گرفتن ناموفق بود: ⁨⁨{error}⁩⁩", + "refreshFailed": "تازه‌سازی ناموفق بود: ⁨⁨{error}⁩⁩", "selectOrCreateTaskList": "برای شروع، یک فهرست کار انتخاب یا ایجاد کنید.", "signInToViewTasks": "برای دیدن کارها وارد شوید.", "noTasks": "هیچ کاری وجود ندارد.", @@ -307,8 +318,8 @@ "upcoming": "پیش رو", "noDate": "بدون تاریخ", "completed": "انجام‌شده", - "duePrefix": "سررسید: ⁨{date}⁩", - "dateTimeDisplay": "⁨{date}⁩ · ⁨{time}⁩", + "duePrefix": "سررسید: ⁨⁨{date}⁩⁩", + "dateTimeDisplay": "⁨⁨{date}⁩⁩ · ⁨⁨{time}⁩⁩", "taskDetails": "جزئیات کار", "editTask": "ویرایش کار", "noTaskSelected": "هیچ کاری انتخاب نشده است.", @@ -363,7 +374,7 @@ "moveToTop": "انتقال به بالاترین جایگاه", "deleteTask": "حذف کار", "newSubtask": "زیرکار جدید", - "deleteTaskConfirmation": "«⁨{title}⁩» حذف شود؟", + "deleteTaskConfirmation": "«⁨⁨{title}⁩⁩» حذف شود؟", "metadata": "فراداده", "id": "شناسه", "etag": "ETag", @@ -387,7 +398,7 @@ "launchAtLoginFailed": "تنظیم اجرای هنگام ورود به‌روزرسانی نشد.", "requiresTrayIcon": "به نماد سینی سیستم نیاز دارد.", "syncComplete": "همگام‌سازی کامل شد.", - "syncFailed": "همگام‌سازی ناموفق بود: ⁨{error}⁩", + "syncFailed": "همگام‌سازی ناموفق بود: ⁨⁨{error}⁩⁩", "notifySyncFailures": "اعلان هنگام شکست همگام‌سازی", "notifyConflicts": "اعلان هنگام تداخل", "notifyDueToday": "اعلان کارهای دارای سررسید امروز", @@ -418,7 +429,7 @@ "diagnostics": "اطلاعات تشخیصی", "apiInspectorDisabled": "نمایش بازرس API", "googleTasksApi": "رابط Google Tasks API", - "discoveryRevision": "بازبینی Discovery: ⁨{revision}⁩", + "discoveryRevision": "بازبینی Discovery: ⁨⁨{revision}⁩⁩", "implementedMethods": "روش‌های پیاده‌سازی‌شده", "supportsTasksScopes": "از محدوده‌های tasks و tasks.readonly پشتیبانی می‌کند", "requiresTasksScope": "به محدودهٔ tasks نیاز دارد", @@ -426,8 +437,8 @@ "signInToInspectPendingOperations": "برای بررسی عملیات در انتظار وارد شوید.", "noBlockedPendingOperations": "هیچ عملیات در انتظار مسدودشده‌ای وجود ندارد.", "operationActions": "اقدامات عملیات", - "pendingOpListId": "فهرست=⁨{id}⁩", - "pendingOpTaskId": "کار=⁨{id}⁩", + "pendingOpListId": "فهرست=⁨⁨{id}⁩⁩", + "pendingOpTaskId": "کار=⁨⁨{id}⁩⁩", "pendingOpAttempts": "تلاش‌ها=⁨{count}⁩", "retry": "تلاش دوباره", "discard": "کنار گذاشتن", @@ -439,9 +450,9 @@ "discardPendingOperationConfirmation": "با این کار عملیات محلی مسدودشده حذف می‌شود. در همگام‌سازی بعدی، داده‌ها از Google Tasks تازه‌سازی می‌شوند.", "pendingOperationDiscarded": "عملیات در انتظار کنار گذاشته شد.", "syncFailureNotificationTitle": "همگام‌سازی BusyMax ناموفق بود", - "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. ⁨{message}⁩", + "syncFailureNotificationBody": "همگام‌سازی پس‌زمینه ناموفق بود. ⁨⁨{message}⁩⁩", "conflictNotificationTitle": "تداخل همگام‌سازی BusyMax", - "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. ⁨{summary}⁩", + "conflictNotificationBody": "یک تغییر محلی در انتظار مسدود شد. ⁨⁨{summary}⁩⁩", "dueTodayNotificationTitle": "کارهای دارای سررسید امروز", "dueTodayNotificationBody": "{count, plural, =0{امروز هیچ کاری سررسید ندارد.} =1{امروز یک کار سررسید دارد.} other{امروز ⁨{count}⁩ کار سررسید دارند.}}", "eventReminderNotificationTitle": "یادآور رویداد", @@ -503,7 +514,7 @@ "keepServerVersion": "نگه‌داشتن نسخهٔ سرور", "reapplyLocalChange": "بررسی و اعمال دوبارهٔ تغییر محلی", "duplicateLocalItem": "تکرار به‌عنوان مورد جدید", - "deleteCalendarConfirmation": "«⁨{title}⁩» حذف شود؟", + "deleteCalendarConfirmation": "«⁨⁨{title}⁩⁩» حذف شود؟", "@moreItems": { "placeholders": { "count": { @@ -572,7 +583,7 @@ "davConnected": "متصل", "davConnecting": "در حال اتصال…", "davSignedOut": "خارج‌شده", - "davLastSuccessfulSync": "آخرین همگام‌سازی موفق: {time}", + "davLastSuccessfulSync": "آخرین همگام‌سازی موفق: ⁨{time}⁩", "@davLastSuccessfulSync": { "placeholders": { "time": { @@ -582,7 +593,7 @@ }, "davNeverSynced": "هنوز همگام نشده", "refreshCollections": "تازه‌سازی تقویم‌ها و فهرست‌های کار", - "nextcloudServerHost": "سرور: {host}", + "nextcloudServerHost": "سرور: ⁨{host}⁩", "@nextcloudServerHost": { "placeholders": { "host": { @@ -595,7 +606,7 @@ "collectionSupportsEventsAndTasks": "رویدادها و کارها", "writableCollection": "قابل نوشتن", "sharedCollection": "اشتراکی", - "collectionLastSynced": "آخرین همگام‌سازی: {time}", + "collectionLastSynced": "آخرین همگام‌سازی: ⁨{time}⁩", "@collectionLastSynced": { "placeholders": { "time": { @@ -603,7 +614,7 @@ } } }, - "collectionSyncError": "مشکل همگام‌سازی: {code}", + "collectionSyncError": "مشکل همگام‌سازی: ⁨{code}⁩", "@collectionSyncError": { "placeholders": { "code": { @@ -612,7 +623,7 @@ } }, "syncConflicts": "تعارض‌های همگام‌سازی", - "remoteChangedAt": "تغییر سرور: {time}", + "remoteChangedAt": "تغییر سرور: ⁨{time}⁩", "@remoteChangedAt": { "placeholders": { "time": { @@ -620,7 +631,7 @@ } } }, - "localPendingEdit": "ویرایش محلی: {summary}", + "localPendingEdit": "ویرایش محلی: ⁨{summary}⁩", "@localPendingEdit": { "placeholders": { "summary": { @@ -637,19 +648,19 @@ "thisAndFutureMoveUnavailable": "انتقال امن این رویداد و رویدادهای بعدی ممکن نیست. این رویداد یا کل مجموعه را انتخاب کنید.", "entireSeriesMoveUnavailable": "قاعدهٔ تکرار به‌صورت محلی در دسترس نیست. به‌جای آن فقط این رویداد را منتقل کنید.", "copyEventAndDeleteOriginal": "رویداد کپی و نسخهٔ اصلی حذف شود؟", - "copyEventMoveWarning": "BusyMax نمی‌تواند این رویداد را مستقیماً از {source} به {destination} منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.", + "copyEventMoveWarning": "BusyMax نمی‌تواند این رویداد را مستقیماً از ⁨{source}⁩ به ⁨{destination}⁩ منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.", "copyAndDelete": "کپی و حذف", "chooseRecurringEventScope": "انتخاب کنید این تغییر برای کل مجموعه، فقط این رویداد، یا این رویداد و رویدادهای بعدی اعمال شود.", "taskDueBeforeStart": "زمان سررسید نباید پیش از زمان شروع باشد.", "taskStartDueTimeModeMismatch": "برای شروع و سررسید هر دو زمان تعیین کنید، یا کار را تمام‌روز کنید.", - "taskListCreateFailed": "ایجاد فهرست کار ممکن نیست: {error}", - "taskListRenameFailed": "تغییر نام فهرست کار ممکن نیست: {error}", - "taskListDeleteFailed": "حذف فهرست کار ممکن نیست: {error}", + "taskListCreateFailed": "ایجاد فهرست کار ممکن نیست: ⁨{error}⁩", + "taskListRenameFailed": "تغییر نام فهرست کار ممکن نیست: ⁨{error}⁩", + "taskListDeleteFailed": "حذف فهرست کار ممکن نیست: ⁨{error}⁩", "unshare": "لغو اشتراک‌گذاری", "readOnlyTaskListCannotRename": "این فهرست کار فقط‌خواندنی است و نمی‌توان نام آن را تغییر داد.", "taskListCannotDelete": "این فهرست کار با مجوزهای فعلی شما قابل حذف نیست.", - "deleteTaskListConfirmation": "‏«{title}» و همهٔ کارهای آن حذف شوند؟", - "unshareTaskListConfirmation": "اشتراک‌گذاری «{title}» از این حساب لغو شود؟", + "deleteTaskListConfirmation": "‏«⁨{title}⁩» و همهٔ کارهای آن حذف شوند؟", + "unshareTaskListConfirmation": "اشتراک‌گذاری «⁨{title}⁩» از این حساب لغو شود؟", "taskStatus": "وضعیت", "taskStatusNone": "بدون وضعیت", "taskStatusNeedsAction": "نیازمند اقدام", @@ -725,12 +736,12 @@ "repeatOnOrdinalSummary": "{position, select, first{در اولین {days}} second{در دومین {days}} third{در سومین {days}} fourth{در چهارمین {days}} fifth{در پنجمین {days}} secondToLast{در یکی‌مانده‌به‌آخرین {days}} last{در آخرین {days}} other{در {days}}}", "repeatInMonthsSummary": "در {months}", "repeatTimesSummary": "{count, plural, one{{count} بار} other{{count} بار}}", - "repeatUntilSummary": "تا {date}", + "repeatUntilSummary": "تا ⁨{date}⁩", "unsupportedRecurrencePreserved": "این قانون تکرار از گزینه‌هایی استفاده می‌کند که این ویرایشگر تغییر نمی‌دهد.", - "recurrenceUnsupportedByProvider": "این تکرار را نمی‌توان با {provider} استفاده کرد.", + "recurrenceUnsupportedByProvider": "این تکرار را نمی‌توان با ⁨{provider}⁩ استفاده کرد.", "duplicateTask": "تکرار کار", "taskDuplicated": "کار تکرار شد.", - "taskDuplicateFailed": "تکرار کار ممکن نیست: {error}", + "taskDuplicateFailed": "تکرار کار ممکن نیست: ⁨{error}⁩", "hideSubtasks": "پنهان کردن زیروظیفه‌ها", "hideClosedSubtasks": "پنهان کردن زیروظیفه‌های بسته‌شده", "reminderUnit": "واحد", @@ -770,7 +781,7 @@ "responseNeedsAction": "در انتظار پاسخ", "responseNotResponded": "بی‌پاسخ", "responseOrganizer": "برگزارکننده", - "invitationResponseFailed": "ارسال پاسخ شما ممکن نیست: {error}", + "invitationResponseFailed": "ارسال پاسخ شما ممکن نیست: ⁨{error}⁩", "@invitationResponseFailed": { "placeholders": { "error": { @@ -783,7 +794,7 @@ "setAction": "تنظیم", "removeFromMyCalendars": "حذف از تقویم‌های من", "removeAction": "حذف", - "removeCalendarConfirmation": "«{title}» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", + "removeCalendarConfirmation": "«⁨{title}⁩» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف یا جدا کرد.", "calendarPendingChangesPreventRemoval": "پیش از حذف یا جدا کردن این تقویم، صبر کنید تغییرات در انتظار آن همگام شوند.", "calendarSubscriptions": "اشتراک‌های تقویم", @@ -801,7 +812,7 @@ "subscriptionHourly": "ساعتی", "subscriptionSixHours": "هر شش ساعت", "subscriptionDaily": "روزانه", - "subscriptionSafeOrigin": "مبدأ: {origin}", + "subscriptionSafeOrigin": "مبدأ: ⁨{origin}⁩", "@subscriptionSafeOrigin": { "placeholders": { "origin": { @@ -812,7 +823,7 @@ "subscriptionSafeOriginUnavailable": "برای پیش‌نمایش مبدأ امن، یک URL معتبر وارد کنید.", "subscriptionReadOnly": "اشتراک فقط‌خواندنی", "subscriptionNeverRefreshed": "هنوز تازه نشده", - "subscriptionLastRefresh": "آخرین تازه‌سازی موفق: {time}", + "subscriptionLastRefresh": "آخرین تازه‌سازی موفق: ⁨{time}⁩", "@subscriptionLastRefresh": { "placeholders": { "time": { @@ -820,7 +831,7 @@ } } }, - "subscriptionNextRefresh": "تازه‌سازی بعدی: {time}", + "subscriptionNextRefresh": "تازه‌سازی بعدی: ⁨{time}⁩", "@subscriptionNextRefresh": { "placeholders": { "time": { @@ -829,7 +840,7 @@ } }, "subscriptionStatusHealthy": "به‌روز", - "subscriptionStatusIssue": "مشکل تازه‌سازی: {code}", + "subscriptionStatusIssue": "مشکل تازه‌سازی: ⁨{code}⁩", "@subscriptionStatusIssue": { "placeholders": { "code": { @@ -839,7 +850,7 @@ }, "refreshNow": "تازه‌سازی اکنون", "unsubscribe": "لغو اشتراک", - "unsubscribeCalendarTitle": "اشتراک «{name}» لغو شود؟", + "unsubscribeCalendarTitle": "اشتراک «⁨{name}⁩» لغو شود؟", "@unsubscribeCalendarTitle": { "placeholders": { "name": { @@ -849,7 +860,7 @@ }, "unsubscribeCalendarConfirmation": "این کار اشتراک محلی و رویدادهای ذخیره‌شدهٔ آن را حذف می‌کند. تقویم منتشرشده تغییر نمی‌کند.", "addSubscriptionAction": "افزودن اشتراک", - "subscriptionOperationFailed": "اشتراک تقویم ناموفق بود: {error}", + "subscriptionOperationFailed": "اشتراک تقویم ناموفق بود: ⁨{error}⁩", "@subscriptionOperationFailed": { "placeholders": { "error": { @@ -866,7 +877,8 @@ "@importEventsFound": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -874,11 +886,12 @@ "@importInvalidEvents": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, - "importFieldsOmitted": "عمداً حذف‌شده: {fields}", + "importFieldsOmitted": "عمداً حذف‌شده: ⁨{fields}⁩", "@importFieldsOmitted": { "placeholders": { "fields": { @@ -894,7 +907,8 @@ "@importQueued": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -902,7 +916,8 @@ "@importDuplicatesSkipped": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, @@ -910,11 +925,12 @@ "@importUnsupportedSets": { "placeholders": { "count": { - "type": "int" + "type": "int", + "format": "decimalPattern" } } }, - "importIcsFailed": "درون‌ریزی فایل تقویم ممکن نیست: {error}", + "importIcsFailed": "درون‌ریزی فایل تقویم ممکن نیست: ⁨{error}⁩", "@importIcsFailed": { "placeholders": { "error": { diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index b52e6ee7..18da5b5c 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,5 +1,11 @@ { "@@locale": "fi", + "repeatWeeklyDaySummary": "{dayKey, select, MO{maanantaisin} TU{tiistaisin} WE{keskiviikkoisin} TH{torstaisin} FR{perjantaisin} SA{lauantaisin} SU{sunnuntaisin} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "kuukauden {first} ja {second} päivänä", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} {firstDay} ja {secondDay} päivänä", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth} ja {secondMonth} {day} päivänä", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {firstDay} ja {secondDay} päivänä", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth} ja {secondMonth} {days} päivänä", "appTitle": "BusyMax", "connectGoogleAccount": "Yhdistä Google-, Microsoft-, Apple iCloud Calendar- tai Nextcloud-tilit.", "googlePermissionsConsentNotice": "Valitse Googlen käyttöoikeusnäkymässä sekä Kalenteri- että Tehtävät-käyttöoikeudet.", @@ -77,7 +83,7 @@ "showInSchedule": "Näytä aikataulussa", "noCalendarsSynced": "Kalentereita ei ole vielä synkronoitu.", "allDay": "Koko päivä", - "moreItems": "+{count} muuta", + "moreItems": "{count, plural, one{+1 muu} other{+{count} muuta}}", "noEventsOrTasks": "Ei tapahtumia tai tehtäviä", "scheduleLoading": "Ladataan aikataulua...", "scheduleUnavailable": "Aikataulu ei ole käytettävissä", @@ -656,8 +662,8 @@ "repeatEveryWeeks": "Joka {count}. viikko", "repeatEveryMonths": "Joka {count}. kuukausi", "repeatEveryYears": "Joka {count}. vuosi", - "repeatOnDaysSummary": "päivinä {days}", - "repeatOnMonthDaysSummary": "kuukauden päivinä {days}", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "kuukauden {days} päivänä", "repeatOnOrdinalSummary": "{position, select, first{ensimmäisenä {days}} second{toisena {days}} third{kolmantena {days}} fourth{neljäntenä {days}} fifth{viidentenä {days}} secondToLast{toiseksi viimeisenä {days}} last{viimeisenä {days}} other{päivinä {days}}}", "repeatInMonthsSummary": "{months} aikana", "repeatTimesSummary": "{count, plural, one{{count} kerran} other{{count} kertaa}}", @@ -872,7 +878,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "kuukauden päivinä {days}", + "repeatOnMonthDaysSummaryMultiple": "kuukauden {days} päivänä", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { @@ -885,7 +891,7 @@ "@repeatSummarySeparator": { "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." }, - "repeatMonthDayValue": "{day}", + "repeatMonthDayValue": "{day}.", "@repeatMonthDayValue": { "description": "Localized day-of-month value used when composing a list of recurrence days.", "placeholders": { @@ -903,11 +909,11 @@ "repeatYearlyMonthDayListStart": "{first}, {rest}", "repeatYearlyMonthListPair": "{first} ja {second}", "repeatYearlyMonthListStart": "{first}, {rest}", - "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day}. päivänä", - "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} {day}. päivänä", - "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} päivinä {days}", + "repeatYearlyOnMonthDaySummary": "{frequency} {month} {day} päivänä", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} {months} {day} päivänä", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} {months} {days} päivänä", "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {months} {position, select, first{ensimmäisenä} second{toisena} third{kolmantena} fourth{neljäntenä} fifth{viidentenä} secondToLast{toiseksi viimeisenä} last{viimeisenä} other{}} {days}", - "repeatYearlyOnMonthDaysSummary": "{frequency} {month} päivinä {days}", + "repeatYearlyOnMonthDaysSummary": "{frequency} {month} {days} päivänä", "@repeatYearlyOnMonthDaysSummary": { "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", "placeholders": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fb4e23d5..368f68fd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,5 +1,11 @@ { "@@locale": "fr", + "repeatWeeklyDaySummary": "{dayKey, select, MO{le lundi} TU{le mardi} WE{le mercredi} TH{le jeudi} FR{le vendredi} SA{le samedi} SU{le dimanche} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "les jours {first} et {second} du mois", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} les jours {firstDay} et {secondDay} de {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} le {day} de {firstMonth} et {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} les jours {firstDay} et {secondDay} de {firstMonth} et {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} les jours {days} de {firstMonth} et {secondMonth}", "appTitle": "BusyMax", "connectGoogleAccount": "Connectez les comptes Google, Microsoft, Calendrier Apple iCloud ou Nextcloud.", "googlePermissionsConsentNotice": "Sur l’écran d’autorisations Google, sélectionnez les autorisations Calendrier et Tâches.", @@ -45,7 +51,7 @@ }, "calendarManagementUnsupported": "Ce fournisseur ne prend pas en charge la gestion des calendriers dans BusyMax.", "primaryCalendarCannotDelete": "Le calendrier principal ne peut pas être supprimé.", - "calendarCreateFailed": "Impossible de créer le calendrier : {error}", + "calendarCreateFailed": "Impossible de créer le calendrier : {error}", "calendarCreatedRefreshPending": "Le calendrier a été créé, mais BusyMax n’a pas pu actualiser le compte. Il apparaîtra après la prochaine synchronisation.", "@calendarCreateFailed": { "placeholders": { @@ -54,7 +60,7 @@ } } }, - "calendarUpdateFailed": "Impossible de mettre à jour le calendrier : {error}", + "calendarUpdateFailed": "Impossible de mettre à jour le calendrier : {error}", "@calendarUpdateFailed": { "placeholders": { "error": { @@ -62,7 +68,7 @@ } } }, - "calendarDeleteFailed": "Impossible de supprimer le calendrier : {error}", + "calendarDeleteFailed": "Impossible de supprimer le calendrier : {error}", "@calendarDeleteFailed": { "placeholders": { "error": { @@ -267,11 +273,11 @@ "feedbackMessageLengthError": "Le message doit comporter entre 10 et 5 000 caractères.", "feedbackInvalidEmail": "Saisissez une adresse e-mail valide.", "feedbackConnectionError": "Impossible de se connecter à BusyStack. Vérifiez votre connexion et réessayez.", - "feedbackTimeoutError": "La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.", + "feedbackTimeoutError": "La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.", "feedbackRateLimitedError": "Trop de commentaires ont été envoyés depuis ce réseau. Patientez, puis réessayez.", "feedbackRejectedError": "Le serveur a rejeté l’envoi. Vérifiez les champs, puis réessayez.", - "feedbackServerError": "BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.", - "feedbackSuccess": "Commentaires envoyés. Référence : {id}", + "feedbackServerError": "BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.", + "feedbackSuccess": "Commentaires envoyés. Référence : {id}", "toggleSidebar": "Afficher/masquer la barre latérale", "showSidebar": "Afficher le panneau latéral", "hideSidebar": "Masquer le panneau latéral", @@ -328,7 +334,7 @@ } } }, - "exportFailed": "Échec de l’export : {error}", + "exportFailed": "Échec de l’export : {error}", "@exportFailed": { "placeholders": { "error": { @@ -336,7 +342,7 @@ } } }, - "refreshFailed": "Échec de l’actualisation : {error}", + "refreshFailed": "Échec de l’actualisation : {error}", "selectOrCreateTaskList": "Sélectionnez ou créez une liste de tâches pour commencer.", "signInToViewTasks": "Connectez-vous pour voir les tâches.", "noTasks": "Aucune tâche.", @@ -405,7 +411,7 @@ "moveToTop": "Déplacer tout en haut", "deleteTask": "Supprimer la tâche", "newSubtask": "Nouvelle sous-tâche", - "deleteTaskConfirmation": "Supprimer « {title} » ?", + "deleteTaskConfirmation": "Supprimer « {title} » ?", "metadata": "Métadonnées", "id": "ID", "etag": "ETag", @@ -429,7 +435,7 @@ "launchAtLoginFailed": "Impossible de modifier le lancement à la connexion.", "requiresTrayIcon": "Nécessite l’icône de la zone de notification.", "syncComplete": "Synchronisation terminée.", - "syncFailed": "Échec de la synchronisation : {error}", + "syncFailed": "Échec de la synchronisation : {error}", "notifySyncFailures": "Notifications d’échec de synchronisation", "notifyConflicts": "Notifications de conflits", "notifyDueToday": "Notifications des tâches dues aujourd’hui", @@ -460,7 +466,7 @@ "diagnostics": "Informations de diagnostic", "apiInspectorDisabled": "Afficher l’inspecteur API", "googleTasksApi": "API Google Tasks", - "discoveryRevision": "Révision Discovery : {revision}", + "discoveryRevision": "Révision Discovery : {revision}", "implementedMethods": "Méthodes implémentées", "supportsTasksScopes": "Prend en charge les champs d’application tasks et tasks.readonly", "requiresTasksScope": "Nécessite le champ d’application tasks", @@ -474,10 +480,10 @@ "retry": "Réessayer", "discard": "Abandonner", "discardChangesAction": "Abandonner", - "discardChanges": "Abandonner les modifications ?", + "discardChanges": "Abandonner les modifications ?", "discardChangesConfirmation": "Les modifications non enregistrées apportées à cette tâche seront perdues.", "retryCompleted": "Nouvelle tentative terminée.", - "discardPendingOperation": "Abandonner l’opération en attente ?", + "discardPendingOperation": "Abandonner l’opération en attente ?", "discardPendingOperationConfirmation": "Cette action supprime l’opération locale bloquée. Lors de la prochaine synchronisation, les données seront rechargées depuis Google Tasks.", "pendingOperationDiscarded": "Opération en attente abandonnée.", "syncFailureNotificationTitle": "Échec de la synchronisation BusyMax", @@ -559,7 +565,7 @@ "keepServerVersion": "Conserver la version du serveur", "reapplyLocalChange": "Examiner et réappliquer la modification locale", "duplicateLocalItem": "Dupliquer comme nouvel élément", - "deleteCalendarConfirmation": "Supprimer « {title} » ?", + "deleteCalendarConfirmation": "Supprimer « {title} » ?", "@deleteCalendarConfirmation": { "placeholders": { "title": { @@ -719,7 +725,7 @@ "repeatEveryWeeks": "Toutes les {count} semaines", "repeatEveryMonths": "Tous les {count} mois", "repeatEveryYears": "Toutes les {count} années", - "repeatOnDaysSummary": "les {days}", + "repeatOnDaysSummary": "{days}", "repeatOnMonthDaysSummary": "le {days} du mois", "repeatOnOrdinalSummary": "{position, select, first{le premier {days}} second{le deuxième {days}} third{le troisième {days}} fourth{le quatrième {days}} fifth{le cinquième {days}} secondToLast{l’avant-dernier {days}} last{le dernier {days}} other{les {days}}}", "repeatInMonthsSummary": "en {months}", @@ -782,7 +788,7 @@ "setAction": "Définir", "removeFromMyCalendars": "Retirer de mes agendas", "removeAction": "Retirer", - "removeCalendarConfirmation": "Retirer « {title} » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.", + "removeCalendarConfirmation": "Retirer « {title} » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.", "calendarCannotRemove": "Ce calendrier ne peut pas être supprimé ou retiré de ce compte.", "calendarPendingChangesPreventRemoval": "Attendez que les modifications en attente de ce calendrier aient fini de se synchroniser avant de le supprimer ou de le retirer.", "calendarSubscriptions": "Abonnements aux calendriers", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7566f637..7d703668 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,5 +1,11 @@ { "@@locale": "hi", + "repeatWeeklyDaySummary": "{dayKey, select, MO{सोमवार} TU{मंगलवार} WE{बुधवार} TH{गुरुवार} FR{शुक्रवार} SA{शनिवार} SU{रविवार} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "की {first} और {second} तारीखों को", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} की {firstDay} और {secondDay} तारीखों को", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth} और {secondMonth} की {day} तारीख को", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth} और {secondMonth} की {firstDay} और {secondDay} तारीखों को", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth} और {secondMonth} की {days} तारीखों को", "appTitle": "BusyMax", "connectGoogleAccount": "Google, Microsoft, Apple iCloud Calendar या Nextcloud खाते कनेक्ट करें।", "googlePermissionsConsentNotice": "Google अनुमति स्क्रीन पर, कैलेंडर और कार्य दोनों अनुमतियाँ चुनें।", @@ -29,7 +35,7 @@ "save": "सहेजें", "settings": "सेटिंग्स", "all": "सभी", - "calendarEvents": "ईवेंट", + "calendarEvents": "इवेंट", "calendarTasks": "कार्य", "calendar": "कैलेंडर", "calendars": "कैलेंडर", @@ -70,7 +76,7 @@ } } }, - "newEvent": "नया ईवेंट", + "newEvent": "नया इवेंट", "refreshCalendar": "कैलेंडर रीफ़्रेश करें", "openInProvider": "सेवा में खोलें", "hideFromSchedule": "शेड्यूल से छिपाएँ", @@ -78,14 +84,14 @@ "noCalendarsSynced": "अभी तक कोई कैलेंडर सिंक नहीं हुआ है।", "allDay": "पूरे दिन", "moreItems": "+{count} और", - "noEventsOrTasks": "कोई ईवेंट या कार्य नहीं", + "noEventsOrTasks": "कोई इवेंट या कार्य नहीं", "scheduleLoading": "शेड्यूल लोड हो रहा है...", "scheduleUnavailable": "शेड्यूल उपलब्ध नहीं है", "scheduleNoSources": "कोई दिखाई देने वाला कैलेंडर या कार्य सूची नहीं", "scheduleNoSourcesDescription": "सेटिंग्स में चुनें कि क्या दिखाना है, फिर रीफ़्रेश करें।", "scheduleSignInRequired": "खाता कनेक्ट करें", "scheduleSignInDescription": "कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।", - "scheduleNoSearchResults": "कोई मिलता-जुलता ईवेंट या कार्य नहीं", + "scheduleNoSearchResults": "कोई मिलता-जुलता इवेंट या कार्य नहीं", "scheduleNoSearchResultsDescription": "कोई दूसरी खोज आज़माएँ या मौजूदा फ़िल्टर हटाएँ।", "refresh": "रीफ़्रेश करें", "trayOpenBusyMax": "BusyMax खोलें", @@ -153,10 +159,10 @@ "sourceCalendar": "कैलेंडर", "sourceTaskList": "कार्य सूची", "createChoiceTitle": "बनाएँ", - "createEventAtTime": "ईवेंट", + "createEventAtTime": "इवेंट", "createTaskAtDate": "कार्य", - "editEvent": "ईवेंट संपादित करें", - "eventTitle": "ईवेंट का शीर्षक", + "editEvent": "इवेंट संपादित करें", + "eventTitle": "इवेंट का शीर्षक", "location": "स्थान", "timeSlot": "समयावधि", "startDateTime": "शुरू होने की तारीख/समय", @@ -215,8 +221,8 @@ "shortcutYearView": "वर्ष का दृश्य", "shortcutAgendaView": "कार्यसूची दृश्य", "shortcutGroupCreateAndEdit": "बनाएँ और संपादित करें", - "shortcutSaveItem": "ईवेंट या कार्य सहेजें", - "shortcutDeleteItem": "ईवेंट या कार्य मिटाएँ", + "shortcutSaveItem": "इवेंट या कार्य सहेजें", + "shortcutDeleteItem": "इवेंट या कार्य मिटाएँ", "shortcutGroupTaskEditing": "कार्य संपादन", "shortcutCancelEditing": "संपादन रद्द करें", "shortcutCancelEditingDescription": "कार्य संपादन या कार्य विवरण बंद करें", @@ -283,7 +289,7 @@ "builtInMicrosoftList": "अंतर्निहित", "builtInMicrosoftListCannotRenameDelete": "Microsoft To Do की अंतर्निहित सूचियों का नाम बदला या उन्हें मिटाया नहीं जा सकता।", "deleteListConfirmation": "Google Tasks से “{title}” हटाएँ?", - "deleteEvent": "ईवेंट मिटाएँ", + "deleteEvent": "इवेंट मिटाएँ", "title": "शीर्षक", "create": "बनाएँ", "newTask": "नया कार्य", @@ -391,7 +397,7 @@ "notifySyncFailures": "सिंक विफल होने की सूचनाएँ", "notifyConflicts": "टकराव की सूचनाएँ", "notifyDueToday": "आज देय कार्यों की सूचनाएँ", - "eventReminders": "ईवेंट रिमाइंडर", + "eventReminders": "इवेंट रिमाइंडर", "onState": "चालू", "taskReminders": "कार्य रिमाइंडर", "notificationDetailLevel": "सूचनाओं के विवरण का स्तर", @@ -444,9 +450,9 @@ "conflictNotificationBody": "लंबित स्थानीय बदलाव अवरुद्ध किया गया। {summary}", "dueTodayNotificationTitle": "आज देय कार्य", "dueTodayNotificationBody": "{count, plural, =1{आज एक कार्य देय है।} other{आज {count} कार्य देय हैं।}}", - "eventReminderNotificationTitle": "ईवेंट रिमाइंडर", + "eventReminderNotificationTitle": "इवेंट रिमाइंडर", "taskReminderNotificationTitle": "कार्य रिमाइंडर", - "eventReminderNotificationBody": "ईवेंट जल्द शुरू होगा।", + "eventReminderNotificationBody": "इवेंट जल्द शुरू होगा।", "taskReminderNotificationBody": "कार्य जल्द देय है।", "notificationOpenAction": "खोलें", "notificationSnoozeAction": "10 मिनट के लिए स्नूज़ करें", @@ -657,7 +663,7 @@ "repeatEveryMonths": "हर {count} महीने", "repeatEveryYears": "हर {count} साल", "repeatOnDaysSummary": "{days} को", - "repeatOnMonthDaysSummary": "के {days}वें दिन", + "repeatOnMonthDaysSummary": "की {days} तारीख को", "repeatOnOrdinalSummary": "{position, select, first{के पहले {days} को} second{के दूसरे {days} को} third{के तीसरे {days} को} fourth{के चौथे {days} को} fifth{के पाँचवें {days} को} secondToLast{के अंतिम से दूसरे {days} को} last{के अंतिम {days} को} other{के {days} को}}", "repeatInMonthsSummary": "{months} में", "repeatTimesSummary": "{count, plural, one{{count} बार} other{{count} बार}}", @@ -872,7 +878,7 @@ } } }, - "repeatOnMonthDaysSummaryMultiple": "के {days}वें दिन", + "repeatOnMonthDaysSummaryMultiple": "की {days} तारीखों को", "@repeatOnMonthDaysSummaryMultiple": { "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", "placeholders": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6435ce21..33f79071 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,5 +1,11 @@ { "@@locale": "it", + "repeatWeeklyDaySummary": "{dayKey, select, MO{il lunedì} TU{il martedì} WE{il mercoledì} TH{il giovedì} FR{il venerdì} SA{il sabato} SU{la domenica} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "nei giorni {first} e {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} nei giorni {firstDay} e {secondDay} di {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} il giorno {day} di {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} nei giorni {firstDay} e {secondDay} di {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} nei giorni {days} di {firstMonth} e {secondMonth}", "appTitle": "BusyMax", "connectGoogleAccount": "Collega gli account Google, Microsoft, Calendario Apple iCloud o Nextcloud.", "googlePermissionsConsentNotice": "Nella schermata delle autorizzazioni di Google, seleziona sia l’autorizzazione per il calendario sia quella per le attività.", @@ -656,7 +662,7 @@ "repeatEveryWeeks": "Ogni {count} settimane", "repeatEveryMonths": "Ogni {count} mesi", "repeatEveryYears": "Ogni {count} anni", - "repeatOnDaysSummary": "il {days}", + "repeatOnDaysSummary": "{days}", "repeatOnMonthDaysSummary": "il giorno {days}", "repeatOnOrdinalSummary": "{position, select, first{il primo {days}} second{il secondo {days}} third{il terzo {days}} fourth{il quarto {days}} fifth{il quinto {days}} secondToLast{il penultimo {days}} last{l’ultimo {days}} other{nei giorni {days}}}", "repeatInMonthsSummary": "nei mesi {months}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3f08f290..e5cada59 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,5 +1,11 @@ { "@@locale": "ja", + "repeatWeeklyDaySummary": "{dayKey, select, MO{月曜日} TU{火曜日} WE{水曜日} TH{木曜日} FR{金曜日} SA{土曜日} SU{日曜日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}の{firstDay}と{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}と{secondMonth}の{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}と{secondMonth}の{firstDay}と{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}と{secondMonth}の{days}", "appTitle": "BusyMax", "connectGoogleAccount": "Google、Microsoft、Apple iCloud Calendar、または Nextcloud のアカウントを接続します。", "googlePermissionsConsentNotice": "Google の権限画面で、カレンダーとタスクの両方の権限を選択してください。", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 4239112e..3a99f899 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,5 +1,11 @@ { "@@locale": "ko", + "repeatWeeklyDaySummary": "{dayKey, select, MO{월요일} TU{화요일} WE{수요일} TH{목요일} FR{금요일} SA{토요일} SU{일요일} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}과 {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month}의 {firstDay}과 {secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} {firstMonth}과 {secondMonth}의 {day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} {firstMonth}과 {secondMonth}의 {firstDay}과 {secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} {firstMonth}과 {secondMonth}의 {days}", "appTitle": "BusyMax", "connectGoogleAccount": "Google, Microsoft, Apple iCloud Calendar 또는 Nextcloud 계정을 연결하세요.", "googlePermissionsConsentNotice": "Google 권한 화면에서 캘린더와 할 일 권한을 모두 선택하세요.", @@ -164,7 +170,7 @@ "doesNotRepeat": "반복 안 함", "defaultReminder": "기본 미리 알림", "guests": "참석자", - "noGuests": "게스트 없음", + "noGuests": "참석자 없음", "description": "설명", "availabilityShowAs": "일정 상태 / 표시 방식", "busy": "바쁨", @@ -677,24 +683,24 @@ "addTeamsMeeting": "Microsoft Teams 회의 추가", "onlineMeetingAdded": "온라인 회의가 추가됨", "requestResponses": "응답 요청", - "requestResponsesDescription": "게스트에게 초대에 응답하도록 요청합니다.", - "hideGuestList": "게스트 목록 숨기기", - "hideGuestListDescription": "게스트는 다른 초대 대상자를 볼 수 없습니다.", + "requestResponsesDescription": "참석자에게 초대에 응답하도록 요청합니다.", + "hideGuestList": "참석자 목록 숨기기", + "hideGuestListDescription": "참석자는 다른 초대 대상자를 볼 수 없습니다.", "allowNewTimeProposals": "새 시간 제안 허용", - "allowNewTimeProposalsDescription": "게스트가 다른 회의 시간을 제안할 수 있습니다.", - "notifyGuestsTitle": "게스트에게 알릴까요?", - "notifyGuestsSaveMessage": "이 회의에는 게스트가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?", - "notifyGuestsDeleteMessage": "이 회의에는 게스트가 있습니다. 삭제할 때 취소를 보낼까요?", + "allowNewTimeProposalsDescription": "참석자가 다른 회의 시간을 제안할 수 있습니다.", + "notifyGuestsTitle": "참석자에게 알릴까요?", + "notifyGuestsSaveMessage": "이 회의에는 참석자가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?", + "notifyGuestsDeleteMessage": "이 회의에는 참석자가 있습니다. 삭제할 때 취소를 보낼까요?", "sendUpdates": "업데이트 보내기", "sendCancellation": "취소 보내기", "doNotSend": "보내지 않음", "microsoftNotifyGuestsSaveTitle": "회의를 저장할까요?", - "microsoftNotifyGuestsSaveMessage": "Microsoft에서 게스트에게 초대 또는 일정 업데이트를 보냅니다.", + "microsoftNotifyGuestsSaveMessage": "Microsoft에서 참석자에게 초대 또는 일정 업데이트를 보냅니다.", "microsoftNotifyGuestsDeleteTitle": "회의를 삭제할까요?", - "microsoftNotifyGuestsDeleteMessage": "Microsoft에서 게스트에게 취소를 보냅니다.", + "microsoftNotifyGuestsDeleteMessage": "Microsoft에서 참석자에게 취소를 보냅니다.", "organizer": "주최자", "yourResponse": "내 응답", - "guestResponses": "게스트 응답", + "guestResponses": "참석자 응답", "respond": "응답", "acceptInvitation": "수락", "tentativeInvitation": "미정", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 694d354f..66838741 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,929 +1,3 @@ { - "@@locale": "pt", - "appTitle": "BusyMax", - "connectGoogleAccount": "Ligue as contas Google, Microsoft, Calendário Apple iCloud ou Nextcloud.", - "googlePermissionsConsentNotice": "No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.", - "googlePermissionsRequiredRetry": "As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.", - "finishSetup": "Concluir configuração", - "continueSetup": "Continuar", - "onboardingSetupTitle": "Configurar o BusyMax", - "onboardingAccountsStepTitle": "Ligar contas", - "onboardingAccountsStepDescription": "Adicione todas as contas que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas suportados de cada conta.", - "onboardingPreferencesStepTitle": "Escolher definições do sistema", - "onboardingPreferencesStepDescription": "Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.", - "signInWithGoogle": "Iniciar sessão com a Google", - "signInWithMicrosoft": "Iniciar sessão com a Microsoft", - "googleTasksProvider": "Google Tasks", - "microsoftTodoProvider": "Microsoft To Do", - "providerNotConfigured": "Este fornecedor não está configurado.", - "waitingForGoogleSignIn": "A aguardar o início de sessão da Google...", - "waitingForMicrosoftSignIn": "A aguardar o início de sessão da Microsoft...", - "microsoftSignInNotConfigured": "O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.", - "cancel": "Cancelar", - "close": "Fechar", - "exit": "Sair", - "options": "Opções", - "hide": "Ocultar", - "show": "Mostrar", - "export": "Exportar", - "save": "Guardar", - "settings": "Definições", - "all": "Tudo", - "calendarEvents": "Eventos", - "calendarTasks": "Tarefas", - "calendar": "Calendário", - "calendars": "Calendários", - "newCalendar": "Novo calendário", - "calendarColor": "Cor do calendário", - "calendarColorOption": "Cor {number}", - "@calendarColorOption": { - "placeholders": { - "number": { - "type": "int" - } - } - }, - "calendarManagementUnsupported": "Este fornecedor não suporta a gestão de calendários no BusyMax.", - "primaryCalendarCannotDelete": "O calendário principal não pode ser eliminado.", - "calendarCreateFailed": "Não foi possível criar o calendário: {error}", - "calendarCreatedRefreshPending": "O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Será apresentado após a próxima sincronização.", - "@calendarCreateFailed": { - "placeholders": { - "error": { - "type": "String" - } - } - }, - "calendarUpdateFailed": "Não foi possível atualizar o calendário: {error}", - "@calendarUpdateFailed": { - "placeholders": { - "error": { - "type": "String" - } - } - }, - "calendarDeleteFailed": "Não foi possível eliminar o calendário: {error}", - "@calendarDeleteFailed": { - "placeholders": { - "error": { - "type": "String" - } - } - }, - "newEvent": "Novo evento", - "refreshCalendar": "Atualizar calendário", - "openInProvider": "Abrir no serviço", - "hideFromSchedule": "Ocultar da agenda", - "showInSchedule": "Mostrar na agenda", - "noCalendarsSynced": "Ainda não há calendários sincronizados.", - "allDay": "Todo o dia", - "moreItems": "+{count} mais", - "noEventsOrTasks": "Sem eventos ou tarefas", - "scheduleLoading": "A carregar agenda...", - "scheduleUnavailable": "Agenda indisponível", - "scheduleNoSources": "Sem calendários ou listas de tarefas visíveis", - "scheduleNoSourcesDescription": "Escolha o que pretende mostrar nas Definições e atualize a agenda.", - "scheduleSignInRequired": "Ligar uma conta", - "scheduleSignInDescription": "Inicie sessão para sincronizar calendários e tarefas.", - "scheduleNoSearchResults": "Nenhum evento ou tarefa correspondente", - "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", - "refresh": "Atualizar", - "trayOpenBusyMax": "Abrir o BusyMax", - "trayShowBusyMax": "Mostrar o BusyMax", - "trayNewEvent": "Novo evento…", - "trayNewTask": "Nova tarefa…", - "trayToday": "Hoje", - "trayAllDay": "Todo o dia", - "trayNow": "Agora", - "trayCalendarEvent": "Evento do calendário", - "trayUntitledEvent": "Evento sem título", - "trayNothingElseToday": "Nada mais hoje", - "trayTasksDueToday": "{count, plural, =1{1 tarefa termina hoje} other{{count} tarefas terminam hoje}}", - "@trayTasksDueToday": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trayOpenTodayAgenda": "Abrir a agenda de hoje", - "traySyncNow": "Sincronizar agora", - "traySyncing": "A sincronizar…", - "trayNotConnected": "Não ligado", - "trayNotYetSynced": "Ainda não sincronizado", - "trayLastSyncedJustNow": "Sincronizado agora mesmo", - "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizado há 1 minuto} other{Sincronizado há {count} minutos}}", - "@trayLastSyncedMinutesAgo": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizado há 1 hora} other{Sincronizado há {count} horas}}", - "@trayLastSyncedHoursAgo": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizado há 1 dia} other{Sincronizado há {count} dias}}", - "@trayLastSyncedDaysAgo": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "traySettings": "Definições", - "trayQuitBusyMax": "Sair do BusyMax", - "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", - "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", - "viewDay": "Dia", - "viewWeek": "Semana", - "viewMonth": "Mês", - "viewYear": "Ano", - "viewAgenda": "Vista de agenda", - "scheduleSettings": "Agenda", - "scheduleDisplaySettings": "Apresentação da agenda", - "scheduleDisplayHoursDescription": "As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.", - "scheduleDayStartsAt": "O dia começa às", - "scheduleDayEndsAt": "O dia termina às", - "sourceCalendar": "Calendário", - "sourceTaskList": "Lista de tarefas", - "createChoiceTitle": "Criar", - "createEventAtTime": "Evento", - "createTaskAtDate": "Tarefa", - "editEvent": "Editar evento", - "eventTitle": "Título do evento", - "location": "Local", - "timeSlot": "Intervalo de tempo", - "startDateTime": "Data e hora de início", - "endDateTime": "Data e hora de fim", - "doesNotRepeat": "Não se repete", - "defaultReminder": "Lembrete predefinido", - "guests": "Convidados", - "noGuests": "Sem convidados", - "description": "Descrição", - "availabilityShowAs": "Disponibilidade / Mostrar como", - "busy": "Ocupado", - "visibility": "Visibilidade", - "defaultVisibility": "Visibilidade predefinida", - "conference": "Conferência", - "noConference": "Sem conferência", - "providerCalendar": "Calendário do fornecedor", - "formatBoldShortLabel": "N", - "formatBoldTooltip": "Negrito", - "formatItalicShortLabel": "I", - "formatItalicTooltip": "Itálico", - "formatUnderlineShortLabel": "S", - "formatUnderlineTooltip": "Sublinhado", - "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", - "reminderAtStart": "À hora de início", - "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", - "reminderDaysBefore": "{days, plural, =1{1 dia antes} other{{days} dias antes}}", - "availabilityFree": "Livre", - "availabilityTentative": "Provisório", - "availabilityOutOfOffice": "Fora do escritório", - "availabilityWorkingElsewhere": "A trabalhar noutro local", - "visibilityDefault": "Predefinida", - "visibilityPublic": "Pública", - "visibilityPrivate": "Privada", - "visibilityConfidential": "Confidencial", - "sensitivityNormal": "Habitual", - "sensitivityPersonal": "Pessoal", - "tasks": "Tarefas", - "allTasks": "Todas as tarefas", - "tasksInList": "Tarefas em {title}", - "taskLists": "Listas de tarefas", - "navigation": "Navegação", - "mainMenu": "Menu principal", - "keyboardShortcuts": "Atalhos de teclado", - "shortcutGroupGeneral": "Geral", - "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos", - "shortcutGroupNavigation": "Navegação", - "shortcutNextPeriod": "Período seguinte", - "shortcutNextPeriodDescription": "Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante", - "shortcutPreviousPeriod": "Período anterior", - "shortcutPreviousPeriodDescription": "Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante", - "shortcutJumpToToday": "Ir para hoje", - "shortcutGroupView": "Vista", - "shortcutDayView": "Vista diária", - "shortcutWeekView": "Vista semanal", - "shortcutMonthView": "Vista mensal", - "shortcutYearView": "Vista anual", - "shortcutAgendaView": "Vista de agenda", - "shortcutGroupCreateAndEdit": "Criar e editar", - "shortcutSaveItem": "Guardar evento ou tarefa", - "shortcutDeleteItem": "Eliminar evento ou tarefa", - "shortcutGroupTaskEditing": "Edição de tarefas", - "shortcutCancelEditing": "Cancelar edição", - "shortcutCancelEditingDescription": "Fechar a edição ou os detalhes da tarefa", - "aboutBusyMax": "Acerca do BusyMax", - "aboutBusyMaxDescription": "Calendário e tarefas", - "license": "Licença", - "apacheLicenseName": "Apache License 2.0", - "website": "Site", - "sourceCode": "Código-fonte", - "reportAnIssue": "Comunicar um problema", - "sendFeedback": "Enviar comentários", - "feedbackSubmit": "Enviar", - "feedbackCategory": "Categoria", - "feedbackSelectCategory": "Selecione uma categoria", - "feedbackCategoryProblem": "Problema ou erro", - "feedbackCategoryFeature": "Pedido de funcionalidade", - "feedbackCategoryPrivacySecurity": "Questão de privacidade ou segurança", - "feedbackCategoryUsability": "Problema de utilização", - "feedbackCategoryOther": "Outro", - "feedbackSubject": "Assunto", - "feedbackDetailedMessage": "Mensagem detalhada", - "feedbackReplyEmail": "Endereço de e-mail para resposta (opcional)", - "feedbackIncludeTechnicalDetails": "Incluir detalhes técnicos", - "feedbackTechnicalDetailsDisclosure": "Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", - "feedbackCategoryRequired": "Selecione uma categoria.", - "feedbackSubjectLengthError": "O assunto deve ter entre 3 e 120 carateres.", - "feedbackMessageLengthError": "A mensagem deve ter entre 10 e 5 000 carateres.", - "feedbackInvalidEmail": "Introduza um endereço de e-mail válido.", - "feedbackConnectionError": "Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.", - "feedbackTimeoutError": "O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.", - "feedbackRateLimitedError": "Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.", - "feedbackRejectedError": "O servidor rejeitou o envio. Reveja os campos e tente novamente.", - "feedbackServerError": "O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.", - "feedbackSuccess": "Comentários enviados. Referência: {id}", - "toggleSidebar": "Mostrar ou ocultar a barra lateral", - "showSidebar": "Mostrar painel lateral", - "hideSidebar": "Ocultar painel lateral", - "accounts": "Contas", - "currentAccount": "Conta atual", - "switchAccount": "Mudar de conta", - "addGoogleAccount": "Adicionar conta Google", - "addMicrosoftAccount": "Adicionar conta Microsoft", - "googleProvider": "Google", - "microsoftProvider": "Microsoft", - "signedInAccount": "Sessão iniciada", - "removeAccount": "Remover conta…", - "removingAccount": "A remover conta…", - "removeAccountDescription": "Parar a sincronização e remover os dados desta conta deste dispositivo.", - "removeAccountTitle": "Remover {account} do BusyMax?", - "removeAccountConfirmation": "Esta ação elimina deste dispositivo as tarefas, calendários, eventos, lembretes e alterações offline pendentes colocados em cache. As alterações não sincronizadas serão perdidas. As cópias dos calendários, eventos, listas de tarefas e tarefas no fornecedor não são eliminadas.", - "revokeGoogleAccess": "Revogar também o acesso do BusyMax a esta conta Google", - "revokeGoogleAccessDescription": "Terá de conceder acesso novamente antes de voltar a ligar a conta.", - "removeAccountAction": "Remover conta", - "removeAccountFailed": "Não foi possível concluir a remoção da conta. Tente novamente.", - "accountRemovedGoogleRevokeFailed": "A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.", - "newTaskList": "Nova lista de tarefas", - "signInToViewTaskLists": "Inicie sessão para ver as listas de tarefas.", - "noTaskListsSynced": "Ainda não há listas de tarefas sincronizadas.", - "listActions": "Ações da lista", - "rename": "Mudar o nome", - "delete": "Eliminar", - "renameList": "Mudar o nome da lista", - "deleteList": "Eliminar lista", - "builtInMicrosoftList": "Incorporada", - "builtInMicrosoftListCannotRenameDelete": "As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.", - "deleteListConfirmation": "Eliminar “{title}” do Google Tasks?", - "deleteEvent": "Eliminar evento", - "title": "Título", - "create": "Criar", - "newTask": "Nova tarefa", - "clearCompleted": "Limpar tarefas concluídas", - "refreshList": "Atualizar lista", - "refreshAll": "Atualizar tudo", - "listRefreshed": "Lista atualizada.", - "allTasksRefreshed": "Todas as contas foram atualizadas.", - "exportedFile": "Exportado para {path}", - "exportFailed": "Falha ao exportar: {error}", - "refreshFailed": "Falha ao atualizar: {error}", - "selectOrCreateTaskList": "Selecione ou crie uma lista de tarefas para começar.", - "signInToViewTasks": "Inicie sessão para ver as tarefas.", - "noTasks": "Sem tarefas.", - "noTasksYet": "Ainda não há tarefas", - "noTasksYetMessage": "Crie uma tarefa ou atualize as suas contas para começar.", - "noTasksInList": "Não há tarefas nesta lista.", - "overdue": "Em atraso", - "today": "Hoje", - "tomorrow": "Amanhã", - "upcoming": "Próximas", - "noDate": "Sem data", - "completed": "Concluídas", - "duePrefix": "Prazo: {date}", - "dateTimeDisplay": "{date}, {time}", - "taskDetails": "Detalhes da tarefa", - "editTask": "Editar tarefa", - "noTaskSelected": "Nenhuma tarefa selecionada.", - "noTaskSelectedHelper": "Selecione uma tarefa para ver e editar os detalhes.", - "taskUnavailable": "Tarefa indisponível.", - "signInToEditTasks": "Inicie sessão para editar tarefas.", - "refreshTask": "Atualizar tarefa", - "primarySection": "Principal", - "statusSection": "Estado", - "openStatus": "Aberta", - "doneStatus": "Concluída", - "notes": "Notas", - "dueDate": "Data limite", - "clearDueDate": "Limpar data limite", - "dueTime": "Hora limite", - "startDate": "Data de início", - "startTime": "Hora de início", - "endDate": "Data de fim", - "endTime": "Hora de fim", - "reminderDate": "Data do lembrete", - "reminderTime": "Hora do lembrete", - "reminder": "Lembrete", - "addReminder": "Adicionar lembrete", - "addGuest": "Adicionar convidado", - "addGuestEmail": "Adicionar e-mail do convidado", - "removeReminder": "Remover lembrete", - "off": "Desativado", - "repeat": "Repetição", - "repeatNone": "Não repetir", - "noneValue": "Nenhum", - "repeatDaily": "Diariamente", - "repeatWeekly": "Semanalmente", - "repeatMonthly": "Mensalmente", - "repeatYearly": "Anualmente", - "importance": "Importância", - "importanceLow": "Baixa", - "importanceNormal": "Média", - "importanceHigh": "Alta", - "categories": "Categorias", - "scheduleSection": "Agenda", - "dueGroup": "Prazo", - "startGroup": "Início", - "reminderGroup": "Lembrete", - "organizationSection": "Organização", - "actionsSection": "Ações", - "advancedSection": "Avançado", - "addCategory": "Adicionar categoria", - "list": "Lista", - "microsoftMoveUnsupported": "Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.", - "createSubtask": "Criar subtarefa", - "subtasks": "Subtarefas", - "moveToTop": "Mover para o início", - "deleteTask": "Eliminar tarefa", - "newSubtask": "Nova subtarefa", - "deleteTaskConfirmation": "Eliminar «{title}»?", - "metadata": "Metadados", - "id": "ID", - "etag": "ETag", - "updated": "Atualizado", - "parent": "Tarefa principal", - "position": "Posição", - "webLink": "Ligação Web", - "assignment": "Atribuição", - "localState": "Estado local", - "pendingSync": "Sincronização pendente", - "synced": "Sincronizado", - "account": "Conta", - "sync": "Sincronização", - "forceFullResync": "Forçar nova sincronização completa", - "forceFullResyncDescription": "Recarrega completamente os dados de todas as contas ligadas. Utilize esta opção apenas para resolver problemas de sincronização.", - "runInBackgroundWhenClosed": "Continuar em execução quando a janela for fechada", - "showTrayIcon": "Mostrar ícone na área de notificação", - "startMinimizedToTray": "Iniciar minimizado na área de notificação", - "launchAtLogin": "Iniciar ao entrar", - "launchAtLoginDescription": "Inicie o BusyMax em segundo plano para que os lembretes funcionem após entrar.", - "launchAtLoginFailed": "Não foi possível atualizar o início de sessão.", - "requiresTrayIcon": "Requer o ícone da área de notificação.", - "syncComplete": "Sincronização concluída.", - "syncFailed": "Falha na sincronização: {error}", - "notifySyncFailures": "Notificações de falhas de sincronização", - "notifyConflicts": "Notificações de conflitos", - "notifyDueToday": "Notificações de tarefas com prazo para hoje", - "eventReminders": "Lembretes de eventos", - "onState": "Ativado", - "taskReminders": "Lembretes de tarefas", - "notificationDetailLevel": "Nível de detalhe das notificações", - "notificationDetailPrivate": "Privado", - "notificationDetailNormal": "Predefinido", - "quietHours": "Período de silêncio", - "quietHoursDescription": "Pausar as notificações durante este período.", - "quietHoursStart": "Início do período de silêncio", - "quietHoursEnd": "Fim do período de silêncio", - "notifications": "Notificações", - "appearance": "Aspeto", - "theme": "Tema", - "themeSystem": "Sistema", - "settingsSystem": "Sistema", - "themeLight": "Claro", - "themeDark": "Escuro", - "themeFamily": "Família de temas", - "themeFamilyYaru": "Tema nativo do Ubuntu (Yaru)", - "localization": "Localização", - "currentLocale": "Configuração regional atual", - "privacy": "Privacidade", - "redactTaskContentInDiagnostics": "Ocultar o conteúdo das tarefas nos diagnósticos", - "developerDiagnostics": "Diagnósticos de programador", - "diagnostics": "Diagnósticos", - "apiInspectorDisabled": "Mostrar inspetor da API", - "googleTasksApi": "API do Google Tasks", - "discoveryRevision": "Revisão de descoberta: {revision}", - "implementedMethods": "Métodos implementados", - "supportsTasksScopes": "Suporta os âmbitos de autorização tasks e tasks.readonly", - "requiresTasksScope": "Requer o âmbito de autorização tasks", - "blockedPendingOperations": "Operações pendentes bloqueadas", - "signInToInspectPendingOperations": "Inicie sessão para inspecionar as operações pendentes.", - "noBlockedPendingOperations": "Não há operações pendentes bloqueadas.", - "operationActions": "Ações da operação", - "pendingOpListId": "lista={id}", - "pendingOpTaskId": "tarefa={id}", - "pendingOpAttempts": "tentativas={count}", - "retry": "Tentar novamente", - "discard": "Descartar", - "discardChangesAction": "Descartar", - "discardChanges": "Descartar alterações?", - "discardChangesConfirmation": "Esta ação descarta as alterações não guardadas nesta tarefa.", - "retryCompleted": "Nova tentativa concluída.", - "discardPendingOperation": "Descartar operação pendente?", - "discardPendingOperationConfirmation": "Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.", - "pendingOperationDiscarded": "Operação pendente descartada.", - "syncFailureNotificationTitle": "Falha na sincronização do BusyMax", - "syncFailureNotificationBody": "A sincronização em segundo plano falhou. {message}", - "conflictNotificationTitle": "Conflito de sincronização do BusyMax", - "conflictNotificationBody": "Uma alteração local pendente foi bloqueada. {summary}", - "dueTodayNotificationTitle": "Tarefas com prazo para hoje", - "dueTodayNotificationBody": "{count, plural, =1{Há uma tarefa com prazo para hoje.} other{Há {count} tarefas com prazo para hoje.}}", - "eventReminderNotificationTitle": "Lembrete de evento", - "taskReminderNotificationTitle": "Lembrete de tarefa", - "eventReminderNotificationBody": "O evento começa em breve.", - "taskReminderNotificationBody": "O prazo da tarefa aproxima-se.", - "notificationOpenAction": "Abrir", - "notificationSnoozeAction": "Adiar 10 minutos", - "notificationDismissAction": "Fechar", - "notificationDetailsHidden": "Os detalhes estão ocultos pelas definições de privacidade.", - "previousMonth": "Mês anterior", - "nextMonth": "Mês seguinte", - "openMonthView": "Abrir vista mensal", - "previousYear": "Ano anterior", - "nextYear": "Ano seguinte", - "openYearView": "Abrir vista anual", - "weekNumberTooltip": "Semana {number}", - "resizeAllDayPanel": "Redimensionar o painel de dia inteiro", - "scheduleItemCount": "{count, plural, =1{1 item} other{{count} itens}}", - "readOnlyCalendar": "Este calendário é só de leitura.", - "selectTimeZone": "Selecionar fuso horário", - "searchLocations": "Pesquisar locais", - "noLocationsFound": "Não foram encontrados locais", - "requiredField": "Este campo é obrigatório.", - "providerConnectionDescription": "Ligue calendários e tarefas a um destes fornecedores.", - "appleICloudProvider": "Calendário Apple iCloud", - "nextcloudProvider": "Nextcloud", - "appleICloudTasksProvider": "Apple iCloud", - "nextcloudTasksProvider": "Tarefas do Nextcloud", - "addAppleICloudAccount": "Adicionar conta do Calendário Apple iCloud", - "addNextcloudAccount": "Adicionar conta Nextcloud", - "waitingForAppleICloud": "A ligar ao Apple iCloud…", - "waitingForNextcloud": "A aguardar autorização do Nextcloud…", - "connectAppleICloudTitle": "Ligar o Calendário Apple iCloud", - "appleAccountEmail": "E-mail da conta Apple", - "appleAppSpecificPassword": "Palavra-passe específica da aplicação", - "appleAppSpecificPasswordHelp": "Crie uma palavra-passe específica da aplicação depois de ativar a autenticação de dois fatores na sua conta Apple.", - "appleAppSpecificPasswordResetWarning": "A reposição da palavra-passe da conta Apple revoga as palavras-passe específicas de aplicações.", - "connectNextcloudTitle": "Ligar ao Nextcloud", - "nextcloudServerUrl": "Servidor Nextcloud ou endereço CalDAV", - "nextcloudServerUrlHelp": "Introduza o URL do servidor Nextcloud ou cole o endereço CalDAV principal copiado do Nextcloud.", - "nextcloudBrowserAuthorizationHelp": "O BusyMax abrirá o navegador. Aprove o acesso aí e volte ao BusyMax.", - "connectAccountAction": "Ligar", - "cancelAccountConnection": "Cancelar ligação", - "nextcloudAccountRemovedRevokeFailed": "A conta foi removida localmente, mas não foi possível revogar a palavra-passe da aplicação Nextcloud.", - "davCachedOfflineNotice": "Os dados de calendários e tarefas são colocados em cache localmente para utilização offline.", - "davReauthenticationRequired": "Volte a ligar esta conta para retomar a sincronização.", - "davTemporarilyUnavailable": "Esta conta está temporariamente indisponível.", - "davPermissionChanged": "As permissões do servidor foram alteradas. As edições pendentes estão em pausa.", - "davUnsupportedServer": "Este servidor ou perfil do fornecedor não é suportado.", - "collectionSettings": "Calendários e listas de tarefas", - "calendarContent": "Eventos do calendário", - "taskContent": "Tarefas", - "readOnlySharedCollection": "Só de leitura", - "pendingLocally": "Pendente localmente", - "conflictBlocked": "Bloqueado por conflito", - "authenticationBlocked": "Bloqueado até voltar a ligar", - "operationFailed": "Operação falhou", - "keepServerVersion": "Manter versão do servidor", - "reapplyLocalChange": "Rever e reaplicar alteração local", - "duplicateLocalItem": "Duplicar como novo item", - "deleteCalendarConfirmation": "Eliminar «{title}»?", - "davConnectionState": "Estado da ligação", - "davConnected": "Ligado", - "davConnecting": "A ligar…", - "davSignedOut": "Sessão terminada", - "davLastSuccessfulSync": "Última sincronização bem-sucedida: {time}", - "@davLastSuccessfulSync": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "davNeverSynced": "Ainda não sincronizado", - "refreshCollections": "Atualizar calendários e listas de tarefas", - "nextcloudServerHost": "Servidor: {host}", - "@nextcloudServerHost": { - "placeholders": { - "host": { - "type": "String" - } - } - }, - "collectionSupportsEvents": "Calendário de eventos", - "collectionSupportsTasks": "Lista de tarefas", - "collectionSupportsEventsAndTasks": "Eventos e tarefas", - "writableCollection": "Editável", - "sharedCollection": "Partilhado", - "collectionLastSynced": "Última sincronização: {time}", - "@collectionLastSynced": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "collectionSyncError": "Problema de sincronização: {code}", - "@collectionSyncError": { - "placeholders": { - "code": { - "type": "String" - } - } - }, - "syncConflicts": "Conflitos de sincronização", - "remoteChangedAt": "Alteração no servidor: {time}", - "@remoteChangedAt": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "localPendingEdit": "Edição local: {summary}", - "@localPendingEdit": { - "placeholders": { - "summary": { - "type": "String" - } - } - }, - "conflictResolutionFailed": "Não foi possível resolver o conflito.", - "recurringEventScope": "Âmbito do evento recorrente", - "entireSeries": "Série inteira", - "singleOccurrence": "Este evento", - "thisAndFollowingEvents": "Este evento e os seguintes", - "thisAndFutureUnavailable": "Não suportado por este fornecedor.", - "thisAndFutureMoveUnavailable": "Este evento e os seguintes não podem ser movidos com segurança. Escolha este evento ou a série inteira.", - "entireSeriesMoveUnavailable": "A regra de recorrência não está disponível localmente. Mova apenas este evento.", - "copyEventAndDeleteOriginal": "Copiar o evento e eliminar o original?", - "copyEventMoveWarning": "O BusyMax não pode mover este evento diretamente de {source} para {destination}. Criará primeiro a cópia e eliminará o original apenas depois de a cópia ser criada com êxito. Os IDs do evento mudarão; os estados das respostas dos participantes poderão ser repostos e poderão ser enviados convites ou cancelamentos; as ligações de reuniões, os anexos, os lembretes, os campos específicos do fornecedor e as exceções de recorrência poderão não ser transferidos.", - "copyAndDelete": "Copiar e eliminar", - "chooseRecurringEventScope": "Escolha se esta alteração se aplica à série inteira, apenas a este evento ou a este e aos eventos seguintes.", - "taskDueBeforeStart": "O prazo não pode ser anterior ao início.", - "taskStartDueTimeModeMismatch": "Defina horários para o início e o prazo, ou torne a tarefa de dia inteiro.", - "taskListCreateFailed": "Não foi possível criar a lista de tarefas: {error}", - "taskListRenameFailed": "Não foi possível mudar o nome da lista de tarefas: {error}", - "taskListDeleteFailed": "Não foi possível eliminar a lista de tarefas: {error}", - "unshare": "Cancelar partilha", - "readOnlyTaskListCannotRename": "Esta lista de tarefas é só de leitura e não pode ter o nome alterado.", - "taskListCannotDelete": "Esta lista de tarefas não pode ser eliminada com as permissões atuais.", - "deleteTaskListConfirmation": "Eliminar “{title}” e todas as suas tarefas?", - "unshareTaskListConfirmation": "Cancelar a partilha de “{title}” desta conta?", - "taskStatus": "Estado", - "taskStatusNone": "Sem estado", - "taskStatusNeedsAction": "Requer ação", - "taskStatusInProcess": "Em curso", - "taskStatusCompleted": "Concluída", - "taskStatusCancelled": "Cancelada", - "completionPercent": "{percent}% concluída", - "completionDate": "Data de conclusão", - "priority": "Prioridade", - "priorityNone": "Sem prioridade", - "priorityHighValue": "Prioridade {priority} · Alta", - "priorityMediumValue": "Prioridade {priority} · Média", - "priorityLowValue": "Prioridade {priority} · Baixa", - "taskUrl": "URL da tarefa", - "invalidTaskUrl": "Introduza um URL absoluto, incluindo o esquema.", - "classification": "Classificação", - "classificationPublic": "Quando partilhada, mostrar a tarefa completa", - "classificationConfidential": "Quando partilhada, mostrar apenas ocupado", - "classificationPrivate": "Quando partilhada, ocultar esta tarefa", - "pinTask": "Afixar tarefa", - "reminders": "Lembretes", - "noReminders": "Sem lembretes", - "editReminder": "Editar lembrete", - "beforeTaskStarts": "Antes do início da tarefa", - "beforeTaskDue": "Antes do prazo da tarefa", - "afterTaskStarts": "Depois do início da tarefa", - "afterTaskDue": "Depois do prazo da tarefa", - "relativeToTaskStart": "Relativo à data de início da tarefa", - "relativeToTaskDue": "Relativo à data limite da tarefa", - "reminderTimeOfDay": "Hora do dia", - "absoluteReminder": "Numa data e hora", - "reminderAmount": "Quantidade", - "reminderUnitSeconds": "Segundos", - "reminderUnitMinutes": "Minutos", - "reminderUnitHours": "Horas", - "reminderUnitDays": "Dias", - "reminderUnitWeeks": "Semanas", - "reminderAtTaskStart": "No início da tarefa", - "reminderAtTaskDue": "No prazo da tarefa", - "unsupportedReminder": "Este tipo de lembrete é preservado, mas a hora não pode ser editada.", - "relatedRemindersTitle": "Manter lembretes relacionados?", - "relatedRemindersDescription": "Esta data tem {count} lembretes relacionados. Mantê-los na data e hora atuais?", - "discardRelatedReminders": "Eliminar lembretes", - "keepRelatedReminders": "Manter lembretes", - "repeatEvery": "Intervalo", - "repeatOn": "Repetir em", - "repeatEnd": "Terminar repetição", - "repeatNever": "Nunca", - "repeatUntil": "Numa data", - "repeatAfter": "Depois de um número de ocorrências", - "repeatCount": "Ocorrências", - "repeatDayOfMonth": "Dias do mês", - "repeatMonths": "Meses", - "repeatOrdinal": "Posição do dia da semana", - "repeatSpecificDays": "Dias específicos", - "repeatFirst": "Primeiro", - "repeatSecond": "Segundo", - "repeatThird": "Terceiro", - "repeatFourth": "Quarto", - "repeatFifth": "Quinto", - "repeatSecondToLast": "Penúltimo", - "repeatLast": "Último", - "repeatAnyDay": "Dia", - "repeatWeekday": "Dia útil", - "repeatWeekendDay": "Dia do fim de semana", - "repeatOrdinalDaySummary": "{dayKey, select, MO{segunda-feira} TU{terça-feira} WE{quarta-feira} TH{quinta-feira} FR{sexta-feira} SA{sábado} SU{domingo} day{dia} weekday{dia útil} weekend{dia do fim de semana} other{{day}}}", - "repeatEveryDays": "A cada {count} dias", - "repeatEveryWeeks": "A cada {count} semanas", - "repeatEveryMonths": "A cada {count} meses", - "repeatEveryYears": "A cada {count} anos", - "repeatOnDaysSummary": "em {days}", - "repeatOnMonthDaysSummary": "no dia {days}", - "repeatOnOrdinalSummary": "{position, select, first{na primeira ocorrência de {days}} second{na segunda ocorrência de {days}} third{na terceira ocorrência de {days}} fourth{na quarta ocorrência de {days}} fifth{na quinta ocorrência de {days}} secondToLast{na penúltima ocorrência de {days}} last{na última ocorrência de {days}} other{em {days}}}", - "repeatInMonthsSummary": "em {months}", - "repeatTimesSummary": "{count, plural, one{{count} vez} other{{count} vezes}}", - "repeatUntilSummary": "até {date}", - "unsupportedRecurrencePreserved": "Esta regra de recorrência utiliza opções que este editor não altera.", - "recurrenceUnsupportedByProvider": "Esta recorrência não pode ser usada com {provider}.", - "duplicateTask": "Duplicar tarefa", - "taskDuplicated": "Tarefa duplicada.", - "taskDuplicateFailed": "Não foi possível duplicar a tarefa: {error}", - "hideSubtasks": "Ocultar subtarefas", - "hideClosedSubtasks": "Ocultar subtarefas concluídas", - "reminderUnit": "Unidade", - "attendeeRequired": "Obrigatório", - "attendeeOptional": "Opcional", - "meetingSection": "Reunião", - "addGoogleMeet": "Adicionar o Google Meet", - "addTeamsMeeting": "Adicionar reunião do Microsoft Teams", - "onlineMeetingAdded": "Reunião online adicionada", - "requestResponses": "Pedir respostas", - "requestResponsesDescription": "Peça aos convidados que respondam ao convite.", - "hideGuestList": "Ocultar lista de convidados", - "hideGuestListDescription": "Os convidados não podem ver quem mais foi convidado.", - "allowNewTimeProposals": "Permitir novas propostas de horário", - "allowNewTimeProposalsDescription": "Os convidados podem sugerir uma hora diferente para a reunião.", - "notifyGuestsTitle": "Notificar convidados?", - "notifyGuestsSaveMessage": "Esta reunião tem convidados. Enviar convites ou atualizações do evento ao guardá-la?", - "notifyGuestsDeleteMessage": "Esta reunião tem convidados. Enviar um cancelamento ao eliminá-la?", - "sendUpdates": "Enviar atualizações", - "sendCancellation": "Enviar cancelamento", - "doNotSend": "Não enviar", - "microsoftNotifyGuestsSaveTitle": "Guardar reunião?", - "microsoftNotifyGuestsSaveMessage": "A Microsoft enviará convites ou atualizações do evento aos convidados.", - "microsoftNotifyGuestsDeleteTitle": "Eliminar reunião?", - "microsoftNotifyGuestsDeleteMessage": "A Microsoft enviará um cancelamento aos convidados.", - "organizer": "Organizador", - "yourResponse": "A sua resposta", - "guestResponses": "Respostas dos convidados", - "respond": "Responder", - "acceptInvitation": "Aceitar", - "tentativeInvitation": "Provisório", - "declineInvitation": "Recusar", - "joinMeeting": "Participar na reunião", - "responseAccepted": "Aceite", - "responseTentative": "Provisório", - "responseDeclined": "Recusado", - "responseNeedsAction": "A aguardar resposta", - "responseNotResponded": "Sem resposta", - "responseOrganizer": "Organizador", - "invitationResponseFailed": "Não foi possível enviar a sua resposta: {error}", - "@invitationResponseFailed": { - "placeholders": { - "error": { - "type": "String" - } - } - }, - "joinMeetingFailed": "Não foi possível abrir a ligação da reunião.", - "setCustomCalendarName": "Definir nome personalizado", - "setAction": "Definir", - "removeFromMyCalendars": "Remover dos meus calendários", - "removeAction": "Remover", - "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Calendar? O calendário partilhado e os respetivos eventos não serão eliminados.", - "calendarCannotRemove": "Não é possível eliminar nem remover este calendário desta conta.", - "calendarPendingChangesPreventRemoval": "Aguarde que as alterações pendentes deste calendário terminem de sincronizar antes de o eliminar ou remover.", - "calendarSubscriptions": "Subscrições de calendários", - "calendarSubscriptionsDescription": "Adicione calendários só de leitura que sejam atualizados a partir de um URL WebCal seguro.", - "addCalendarSubscription": "Adicionar subscrição de calendário", - "subscriptionName": "Nome local", - "subscriptionUrl": "URL da subscrição", - "subscriptionUrlHelp": "Introduza um URL HTTPS ou webcal. O BusyMax mantém o URL completo num armazenamento seguro.", - "subscriptionUrlInvalid": "Introduza um URL HTTPS ou webcal válido sem informações de utilizador ou fragmento.", - "subscriptionColor": "Cor local", - "subscriptionColorHelp": "Utilize uma cor de seis dígitos, como #3584E4.", - "subscriptionColorInvalid": "Introduza uma cor hexadecimal de seis dígitos.", - "subscriptionRefreshMode": "Frequência de atualização", - "subscriptionAutomatic": "Automática", - "subscriptionHourly": "De hora a hora", - "subscriptionSixHours": "A cada seis horas", - "subscriptionDaily": "Diária", - "subscriptionSafeOrigin": "Origem: {origin}", - "@subscriptionSafeOrigin": { - "placeholders": { - "origin": { - "type": "String" - } - } - }, - "subscriptionSafeOriginUnavailable": "Introduza um URL válido para pré-visualizar a origem segura.", - "subscriptionReadOnly": "Subscrição só de leitura", - "subscriptionNeverRefreshed": "Ainda não atualizada", - "subscriptionLastRefresh": "Última atualização bem-sucedida: {time}", - "@subscriptionLastRefresh": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "subscriptionNextRefresh": "Próxima atualização: {time}", - "@subscriptionNextRefresh": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "subscriptionStatusHealthy": "Atualizada", - "subscriptionStatusIssue": "Problema de atualização: {code}", - "@subscriptionStatusIssue": { - "placeholders": { - "code": { - "type": "String" - } - } - }, - "refreshNow": "Atualizar agora", - "unsubscribe": "Cancelar subscrição", - "unsubscribeCalendarTitle": "Cancelar subscrição de “{name}”?", - "@unsubscribeCalendarTitle": { - "placeholders": { - "name": { - "type": "String" - } - } - }, - "unsubscribeCalendarConfirmation": "Isto remove a subscrição local e os eventos colocados em cache. O calendário publicado não é alterado.", - "addSubscriptionAction": "Adicionar subscrição", - "subscriptionOperationFailed": "Falha na subscrição do calendário: {error}", - "@subscriptionOperationFailed": { - "placeholders": { - "error": { - "type": "String" - } - } - }, - "subscriptions": "Subscrições", - "calendarImport": "Importação de calendário", - "calendarImportDescription": "Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os deve receber.", - "importIcsFile": "Importar ficheiro .ics", - "importIcsPreview": "Importar eventos do calendário", - "importEventsFound": "Conjuntos de eventos importáveis: {count}", - "@importEventsFound": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "importInvalidEvents": "Eventos inválidos: {count}", - "@importInvalidEvents": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "importFieldsOmitted": "Omitidos intencionalmente: {fields}", - "@importFieldsOmitted": { - "placeholders": { - "fields": { - "type": "String" - } - } - }, - "noWritableCalendars": "Não está disponível nenhum calendário de destino editável.", - "importDestinationCalendar": "Calendário de destino", - "importIcsConfirm": "Importar eventos", - "importIcsComplete": "Importação concluída", - "importQueued": "Importados ou em fila: {count}", - "@importQueued": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "importDuplicatesSkipped": "Duplicados ignorados: {count}", - "@importDuplicatesSkipped": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "importUnsupportedSets": "Conjuntos de recorrência não suportados: {count}", - "@importUnsupportedSets": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "importIcsFailed": "Não foi possível importar o ficheiro de calendário: {error}", - "@importIcsFailed": { - "placeholders": { - "error": { - "type": "String" - } - } - }, - "networkOffline": "Sem ligação", - "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", - "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente.", - "@repeatOnOrdinalSummary": { - "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", - "placeholders": { - "position": { - "type": "String" - }, - "days": { - "type": "String" - } - } - }, - "repeatOnMonthDaysSummaryMultiple": "nos dias {days}", - "@repeatOnMonthDaysSummaryMultiple": { - "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", - "placeholders": { - "days": { - "type": "String" - } - } - }, - "repeatSummarySeparator": " ", - "@repeatSummarySeparator": { - "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." - }, - "repeatMonthDayValue": "{day}", - "@repeatMonthDayValue": { - "description": "Localized day-of-month value used when composing a list of recurrence days.", - "placeholders": { - "day": { - "type": "String" - } - } - }, - "repeatWeekdayListPair": "{first} e {second}", - "repeatWeekdayListStart": "{first}, {rest}", - "repeatMonthDayListPair": "{first} e {second}", - "repeatMonthDayListStart": "{first}, {rest}", - "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", - "repeatYearlyMonthDayListPair": "{first} e {second}", - "repeatYearlyMonthDayListStart": "{first}, {rest}", - "repeatYearlyMonthListPair": "{first} e {second}", - "repeatYearlyMonthListStart": "{first}, {rest}", - "repeatYearlyOnMonthDaySummary": "{frequency} no dia {day} de {month}", - "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} no dia {day} de {months}", - "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} nos dias {days} de {months}", - "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {months}} second{na segunda ocorrência de {days} de {months}} third{na terceira ocorrência de {days} de {months}} fourth{na quarta ocorrência de {days} de {months}} fifth{na quinta ocorrência de {days} de {months}} secondToLast{na penúltima ocorrência de {days} de {months}} last{na última ocorrência de {days} de {months}} other{em {days} de {months}}}", - "repeatYearlyOnMonthDaysSummary": "{frequency} nos dias {days} de {month}", - "@repeatYearlyOnMonthDaysSummary": { - "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", - "placeholders": { - "frequency": {"type": "String"}, - "month": {"type": "String"}, - "days": {"type": "String"} - } - }, - "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {month}} second{na segunda ocorrência de {days} de {month}} third{na terceira ocorrência de {days} de {month}} fourth{na quarta ocorrência de {days} de {month}} fifth{na quinta ocorrência de {days} de {month}} secondToLast{na penúltima ocorrência de {days} de {month}} last{na última ocorrência de {days} de {month}} other{em {days} de {month}}}", - "@repeatYearlyOnOrdinalSummary": { - "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", - "placeholders": { - "frequency": {"type": "String"}, - "month": {"type": "String"}, - "position": {"type": "String"}, - "days": {"type": "String"} - } - } + "@@locale": "pt" } diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb new file mode 100644 index 00000000..2d618301 --- /dev/null +++ b/lib/l10n/app_pt_PT.arb @@ -0,0 +1,935 @@ +{ + "@@locale": "pt_PT", + "repeatWeeklyDaySummary": "{dayKey, select, MO{à segunda-feira} TU{à terça-feira} WE{à quarta-feira} TH{à quinta-feira} FR{à sexta-feira} SA{ao sábado} SU{ao domingo} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "nos dias {first} e {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} nos dias {firstDay} e {secondDay} de {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} no dia {day} de {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} nos dias {firstDay} e {secondDay} de {firstMonth} e {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} nos dias {days} de {firstMonth} e {secondMonth}", + "appTitle": "BusyMax", + "connectGoogleAccount": "Ligue as contas Google, Microsoft, Calendário Apple iCloud ou Nextcloud.", + "googlePermissionsConsentNotice": "No ecrã de autorizações da Google, selecione as autorizações do Calendário e das Tarefas.", + "googlePermissionsRequiredRetry": "As autorizações do Calendário Google e do Google Tasks são necessárias. Tente novamente e selecione ambas as caixas.", + "finishSetup": "Concluir configuração", + "continueSetup": "Continuar", + "onboardingSetupTitle": "Configurar o BusyMax", + "onboardingAccountsStepTitle": "Ligar contas", + "onboardingAccountsStepDescription": "Adicione todas as contas que pretende utilizar. O BusyMax sincroniza calendários, eventos, listas de tarefas e tarefas suportados de cada conta.", + "onboardingPreferencesStepTitle": "Escolher definições do sistema", + "onboardingPreferencesStepDescription": "Configure o comportamento da aplicação no ambiente de trabalho, os lembretes, o nível de detalhe das notificações e o aspeto antes de abrir a agenda.", + "signInWithGoogle": "Iniciar sessão com a Google", + "signInWithMicrosoft": "Iniciar sessão com a Microsoft", + "googleTasksProvider": "Google Tasks", + "microsoftTodoProvider": "Microsoft To Do", + "providerNotConfigured": "Este fornecedor não está configurado.", + "waitingForGoogleSignIn": "A aguardar o início de sessão da Google...", + "waitingForMicrosoftSignIn": "A aguardar o início de sessão da Microsoft...", + "microsoftSignInNotConfigured": "O início de sessão da Microsoft não está configurado. Defina MICROSOFT_OAUTH_CLIENT_ID.", + "cancel": "Cancelar", + "close": "Fechar", + "exit": "Sair", + "options": "Opções", + "hide": "Ocultar", + "show": "Mostrar", + "export": "Exportar", + "save": "Guardar", + "settings": "Definições", + "all": "Tudo", + "calendarEvents": "Eventos", + "calendarTasks": "Tarefas", + "calendar": "Calendário", + "calendars": "Calendários", + "newCalendar": "Novo calendário", + "calendarColor": "Cor do calendário", + "calendarColorOption": "Cor {number}", + "@calendarColorOption": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "calendarManagementUnsupported": "Este fornecedor não suporta a gestão de calendários no BusyMax.", + "primaryCalendarCannotDelete": "O calendário principal não pode ser eliminado.", + "calendarCreateFailed": "Não foi possível criar o calendário: {error}", + "calendarCreatedRefreshPending": "O calendário foi criado, mas o BusyMax não conseguiu atualizar a conta. Será apresentado após a próxima sincronização.", + "@calendarCreateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarUpdateFailed": "Não foi possível atualizar o calendário: {error}", + "@calendarUpdateFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "calendarDeleteFailed": "Não foi possível eliminar o calendário: {error}", + "@calendarDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "newEvent": "Novo evento", + "refreshCalendar": "Atualizar calendário", + "openInProvider": "Abrir no serviço", + "hideFromSchedule": "Ocultar da agenda", + "showInSchedule": "Mostrar na agenda", + "noCalendarsSynced": "Ainda não há calendários sincronizados.", + "allDay": "Todo o dia", + "moreItems": "+{count} mais", + "noEventsOrTasks": "Sem eventos ou tarefas", + "scheduleLoading": "A carregar agenda...", + "scheduleUnavailable": "Agenda indisponível", + "scheduleNoSources": "Sem calendários ou listas de tarefas visíveis", + "scheduleNoSourcesDescription": "Escolha o que pretende mostrar nas Definições e atualize a agenda.", + "scheduleSignInRequired": "Ligar uma conta", + "scheduleSignInDescription": "Inicie sessão para sincronizar calendários e tarefas.", + "scheduleNoSearchResults": "Nenhum evento ou tarefa correspondente", + "scheduleNoSearchResultsDescription": "Experimente outra pesquisa ou limpe os filtros atuais.", + "refresh": "Atualizar", + "trayOpenBusyMax": "Abrir o BusyMax", + "trayShowBusyMax": "Mostrar o BusyMax", + "trayNewEvent": "Novo evento…", + "trayNewTask": "Nova tarefa…", + "trayToday": "Hoje", + "trayAllDay": "Todo o dia", + "trayNow": "Agora", + "trayCalendarEvent": "Evento do calendário", + "trayUntitledEvent": "Evento sem título", + "trayNothingElseToday": "Nada mais hoje", + "trayTasksDueToday": "{count, plural, =1{1 tarefa termina hoje} other{{count} tarefas terminam hoje}}", + "@trayTasksDueToday": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayOpenTodayAgenda": "Abrir a agenda de hoje", + "traySyncNow": "Sincronizar agora", + "traySyncing": "A sincronizar…", + "trayNotConnected": "Não ligado", + "trayNotYetSynced": "Ainda não sincronizado", + "trayLastSyncedJustNow": "Sincronizado agora mesmo", + "trayLastSyncedMinutesAgo": "{count, plural, =1{Sincronizado há 1 minuto} other{Sincronizado há {count} minutos}}", + "@trayLastSyncedMinutesAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedHoursAgo": "{count, plural, =1{Sincronizado há 1 hora} other{Sincronizado há {count} horas}}", + "@trayLastSyncedHoursAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "trayLastSyncedDaysAgo": "{count, plural, =1{Sincronizado há 1 dia} other{Sincronizado há {count} dias}}", + "@trayLastSyncedDaysAgo": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "traySettings": "Definições", + "trayQuitBusyMax": "Sair do BusyMax", + "agendaLoadMoreOverdue": "Carregar mais tarefas em atraso", + "agendaLoadMoreNoDate": "Carregar mais tarefas sem data", + "viewDay": "Dia", + "viewWeek": "Semana", + "viewMonth": "Mês", + "viewYear": "Ano", + "viewAgenda": "Vista de agenda", + "scheduleSettings": "Agenda", + "scheduleDisplaySettings": "Apresentação da agenda", + "scheduleDisplayHoursDescription": "As vistas de dia e semana mostram inicialmente este intervalo horário. Os itens anteriores ou posteriores alargam-no quando necessário.", + "scheduleDayStartsAt": "O dia começa às", + "scheduleDayEndsAt": "O dia termina às", + "sourceCalendar": "Calendário", + "sourceTaskList": "Lista de tarefas", + "createChoiceTitle": "Criar", + "createEventAtTime": "Evento", + "createTaskAtDate": "Tarefa", + "editEvent": "Editar evento", + "eventTitle": "Título do evento", + "location": "Local", + "timeSlot": "Intervalo de tempo", + "startDateTime": "Data e hora de início", + "endDateTime": "Data e hora de fim", + "doesNotRepeat": "Não se repete", + "defaultReminder": "Lembrete predefinido", + "guests": "Convidados", + "noGuests": "Sem convidados", + "description": "Descrição", + "availabilityShowAs": "Disponibilidade / Mostrar como", + "busy": "Ocupado", + "visibility": "Visibilidade", + "defaultVisibility": "Visibilidade predefinida", + "conference": "Conferência", + "noConference": "Sem conferência", + "providerCalendar": "Calendário do fornecedor", + "formatBoldShortLabel": "N", + "formatBoldTooltip": "Negrito", + "formatItalicShortLabel": "I", + "formatItalicTooltip": "Itálico", + "formatUnderlineShortLabel": "S", + "formatUnderlineTooltip": "Sublinhado", + "reminderMinutesBefore": "{minutes, plural, =1{1 minuto antes} other{{minutes} minutos antes}}", + "reminderAtStart": "À hora de início", + "reminderHoursBefore": "{hours, plural, =1{1 hora antes} other{{hours} horas antes}}", + "reminderDaysBefore": "{days, plural, =1{1 dia antes} other{{days} dias antes}}", + "availabilityFree": "Livre", + "availabilityTentative": "Provisório", + "availabilityOutOfOffice": "Fora do escritório", + "availabilityWorkingElsewhere": "A trabalhar noutro local", + "visibilityDefault": "Predefinida", + "visibilityPublic": "Pública", + "visibilityPrivate": "Privada", + "visibilityConfidential": "Confidencial", + "sensitivityNormal": "Normal", + "sensitivityPersonal": "Pessoal", + "tasks": "Tarefas", + "allTasks": "Todas as tarefas", + "tasksInList": "Tarefas em {title}", + "taskLists": "Listas de tarefas", + "navigation": "Navegação", + "mainMenu": "Menu principal", + "keyboardShortcuts": "Atalhos de teclado", + "shortcutGroupGeneral": "Geral", + "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos", + "shortcutGroupNavigation": "Navegação", + "shortcutNextPeriod": "Período seguinte", + "shortcutNextPeriodDescription": "Semana seguinte na vista semanal, mês seguinte na vista mensal e assim por diante", + "shortcutPreviousPeriod": "Período anterior", + "shortcutPreviousPeriodDescription": "Semana anterior na vista semanal, mês anterior na vista mensal e assim por diante", + "shortcutJumpToToday": "Ir para hoje", + "shortcutGroupView": "Vista", + "shortcutDayView": "Vista diária", + "shortcutWeekView": "Vista semanal", + "shortcutMonthView": "Vista mensal", + "shortcutYearView": "Vista anual", + "shortcutAgendaView": "Vista de agenda", + "shortcutGroupCreateAndEdit": "Criar e editar", + "shortcutSaveItem": "Guardar evento ou tarefa", + "shortcutDeleteItem": "Eliminar evento ou tarefa", + "shortcutGroupTaskEditing": "Edição de tarefas", + "shortcutCancelEditing": "Cancelar edição", + "shortcutCancelEditingDescription": "Fechar a edição ou os detalhes da tarefa", + "aboutBusyMax": "Acerca do BusyMax", + "aboutBusyMaxDescription": "Calendário e tarefas", + "license": "Licença", + "apacheLicenseName": "Apache License 2.0", + "website": "Site", + "sourceCode": "Código-fonte", + "reportAnIssue": "Comunicar um problema", + "sendFeedback": "Enviar comentários", + "feedbackSubmit": "Enviar", + "feedbackCategory": "Categoria", + "feedbackSelectCategory": "Selecione uma categoria", + "feedbackCategoryProblem": "Problema ou erro", + "feedbackCategoryFeature": "Pedido de funcionalidade", + "feedbackCategoryPrivacySecurity": "Questão de privacidade ou segurança", + "feedbackCategoryUsability": "Problema de utilização", + "feedbackCategoryOther": "Outro", + "feedbackSubject": "Assunto", + "feedbackDetailedMessage": "Mensagem detalhada", + "feedbackReplyEmail": "Endereço de e-mail para resposta (opcional)", + "feedbackIncludeTechnicalDetails": "Incluir detalhes técnicos", + "feedbackTechnicalDetailsDisclosure": "Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", + "feedbackCategoryRequired": "Selecione uma categoria.", + "feedbackSubjectLengthError": "O assunto deve ter entre 3 e 120 carateres.", + "feedbackMessageLengthError": "A mensagem deve ter entre 10 e 5 000 carateres.", + "feedbackInvalidEmail": "Introduza um endereço de e-mail válido.", + "feedbackConnectionError": "Não foi possível ligar ao BusyStack. Verifique a ligação e tente novamente.", + "feedbackTimeoutError": "O pedido excedeu o tempo limite. Os seus comentários não foram apagados; tente novamente.", + "feedbackRateLimitedError": "Foram enviados demasiados comentários a partir desta rede. Aguarde e tente novamente.", + "feedbackRejectedError": "O servidor rejeitou o envio. Reveja os campos e tente novamente.", + "feedbackServerError": "O BusyStack não pode aceitar os seus comentários neste momento. Os seus comentários não foram apagados; tente novamente.", + "feedbackSuccess": "Comentários enviados. Referência: {id}", + "toggleSidebar": "Mostrar ou ocultar a barra lateral", + "showSidebar": "Mostrar painel lateral", + "hideSidebar": "Ocultar painel lateral", + "accounts": "Contas", + "currentAccount": "Conta atual", + "switchAccount": "Mudar de conta", + "addGoogleAccount": "Adicionar conta Google", + "addMicrosoftAccount": "Adicionar conta Microsoft", + "googleProvider": "Google", + "microsoftProvider": "Microsoft", + "signedInAccount": "Sessão iniciada", + "removeAccount": "Remover conta…", + "removingAccount": "A remover conta…", + "removeAccountDescription": "Parar a sincronização e remover os dados desta conta deste dispositivo.", + "removeAccountTitle": "Remover {account} do BusyMax?", + "removeAccountConfirmation": "Esta ação elimina deste dispositivo as tarefas, calendários, eventos, lembretes e alterações offline pendentes colocados em cache. As alterações não sincronizadas serão perdidas. As cópias dos calendários, eventos, listas de tarefas e tarefas no fornecedor não são eliminadas.", + "revokeGoogleAccess": "Revogar também o acesso do BusyMax a esta conta Google", + "revokeGoogleAccessDescription": "Terá de conceder acesso novamente antes de voltar a ligar a conta.", + "removeAccountAction": "Remover conta", + "removeAccountFailed": "Não foi possível concluir a remoção da conta. Tente novamente.", + "accountRemovedGoogleRevokeFailed": "A conta foi removida deste dispositivo, mas não foi possível revogar o acesso do BusyMax à sua conta Google. Pode revogar esse acesso na sua conta Google.", + "newTaskList": "Nova lista de tarefas", + "signInToViewTaskLists": "Inicie sessão para ver as listas de tarefas.", + "noTaskListsSynced": "Ainda não há listas de tarefas sincronizadas.", + "listActions": "Ações da lista", + "rename": "Mudar o nome", + "delete": "Eliminar", + "renameList": "Mudar o nome da lista", + "deleteList": "Eliminar lista", + "builtInMicrosoftList": "Incorporada", + "builtInMicrosoftListCannotRenameDelete": "As listas incorporadas do Microsoft To Do não podem ser renomeadas nem eliminadas.", + "deleteListConfirmation": "Eliminar “{title}” do Google Tasks?", + "deleteEvent": "Eliminar evento", + "title": "Título", + "create": "Criar", + "newTask": "Nova tarefa", + "clearCompleted": "Limpar tarefas concluídas", + "refreshList": "Atualizar lista", + "refreshAll": "Atualizar tudo", + "listRefreshed": "Lista atualizada.", + "allTasksRefreshed": "Todas as contas foram atualizadas.", + "exportedFile": "Exportado para {path}", + "exportFailed": "Falha ao exportar: {error}", + "refreshFailed": "Falha ao atualizar: {error}", + "selectOrCreateTaskList": "Selecione ou crie uma lista de tarefas para começar.", + "signInToViewTasks": "Inicie sessão para ver as tarefas.", + "noTasks": "Sem tarefas.", + "noTasksYet": "Ainda não há tarefas", + "noTasksYetMessage": "Crie uma tarefa ou atualize as suas contas para começar.", + "noTasksInList": "Não há tarefas nesta lista.", + "overdue": "Em atraso", + "today": "Hoje", + "tomorrow": "Amanhã", + "upcoming": "Próximas", + "noDate": "Sem data", + "completed": "Concluídas", + "duePrefix": "Prazo: {date}", + "dateTimeDisplay": "{date}, {time}", + "taskDetails": "Detalhes da tarefa", + "editTask": "Editar tarefa", + "noTaskSelected": "Nenhuma tarefa selecionada.", + "noTaskSelectedHelper": "Selecione uma tarefa para ver e editar os detalhes.", + "taskUnavailable": "Tarefa indisponível.", + "signInToEditTasks": "Inicie sessão para editar tarefas.", + "refreshTask": "Atualizar tarefa", + "primarySection": "Principal", + "statusSection": "Estado", + "openStatus": "Aberta", + "doneStatus": "Concluída", + "notes": "Notas", + "dueDate": "Data limite", + "clearDueDate": "Limpar data limite", + "dueTime": "Hora limite", + "startDate": "Data de início", + "startTime": "Hora de início", + "endDate": "Data de fim", + "endTime": "Hora de fim", + "reminderDate": "Data do lembrete", + "reminderTime": "Hora do lembrete", + "reminder": "Lembrete", + "addReminder": "Adicionar lembrete", + "addGuest": "Adicionar convidado", + "addGuestEmail": "Adicionar e-mail do convidado", + "removeReminder": "Remover lembrete", + "off": "Desativado", + "repeat": "Repetição", + "repeatNone": "Não repetir", + "noneValue": "Nenhum", + "repeatDaily": "Diariamente", + "repeatWeekly": "Semanalmente", + "repeatMonthly": "Mensalmente", + "repeatYearly": "Anualmente", + "importance": "Importância", + "importanceLow": "Baixa", + "importanceNormal": "Média", + "importanceHigh": "Alta", + "categories": "Categorias", + "scheduleSection": "Agenda", + "dueGroup": "Prazo", + "startGroup": "Início", + "reminderGroup": "Lembrete", + "organizationSection": "Organização", + "actionsSection": "Ações", + "advancedSection": "Avançado", + "addCategory": "Adicionar categoria", + "list": "Lista", + "microsoftMoveUnsupported": "Nesta versão, não é possível mover tarefas entre listas em contas Microsoft To Do.", + "createSubtask": "Criar subtarefa", + "subtasks": "Subtarefas", + "moveToTop": "Mover para o início", + "deleteTask": "Eliminar tarefa", + "newSubtask": "Nova subtarefa", + "deleteTaskConfirmation": "Eliminar «{title}»?", + "metadata": "Metadados", + "id": "ID", + "etag": "ETag", + "updated": "Atualizado", + "parent": "Tarefa principal", + "position": "Posição", + "webLink": "Ligação Web", + "assignment": "Atribuição", + "localState": "Estado local", + "pendingSync": "Sincronização pendente", + "synced": "Sincronizado", + "account": "Conta", + "sync": "Sincronização", + "forceFullResync": "Forçar nova sincronização completa", + "forceFullResyncDescription": "Recarrega completamente os dados de todas as contas ligadas. Utilize esta opção apenas para resolver problemas de sincronização.", + "runInBackgroundWhenClosed": "Continuar em execução quando a janela for fechada", + "showTrayIcon": "Mostrar ícone na área de notificação", + "startMinimizedToTray": "Iniciar minimizado na área de notificação", + "launchAtLogin": "Iniciar ao entrar", + "launchAtLoginDescription": "Inicie o BusyMax em segundo plano para que os lembretes funcionem após entrar.", + "launchAtLoginFailed": "Não foi possível atualizar o início de sessão.", + "requiresTrayIcon": "Requer o ícone da área de notificação.", + "syncComplete": "Sincronização concluída.", + "syncFailed": "Falha na sincronização: {error}", + "notifySyncFailures": "Notificações de falhas de sincronização", + "notifyConflicts": "Notificações de conflitos", + "notifyDueToday": "Notificações de tarefas com prazo para hoje", + "eventReminders": "Lembretes de eventos", + "onState": "Ativado", + "taskReminders": "Lembretes de tarefas", + "notificationDetailLevel": "Nível de detalhe das notificações", + "notificationDetailPrivate": "Privado", + "notificationDetailNormal": "Predefinido", + "quietHours": "Período de silêncio", + "quietHoursDescription": "Pausar as notificações durante este período.", + "quietHoursStart": "Início do período de silêncio", + "quietHoursEnd": "Fim do período de silêncio", + "notifications": "Notificações", + "appearance": "Aspeto", + "theme": "Tema", + "themeSystem": "Sistema", + "settingsSystem": "Sistema", + "themeLight": "Claro", + "themeDark": "Escuro", + "themeFamily": "Família de temas", + "themeFamilyYaru": "Tema nativo do Ubuntu (Yaru)", + "localization": "Localização", + "currentLocale": "Configuração regional atual", + "privacy": "Privacidade", + "redactTaskContentInDiagnostics": "Ocultar o conteúdo das tarefas nos diagnósticos", + "developerDiagnostics": "Diagnósticos de programador", + "diagnostics": "Diagnósticos", + "apiInspectorDisabled": "Mostrar inspetor da API", + "googleTasksApi": "API do Google Tasks", + "discoveryRevision": "Revisão de descoberta: {revision}", + "implementedMethods": "Métodos implementados", + "supportsTasksScopes": "Suporta os âmbitos de autorização tasks e tasks.readonly", + "requiresTasksScope": "Requer o âmbito de autorização tasks", + "blockedPendingOperations": "Operações pendentes bloqueadas", + "signInToInspectPendingOperations": "Inicie sessão para inspecionar as operações pendentes.", + "noBlockedPendingOperations": "Não há operações pendentes bloqueadas.", + "operationActions": "Ações da operação", + "pendingOpListId": "lista={id}", + "pendingOpTaskId": "tarefa={id}", + "pendingOpAttempts": "tentativas={count}", + "retry": "Tentar novamente", + "discard": "Descartar", + "discardChangesAction": "Descartar", + "discardChanges": "Descartar alterações?", + "discardChangesConfirmation": "Esta ação descarta as alterações não guardadas nesta tarefa.", + "retryCompleted": "Nova tentativa concluída.", + "discardPendingOperation": "Descartar operação pendente?", + "discardPendingOperationConfirmation": "Esta ação remove a operação local bloqueada. Na próxima sincronização, os dados serão novamente carregados do Google Tasks.", + "pendingOperationDiscarded": "Operação pendente descartada.", + "syncFailureNotificationTitle": "Falha na sincronização do BusyMax", + "syncFailureNotificationBody": "A sincronização em segundo plano falhou. {message}", + "conflictNotificationTitle": "Conflito de sincronização do BusyMax", + "conflictNotificationBody": "Uma alteração local pendente foi bloqueada. {summary}", + "dueTodayNotificationTitle": "Tarefas com prazo para hoje", + "dueTodayNotificationBody": "{count, plural, =1{Há uma tarefa com prazo para hoje.} other{Há {count} tarefas com prazo para hoje.}}", + "eventReminderNotificationTitle": "Lembrete de evento", + "taskReminderNotificationTitle": "Lembrete de tarefa", + "eventReminderNotificationBody": "O evento começa em breve.", + "taskReminderNotificationBody": "O prazo da tarefa aproxima-se.", + "notificationOpenAction": "Abrir", + "notificationSnoozeAction": "Adiar 10 minutos", + "notificationDismissAction": "Fechar", + "notificationDetailsHidden": "Os detalhes estão ocultos pelas definições de privacidade.", + "previousMonth": "Mês anterior", + "nextMonth": "Mês seguinte", + "openMonthView": "Abrir vista mensal", + "previousYear": "Ano anterior", + "nextYear": "Ano seguinte", + "openYearView": "Abrir vista anual", + "weekNumberTooltip": "Semana {number}", + "resizeAllDayPanel": "Redimensionar o painel de dia inteiro", + "scheduleItemCount": "{count, plural, =1{1 item} other{{count} itens}}", + "readOnlyCalendar": "Este calendário é só de leitura.", + "selectTimeZone": "Selecionar fuso horário", + "searchLocations": "Pesquisar locais", + "noLocationsFound": "Não foram encontrados locais", + "requiredField": "Este campo é obrigatório.", + "providerConnectionDescription": "Ligue calendários e tarefas a um destes fornecedores.", + "appleICloudProvider": "Calendário Apple iCloud", + "nextcloudProvider": "Nextcloud", + "appleICloudTasksProvider": "Apple iCloud", + "nextcloudTasksProvider": "Tarefas do Nextcloud", + "addAppleICloudAccount": "Adicionar conta do Calendário Apple iCloud", + "addNextcloudAccount": "Adicionar conta Nextcloud", + "waitingForAppleICloud": "A ligar ao Apple iCloud…", + "waitingForNextcloud": "A aguardar autorização do Nextcloud…", + "connectAppleICloudTitle": "Ligar o Calendário Apple iCloud", + "appleAccountEmail": "E-mail da conta Apple", + "appleAppSpecificPassword": "Palavra-passe específica da aplicação", + "appleAppSpecificPasswordHelp": "Crie uma palavra-passe específica da aplicação depois de ativar a autenticação de dois fatores na sua conta Apple.", + "appleAppSpecificPasswordResetWarning": "A reposição da palavra-passe da conta Apple revoga as palavras-passe específicas de aplicações.", + "connectNextcloudTitle": "Ligar ao Nextcloud", + "nextcloudServerUrl": "Servidor Nextcloud ou endereço CalDAV", + "nextcloudServerUrlHelp": "Introduza o URL do servidor Nextcloud ou cole o endereço CalDAV principal copiado do Nextcloud.", + "nextcloudBrowserAuthorizationHelp": "O BusyMax abrirá o navegador. Aprove o acesso aí e volte ao BusyMax.", + "connectAccountAction": "Ligar", + "cancelAccountConnection": "Cancelar ligação", + "nextcloudAccountRemovedRevokeFailed": "A conta foi removida localmente, mas não foi possível revogar a palavra-passe da aplicação Nextcloud.", + "davCachedOfflineNotice": "Os dados de calendários e tarefas são colocados em cache localmente para utilização offline.", + "davReauthenticationRequired": "Volte a ligar esta conta para retomar a sincronização.", + "davTemporarilyUnavailable": "Esta conta está temporariamente indisponível.", + "davPermissionChanged": "As permissões do servidor foram alteradas. As edições pendentes estão em pausa.", + "davUnsupportedServer": "Este servidor ou perfil do fornecedor não é suportado.", + "collectionSettings": "Calendários e listas de tarefas", + "calendarContent": "Eventos do calendário", + "taskContent": "Tarefas", + "readOnlySharedCollection": "Só de leitura", + "pendingLocally": "Pendente localmente", + "conflictBlocked": "Bloqueado por conflito", + "authenticationBlocked": "Bloqueado até voltar a ligar", + "operationFailed": "Operação falhou", + "keepServerVersion": "Manter versão do servidor", + "reapplyLocalChange": "Rever e reaplicar alteração local", + "duplicateLocalItem": "Duplicar como novo item", + "deleteCalendarConfirmation": "Eliminar «{title}»?", + "davConnectionState": "Estado da ligação", + "davConnected": "Ligado", + "davConnecting": "A ligar…", + "davSignedOut": "Sessão terminada", + "davLastSuccessfulSync": "Última sincronização bem-sucedida: {time}", + "@davLastSuccessfulSync": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "davNeverSynced": "Ainda não sincronizado", + "refreshCollections": "Atualizar calendários e listas de tarefas", + "nextcloudServerHost": "Servidor: {host}", + "@nextcloudServerHost": { + "placeholders": { + "host": { + "type": "String" + } + } + }, + "collectionSupportsEvents": "Calendário de eventos", + "collectionSupportsTasks": "Lista de tarefas", + "collectionSupportsEventsAndTasks": "Eventos e tarefas", + "writableCollection": "Editável", + "sharedCollection": "Partilhado", + "collectionLastSynced": "Última sincronização: {time}", + "@collectionLastSynced": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "collectionSyncError": "Problema de sincronização: {code}", + "@collectionSyncError": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "syncConflicts": "Conflitos de sincronização", + "remoteChangedAt": "Alteração no servidor: {time}", + "@remoteChangedAt": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "localPendingEdit": "Edição local: {summary}", + "@localPendingEdit": { + "placeholders": { + "summary": { + "type": "String" + } + } + }, + "conflictResolutionFailed": "Não foi possível resolver o conflito.", + "recurringEventScope": "Âmbito do evento recorrente", + "entireSeries": "Série inteira", + "singleOccurrence": "Este evento", + "thisAndFollowingEvents": "Este evento e os seguintes", + "thisAndFutureUnavailable": "Não suportado por este fornecedor.", + "thisAndFutureMoveUnavailable": "Este evento e os seguintes não podem ser movidos com segurança. Escolha este evento ou a série inteira.", + "entireSeriesMoveUnavailable": "A regra de recorrência não está disponível localmente. Mova apenas este evento.", + "copyEventAndDeleteOriginal": "Copiar o evento e eliminar o original?", + "copyEventMoveWarning": "O BusyMax não pode mover este evento diretamente de {source} para {destination}. Criará primeiro a cópia e eliminará o original apenas depois de a cópia ser criada com êxito. Os IDs do evento mudarão; os estados das respostas dos participantes poderão ser repostos e poderão ser enviados convites ou cancelamentos; as ligações de reuniões, os anexos, os lembretes, os campos específicos do fornecedor e as exceções de recorrência poderão não ser transferidos.", + "copyAndDelete": "Copiar e eliminar", + "chooseRecurringEventScope": "Escolha se esta alteração se aplica à série inteira, apenas a este evento ou a este e aos eventos seguintes.", + "taskDueBeforeStart": "O prazo não pode ser anterior ao início.", + "taskStartDueTimeModeMismatch": "Defina horários para o início e o prazo, ou torne a tarefa de dia inteiro.", + "taskListCreateFailed": "Não foi possível criar a lista de tarefas: {error}", + "taskListRenameFailed": "Não foi possível mudar o nome da lista de tarefas: {error}", + "taskListDeleteFailed": "Não foi possível eliminar a lista de tarefas: {error}", + "unshare": "Cancelar partilha", + "readOnlyTaskListCannotRename": "Esta lista de tarefas é só de leitura e não pode ter o nome alterado.", + "taskListCannotDelete": "Esta lista de tarefas não pode ser eliminada com as permissões atuais.", + "deleteTaskListConfirmation": "Eliminar “{title}” e todas as suas tarefas?", + "unshareTaskListConfirmation": "Cancelar a partilha de “{title}” desta conta?", + "taskStatus": "Estado", + "taskStatusNone": "Sem estado", + "taskStatusNeedsAction": "Requer ação", + "taskStatusInProcess": "Em curso", + "taskStatusCompleted": "Concluída", + "taskStatusCancelled": "Cancelada", + "completionPercent": "{percent}% concluída", + "completionDate": "Data de conclusão", + "priority": "Prioridade", + "priorityNone": "Sem prioridade", + "priorityHighValue": "Prioridade {priority} · Alta", + "priorityMediumValue": "Prioridade {priority} · Média", + "priorityLowValue": "Prioridade {priority} · Baixa", + "taskUrl": "URL da tarefa", + "invalidTaskUrl": "Introduza um URL absoluto, incluindo o esquema.", + "classification": "Classificação", + "classificationPublic": "Quando partilhada, mostrar a tarefa completa", + "classificationConfidential": "Quando partilhada, mostrar apenas ocupado", + "classificationPrivate": "Quando partilhada, ocultar esta tarefa", + "pinTask": "Afixar tarefa", + "reminders": "Lembretes", + "noReminders": "Sem lembretes", + "editReminder": "Editar lembrete", + "beforeTaskStarts": "Antes do início da tarefa", + "beforeTaskDue": "Antes do prazo da tarefa", + "afterTaskStarts": "Depois do início da tarefa", + "afterTaskDue": "Depois do prazo da tarefa", + "relativeToTaskStart": "Relativo à data de início da tarefa", + "relativeToTaskDue": "Relativo à data limite da tarefa", + "reminderTimeOfDay": "Hora do dia", + "absoluteReminder": "Numa data e hora", + "reminderAmount": "Quantidade", + "reminderUnitSeconds": "Segundos", + "reminderUnitMinutes": "Minutos", + "reminderUnitHours": "Horas", + "reminderUnitDays": "Dias", + "reminderUnitWeeks": "Semanas", + "reminderAtTaskStart": "No início da tarefa", + "reminderAtTaskDue": "No prazo da tarefa", + "unsupportedReminder": "Este tipo de lembrete é preservado, mas a hora não pode ser editada.", + "relatedRemindersTitle": "Manter lembretes relacionados?", + "relatedRemindersDescription": "Esta data tem {count} lembretes relacionados. Mantê-los na data e hora atuais?", + "discardRelatedReminders": "Eliminar lembretes", + "keepRelatedReminders": "Manter lembretes", + "repeatEvery": "Intervalo", + "repeatOn": "Repetir em", + "repeatEnd": "Terminar repetição", + "repeatNever": "Nunca", + "repeatUntil": "Numa data", + "repeatAfter": "Depois de um número de ocorrências", + "repeatCount": "Ocorrências", + "repeatDayOfMonth": "Dias do mês", + "repeatMonths": "Meses", + "repeatOrdinal": "Posição do dia da semana", + "repeatSpecificDays": "Dias específicos", + "repeatFirst": "Primeiro", + "repeatSecond": "Segundo", + "repeatThird": "Terceiro", + "repeatFourth": "Quarto", + "repeatFifth": "Quinto", + "repeatSecondToLast": "Penúltimo", + "repeatLast": "Último", + "repeatAnyDay": "Dia", + "repeatWeekday": "Dia útil", + "repeatWeekendDay": "Dia do fim de semana", + "repeatOrdinalDaySummary": "{dayKey, select, MO{segunda-feira} TU{terça-feira} WE{quarta-feira} TH{quinta-feira} FR{sexta-feira} SA{sábado} SU{domingo} day{dia} weekday{dia útil} weekend{dia do fim de semana} other{{day}}}", + "repeatEveryDays": "A cada {count} dias", + "repeatEveryWeeks": "A cada {count} semanas", + "repeatEveryMonths": "A cada {count} meses", + "repeatEveryYears": "A cada {count} anos", + "repeatOnDaysSummary": "{days}", + "repeatOnMonthDaysSummary": "no dia {days}", + "repeatOnOrdinalSummary": "{position, select, first{na primeira ocorrência de {days}} second{na segunda ocorrência de {days}} third{na terceira ocorrência de {days}} fourth{na quarta ocorrência de {days}} fifth{na quinta ocorrência de {days}} secondToLast{na penúltima ocorrência de {days}} last{na última ocorrência de {days}} other{em {days}}}", + "repeatInMonthsSummary": "em {months}", + "repeatTimesSummary": "{count, plural, one{{count} vez} other{{count} vezes}}", + "repeatUntilSummary": "até {date}", + "unsupportedRecurrencePreserved": "Esta regra de recorrência utiliza opções que este editor não altera.", + "recurrenceUnsupportedByProvider": "Esta recorrência não pode ser usada com {provider}.", + "duplicateTask": "Duplicar tarefa", + "taskDuplicated": "Tarefa duplicada.", + "taskDuplicateFailed": "Não foi possível duplicar a tarefa: {error}", + "hideSubtasks": "Ocultar subtarefas", + "hideClosedSubtasks": "Ocultar subtarefas concluídas", + "reminderUnit": "Unidade", + "attendeeRequired": "Obrigatório", + "attendeeOptional": "Opcional", + "meetingSection": "Reunião", + "addGoogleMeet": "Adicionar o Google Meet", + "addTeamsMeeting": "Adicionar reunião do Microsoft Teams", + "onlineMeetingAdded": "Reunião online adicionada", + "requestResponses": "Pedir respostas", + "requestResponsesDescription": "Peça aos convidados que respondam ao convite.", + "hideGuestList": "Ocultar lista de convidados", + "hideGuestListDescription": "Os convidados não podem ver quem mais foi convidado.", + "allowNewTimeProposals": "Permitir novas propostas de horário", + "allowNewTimeProposalsDescription": "Os convidados podem sugerir uma hora diferente para a reunião.", + "notifyGuestsTitle": "Notificar convidados?", + "notifyGuestsSaveMessage": "Esta reunião tem convidados. Enviar convites ou atualizações do evento ao guardá-la?", + "notifyGuestsDeleteMessage": "Esta reunião tem convidados. Enviar um cancelamento ao eliminá-la?", + "sendUpdates": "Enviar atualizações", + "sendCancellation": "Enviar cancelamento", + "doNotSend": "Não enviar", + "microsoftNotifyGuestsSaveTitle": "Guardar reunião?", + "microsoftNotifyGuestsSaveMessage": "A Microsoft enviará convites ou atualizações do evento aos convidados.", + "microsoftNotifyGuestsDeleteTitle": "Eliminar reunião?", + "microsoftNotifyGuestsDeleteMessage": "A Microsoft enviará um cancelamento aos convidados.", + "organizer": "Organizador", + "yourResponse": "A sua resposta", + "guestResponses": "Respostas dos convidados", + "respond": "Responder", + "acceptInvitation": "Aceitar", + "tentativeInvitation": "Provisório", + "declineInvitation": "Recusar", + "joinMeeting": "Participar na reunião", + "responseAccepted": "Aceite", + "responseTentative": "Provisório", + "responseDeclined": "Recusado", + "responseNeedsAction": "A aguardar resposta", + "responseNotResponded": "Sem resposta", + "responseOrganizer": "Organizador", + "invitationResponseFailed": "Não foi possível enviar a sua resposta: {error}", + "@invitationResponseFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "joinMeetingFailed": "Não foi possível abrir a ligação da reunião.", + "setCustomCalendarName": "Definir nome personalizado", + "setAction": "Definir", + "removeFromMyCalendars": "Remover dos meus calendários", + "removeAction": "Remover", + "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Calendar? O calendário partilhado e os respetivos eventos não serão eliminados.", + "calendarCannotRemove": "Não é possível eliminar nem remover este calendário desta conta.", + "calendarPendingChangesPreventRemoval": "Aguarde que as alterações pendentes deste calendário terminem de sincronizar antes de o eliminar ou remover.", + "calendarSubscriptions": "Subscrições de calendários", + "calendarSubscriptionsDescription": "Adicione calendários só de leitura que sejam atualizados a partir de um URL WebCal seguro.", + "addCalendarSubscription": "Adicionar subscrição de calendário", + "subscriptionName": "Nome local", + "subscriptionUrl": "URL da subscrição", + "subscriptionUrlHelp": "Introduza um URL HTTPS ou webcal. O BusyMax mantém o URL completo num armazenamento seguro.", + "subscriptionUrlInvalid": "Introduza um URL HTTPS ou webcal válido sem informações de utilizador ou fragmento.", + "subscriptionColor": "Cor local", + "subscriptionColorHelp": "Utilize uma cor de seis dígitos, como #3584E4.", + "subscriptionColorInvalid": "Introduza uma cor hexadecimal de seis dígitos.", + "subscriptionRefreshMode": "Frequência de atualização", + "subscriptionAutomatic": "Automática", + "subscriptionHourly": "De hora a hora", + "subscriptionSixHours": "A cada seis horas", + "subscriptionDaily": "Diária", + "subscriptionSafeOrigin": "Origem: {origin}", + "@subscriptionSafeOrigin": { + "placeholders": { + "origin": { + "type": "String" + } + } + }, + "subscriptionSafeOriginUnavailable": "Introduza um URL válido para pré-visualizar a origem segura.", + "subscriptionReadOnly": "Subscrição só de leitura", + "subscriptionNeverRefreshed": "Ainda não atualizada", + "subscriptionLastRefresh": "Última atualização bem-sucedida: {time}", + "@subscriptionLastRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionNextRefresh": "Próxima atualização: {time}", + "@subscriptionNextRefresh": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "subscriptionStatusHealthy": "Atualizada", + "subscriptionStatusIssue": "Problema de atualização: {code}", + "@subscriptionStatusIssue": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "refreshNow": "Atualizar agora", + "unsubscribe": "Cancelar subscrição", + "unsubscribeCalendarTitle": "Cancelar subscrição de “{name}”?", + "@unsubscribeCalendarTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "unsubscribeCalendarConfirmation": "Isto remove a subscrição local e os eventos colocados em cache. O calendário publicado não é alterado.", + "addSubscriptionAction": "Adicionar subscrição", + "subscriptionOperationFailed": "Falha na subscrição do calendário: {error}", + "@subscriptionOperationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subscriptions": "Subscrições", + "calendarImport": "Importação de calendário", + "calendarImportDescription": "Selecione um ficheiro, reveja os eventos e escolha o calendário editável que os deve receber.", + "importIcsFile": "Importar ficheiro .ics", + "importIcsPreview": "Importar eventos do calendário", + "importEventsFound": "Conjuntos de eventos importáveis: {count}", + "@importEventsFound": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importInvalidEvents": "Eventos inválidos: {count}", + "@importInvalidEvents": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importFieldsOmitted": "Omitidos intencionalmente: {fields}", + "@importFieldsOmitted": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "noWritableCalendars": "Não está disponível nenhum calendário de destino editável.", + "importDestinationCalendar": "Calendário de destino", + "importIcsConfirm": "Importar eventos", + "importIcsComplete": "Importação concluída", + "importQueued": "Importados ou em fila: {count}", + "@importQueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importDuplicatesSkipped": "Duplicados ignorados: {count}", + "@importDuplicatesSkipped": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importUnsupportedSets": "Conjuntos de recorrência não suportados: {count}", + "@importUnsupportedSets": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "importIcsFailed": "Não foi possível importar o ficheiro de calendário: {error}", + "@importIcsFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "networkOffline": "Sem ligação", + "networkOfflineDescription": "As alterações serão sincronizadas quando a ligação for restabelecida.", + "networkOfflineTryAgain": "Está offline. Ligue-se à Internet e tente novamente.", + "@repeatOnOrdinalSummary": { + "description": "Complete sentence fragment for ordinal recurrence. {position} is one of first, second, third, fourth, fifth, secondToLast, or last; {days} is a localized weekday.", + "placeholders": { + "position": { + "type": "String" + }, + "days": { + "type": "String" + } + } + }, + "repeatOnMonthDaysSummaryMultiple": "nos dias {days}", + "@repeatOnMonthDaysSummaryMultiple": { + "description": "Sentence fragment for recurrence on multiple days of the month; {days} is a localized list of day numbers.", + "placeholders": { + "days": { + "type": "String" + } + } + }, + "repeatSummarySeparator": " ", + "@repeatSummarySeparator": { + "description": "Separator inserted between the base recurrence frequency and a following day or ordinal phrase." + }, + "repeatMonthDayValue": "{day}", + "@repeatMonthDayValue": { + "description": "Localized day-of-month value used when composing a list of recurrence days.", + "placeholders": { + "day": { + "type": "String" + } + } + }, + "repeatWeekdayListPair": "{first} e {second}", + "repeatWeekdayListStart": "{first}, {rest}", + "repeatMonthDayListPair": "{first} e {second}", + "repeatMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthValue": "{monthKey, select, other{{month}}}", + "repeatYearlyMonthDayListPair": "{first} e {second}", + "repeatYearlyMonthDayListStart": "{first}, {rest}", + "repeatYearlyMonthListPair": "{first} e {second}", + "repeatYearlyMonthListStart": "{first}, {rest}", + "repeatYearlyOnMonthDaySummary": "{frequency} no dia {day} de {month}", + "repeatYearlyInMonthsOnMonthDaySummary": "{frequency} no dia {day} de {months}", + "repeatYearlyInMonthsOnMonthDaysSummary": "{frequency} nos dias {days} de {months}", + "repeatYearlyInMonthsOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {months}} second{na segunda ocorrência de {days} de {months}} third{na terceira ocorrência de {days} de {months}} fourth{na quarta ocorrência de {days} de {months}} fifth{na quinta ocorrência de {days} de {months}} secondToLast{na penúltima ocorrência de {days} de {months}} last{na última ocorrência de {days} de {months}} other{em {days} de {months}}}", + "repeatYearlyOnMonthDaysSummary": "{frequency} nos dias {days} de {month}", + "@repeatYearlyOnMonthDaysSummary": { + "description": "Complete yearly recurrence summary for selected month days; placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "days": {"type": "String"} + } + }, + "repeatYearlyOnOrdinalSummary": "{frequency} {position, select, first{na primeira ocorrência de {days} de {month}} second{na segunda ocorrência de {days} de {month}} third{na terceira ocorrência de {days} de {month}} fourth{na quarta ocorrência de {days} de {month}} fifth{na quinta ocorrência de {days} de {month}} secondToLast{na penúltima ocorrência de {days} de {month}} last{na última ocorrência de {days} de {month}} other{em {days} de {month}}}", + "@repeatYearlyOnOrdinalSummary": { + "description": "Complete yearly ordinal recurrence summary; {position} selects the ordinal wording and placeholders may be reordered by locale.", + "placeholders": { + "frequency": {"type": "String"}, + "month": {"type": "String"}, + "position": {"type": "String"}, + "days": {"type": "String"} + } + } +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0b6078ba..f68b392b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,5 +1,11 @@ { "@@locale": "ru", + "repeatWeeklyDaySummary": "{dayKey, select, MO{понедельникам} TU{вторникам} WE{средам} TH{четвергам} FR{пятницам} SA{субботам} SU{воскресеньям} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first} и {second} числа каждого месяца", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}: {month}, {firstDay} и {secondDay} числа", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}: {firstMonth} и {secondMonth}, {day} числа", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}: {firstMonth} и {secondMonth}, {firstDay} и {secondDay} числа", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}: {firstMonth} и {secondMonth}, {days} числа", "appTitle": "BusyMax", "connectGoogleAccount": "Подключите аккаунты Google, Microsoft, Apple iCloud Calendar или Nextcloud.", "googlePermissionsConsentNotice": "На экране запроса доступа Google установите флажки «Google Календарь» и «Google Задачи».", @@ -656,7 +662,7 @@ "repeatEveryWeeks": "Каждые {count} нед.", "repeatEveryMonths": "Каждые {count} мес.", "repeatEveryYears": "Каждые {count} г.", - "repeatOnDaysSummary": "в дни {days}", + "repeatOnDaysSummary": "по {days}", "repeatOnMonthDaysSummary": "{days} числа месяца", "repeatOnOrdinalSummary": "{position, select, first{в первый {days}} second{во второй {days}} third{в третий {days}} fourth{в четвёртый {days}} fifth{в пятый {days}} secondToLast{в предпоследний {days}} last{в последний {days}} other{в {days}}}", "repeatInMonthsSummary": "в месяцы {months}", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 08f0ad16..02941eb0 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,5 +1,11 @@ { "@@locale": "vi", + "repeatWeeklyDaySummary": "{dayKey, select, MO{Thứ Hai} TU{Thứ Ba} WE{Thứ Tư} TH{Thứ Năm} FR{Thứ Sáu} SA{Thứ Bảy} SU{Chủ Nhật} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "vào các ngày {first} và {second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency} vào các ngày {firstDay} và {secondDay} của {month}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency} vào ngày {day} của {firstMonth} và {secondMonth}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency} vào các ngày {firstDay} và {secondDay} của {firstMonth} và {secondMonth}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency} vào các ngày {days} của {firstMonth} và {secondMonth}", "appTitle": "BusyMax", "connectGoogleAccount": "Kết nối tài khoản Google, Microsoft, Apple iCloud Calendar hoặc Nextcloud.", "googlePermissionsConsentNotice": "Trên màn hình cấp quyền của Google, hãy chọn cả quyền truy cập Lịch và Công việc.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8df6b96c..f39ef2cf 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,5 +1,11 @@ { "@@locale": "zh", + "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}和{secondMonth}的{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{days}", "appTitle": "BusyMax", "connectGoogleAccount": "连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。", "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", @@ -7,8 +13,8 @@ "finishSetup": "完成设置", "continueSetup": "继续", "onboardingSetupTitle": "设置 BusyMax", - "onboardingAccountsStepTitle": "连接帐户", - "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。", + "onboardingAccountsStepTitle": "连接账户", + "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。", "onboardingPreferencesStepTitle": "选择系统设置", "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", "signInWithGoogle": "使用 Google 登录", @@ -83,20 +89,20 @@ "scheduleUnavailable": "日程不可用", "scheduleNoSources": "没有可见的日历或任务列表", "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", - "scheduleSignInRequired": "连接帐户", + "scheduleSignInRequired": "连接账户", "scheduleSignInDescription": "登录以同步日历和任务。", "scheduleNoSearchResults": "没有匹配的日程或任务", "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", "trayShowBusyMax": "显示 BusyMax", - "trayNewEvent": "新活动…", + "trayNewEvent": "新日程…", "trayNewTask": "新任务…", "trayToday": "今天", "trayAllDay": "全天", "trayNow": "现在", - "trayCalendarEvent": "日历活动", - "trayUntitledEvent": "无标题活动", + "trayCalendarEvent": "日历日程", + "trayUntitledEvent": "无标题日程", "trayNothingElseToday": "今天没有其他内容", "trayTasksDueToday": "{count, plural, =1{今天有 1 个任务到期} other{今天有 {count} 个任务到期}}", "@trayTasksDueToday": { @@ -240,7 +246,7 @@ "feedbackDetailedMessage": "详细信息", "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", "feedbackIncludeTechnicalDetails": "包含技术详情", - "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", "feedbackCategoryRequired": "请选择类别。", "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", @@ -254,24 +260,24 @@ "toggleSidebar": "显示或隐藏侧边栏", "showSidebar": "显示侧边栏面板", "hideSidebar": "隐藏侧边栏面板", - "accounts": "帐户", - "currentAccount": "当前帐户", - "switchAccount": "切换帐户", - "addGoogleAccount": "添加 Google 帐户", - "addMicrosoftAccount": "添加 Microsoft 帐户", + "accounts": "账户", + "currentAccount": "当前账户", + "switchAccount": "切换账户", + "addGoogleAccount": "添加 Google 账户", + "addMicrosoftAccount": "添加 Microsoft 账户", "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "已登录", - "removeAccount": "移除帐户…", - "removingAccount": "正在移除帐户…", - "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccount": "移除账户…", + "removingAccount": "正在移除账户…", + "removeAccountDescription": "停止同步并从此设备移除此账户的数据。", "removeAccountTitle": "从 BusyMax 中移除 {account}?", - "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。", - "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 账户的访问权限", "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", - "removeAccountAction": "移除帐户", - "removeAccountFailed": "无法完成帐户移除。请重试。", - "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", + "removeAccountAction": "移除账户", + "removeAccountFailed": "无法完成账户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。", "newTaskList": "新任务列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", @@ -291,7 +297,7 @@ "refreshList": "刷新列表", "refreshAll": "全部刷新", "listRefreshed": "列表已刷新。", - "allTasksRefreshed": "所有帐户均已刷新。", + "allTasksRefreshed": "所有账户均已刷新。", "exportedFile": "已导出到 {path}", "exportFailed": "导出失败:{error}", "refreshFailed": "刷新失败:{error}", @@ -299,7 +305,7 @@ "signInToViewTasks": "登录以查看任务。", "noTasks": "没有任务。", "noTasksYet": "还没有任务", - "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksYetMessage": "创建任务或刷新账户以开始使用。", "noTasksInList": "此列表中没有任务。", "overdue": "已逾期", "today": "今天", @@ -357,7 +363,7 @@ "advancedSection": "高级", "addCategory": "添加类别", "list": "列表", - "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 账户的列表之间移动任务。", "createSubtask": "创建子任务", "subtasks": "子任务", "moveToTop": "移到顶部", @@ -375,7 +381,7 @@ "localState": "本地状态", "pendingSync": "等待同步", "synced": "已同步", - "account": "帐户", + "account": "账户", "sync": "同步", "forceFullResync": "强制完全重新同步", "forceFullResyncDescription": "从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。", @@ -493,7 +499,7 @@ "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", "davUnsupportedServer": "不支持此服务器或提供商配置。", "collectionSettings": "日历和任务列表", - "calendarContent": "日历活动", + "calendarContent": "日历日程", "taskContent": "任务", "readOnlySharedCollection": "只读", "pendingLocally": "本地待处理", @@ -526,9 +532,9 @@ } } }, - "collectionSupportsEvents": "活动日历", + "collectionSupportsEvents": "日程日历", "collectionSupportsTasks": "任务列表", - "collectionSupportsEventsAndTasks": "活动和任务", + "collectionSupportsEventsAndTasks": "日程和任务", "writableCollection": "可写", "sharedCollection": "已共享", "collectionLastSynced": "上次同步:{time}", @@ -565,17 +571,17 @@ } }, "conflictResolutionFailed": "无法解决冲突。", - "recurringEventScope": "重复活动范围", + "recurringEventScope": "重复日程范围", "entireSeries": "整个系列", "singleOccurrence": "此事件", "thisAndFollowingEvents": "此事件及后续事件", "thisAndFutureUnavailable": "此提供商不支持。", - "thisAndFutureMoveUnavailable": "无法安全移动此活动及后续活动。请选择此活动或整个系列。", - "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此活动。", - "copyEventAndDeleteOriginal": "复制活动并删除原活动?", - "copyEventMoveWarning": "BusyMax 无法将此活动直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", + "thisAndFutureMoveUnavailable": "无法安全移动此日程及后续日程。请选择此日程或整个系列。", + "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此日程。", + "copyEventAndDeleteOriginal": "复制日程并删除原日程?", + "copyEventMoveWarning": "BusyMax 无法将此日程直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", "copyAndDelete": "复制并删除", - "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。", + "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", "taskListCreateFailed": "无法创建任务列表:{error}", @@ -683,13 +689,13 @@ "allowNewTimeProposals": "允许提出新时间", "allowNewTimeProposalsDescription": "参与者可以建议其他会议时间。", "notifyGuestsTitle": "通知参与者?", - "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或活动更新吗?", + "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或日程更新吗?", "notifyGuestsDeleteMessage": "此会议有参与者。删除时发送取消通知吗?", "sendUpdates": "发送更新", "sendCancellation": "发送取消通知", "doNotSend": "不发送", "microsoftNotifyGuestsSaveTitle": "保存会议?", - "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或活动更新。", + "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或日程更新。", "microsoftNotifyGuestsDeleteTitle": "删除会议?", "microsoftNotifyGuestsDeleteMessage": "Microsoft 将向参与者发送取消通知。", "organizer": "组织者", @@ -719,7 +725,7 @@ "setAction": "设置", "removeFromMyCalendars": "从“我的日历”中移除", "removeAction": "移除", - "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", + "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其日程不会被删除。", "calendarCannotRemove": "无法从此账户删除或移除此日历。", "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", "calendarSubscriptions": "日历订阅", @@ -783,7 +789,7 @@ } } }, - "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的活动。已发布的日历不会更改。", + "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的日程。已发布的日历不会更改。", "addSubscriptionAction": "添加订阅", "subscriptionOperationFailed": "日历订阅失败:{error}", "@subscriptionOperationFailed": { @@ -795,10 +801,10 @@ }, "subscriptions": "订阅", "calendarImport": "日历导入", - "calendarImportDescription": "选择文件,查看其中的活动,然后选择接收这些活动的可写日历。", + "calendarImportDescription": "选择文件,查看其中的日程,然后选择接收这些日程的可写日历。", "importIcsFile": "导入 .ics 文件", - "importIcsPreview": "导入日历活动", - "importEventsFound": "可导入活动集:{count}", + "importIcsPreview": "导入日历日程", + "importEventsFound": "可导入日程集:{count}", "@importEventsFound": { "placeholders": { "count": { @@ -806,7 +812,7 @@ } } }, - "importInvalidEvents": "无效活动:{count}", + "importInvalidEvents": "无效日程:{count}", "@importInvalidEvents": { "placeholders": { "count": { @@ -824,7 +830,7 @@ }, "noWritableCalendars": "没有可用的可写目标日历。", "importDestinationCalendar": "目标日历", - "importIcsConfirm": "导入活动", + "importIcsConfirm": "导入日程", "importIcsComplete": "导入完成", "importQueued": "已导入或已排队:{count}", "@importQueued": { diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index d1ceade4..762df8a4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,5 +1,11 @@ { "@@locale": "zh_Hans", + "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}和{secondMonth}的{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{days}", "appTitle": "BusyMax", "connectGoogleAccount": "连接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 账户。", "googlePermissionsConsentNotice": "在 Google 权限页面上,同时选择日历和任务权限。", @@ -7,8 +13,8 @@ "finishSetup": "完成设置", "continueSetup": "继续", "onboardingSetupTitle": "设置 BusyMax", - "onboardingAccountsStepTitle": "连接帐户", - "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。", + "onboardingAccountsStepTitle": "连接账户", + "onboardingAccountsStepDescription": "添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。", "onboardingPreferencesStepTitle": "选择系统设置", "onboardingPreferencesStepDescription": "打开日程前,请设置桌面行为、提醒、通知详细程度和外观。", "signInWithGoogle": "使用 Google 登录", @@ -83,20 +89,20 @@ "scheduleUnavailable": "日程不可用", "scheduleNoSources": "没有可见的日历或任务列表", "scheduleNoSourcesDescription": "请在设置中选择要显示的内容,然后刷新。", - "scheduleSignInRequired": "连接帐户", + "scheduleSignInRequired": "连接账户", "scheduleSignInDescription": "登录以同步日历和任务。", "scheduleNoSearchResults": "没有匹配的日程或任务", "scheduleNoSearchResultsDescription": "请尝试其他搜索内容或清除当前筛选条件。", "refresh": "刷新", "trayOpenBusyMax": "打开 BusyMax", "trayShowBusyMax": "显示 BusyMax", - "trayNewEvent": "新活动…", + "trayNewEvent": "新日程…", "trayNewTask": "新任务…", "trayToday": "今天", "trayAllDay": "全天", "trayNow": "现在", - "trayCalendarEvent": "日历活动", - "trayUntitledEvent": "无标题活动", + "trayCalendarEvent": "日历日程", + "trayUntitledEvent": "无标题日程", "trayNothingElseToday": "今天没有其他内容", "trayTasksDueToday": "{count, plural, =1{今天有 1 个任务到期} other{今天有 {count} 个任务到期}}", "@trayTasksDueToday": { @@ -240,7 +246,7 @@ "feedbackDetailedMessage": "详细信息", "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", "feedbackIncludeTechnicalDetails": "包含技术详情", - "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。", + "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", "feedbackCategoryRequired": "请选择类别。", "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", @@ -254,24 +260,24 @@ "toggleSidebar": "显示或隐藏侧边栏", "showSidebar": "显示侧边栏面板", "hideSidebar": "隐藏侧边栏面板", - "accounts": "帐户", - "currentAccount": "当前帐户", - "switchAccount": "切换帐户", - "addGoogleAccount": "添加 Google 帐户", - "addMicrosoftAccount": "添加 Microsoft 帐户", + "accounts": "账户", + "currentAccount": "当前账户", + "switchAccount": "切换账户", + "addGoogleAccount": "添加 Google 账户", + "addMicrosoftAccount": "添加 Microsoft 账户", "googleProvider": "Google", "microsoftProvider": "Microsoft", "signedInAccount": "已登录", - "removeAccount": "移除帐户…", - "removingAccount": "正在移除帐户…", - "removeAccountDescription": "停止同步并从此设备移除此帐户的数据。", + "removeAccount": "移除账户…", + "removingAccount": "正在移除账户…", + "removeAccountDescription": "停止同步并从此设备移除此账户的数据。", "removeAccountTitle": "从 BusyMax 中移除 {account}?", - "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。", - "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 帐户的访问权限", + "removeAccountConfirmation": "这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。", + "revokeGoogleAccess": "同时撤销 BusyMax 对此 Google 账户的访问权限", "revokeGoogleAccessDescription": "重新连接之前,您需要再次授予访问权限。", - "removeAccountAction": "移除帐户", - "removeAccountFailed": "无法完成帐户移除。请重试。", - "accountRemovedGoogleRevokeFailed": "该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。", + "removeAccountAction": "移除账户", + "removeAccountFailed": "无法完成账户移除。请重试。", + "accountRemovedGoogleRevokeFailed": "该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。", "newTaskList": "新任务列表", "signInToViewTaskLists": "登录以查看任务列表。", "noTaskListsSynced": "尚未同步任何任务列表。", @@ -291,7 +297,7 @@ "refreshList": "刷新列表", "refreshAll": "全部刷新", "listRefreshed": "列表已刷新。", - "allTasksRefreshed": "所有帐户均已刷新。", + "allTasksRefreshed": "所有账户均已刷新。", "exportedFile": "已导出到 {path}", "exportFailed": "导出失败:{error}", "refreshFailed": "刷新失败:{error}", @@ -299,7 +305,7 @@ "signInToViewTasks": "登录以查看任务。", "noTasks": "没有任务。", "noTasksYet": "还没有任务", - "noTasksYetMessage": "创建任务或刷新帐户以开始使用。", + "noTasksYetMessage": "创建任务或刷新账户以开始使用。", "noTasksInList": "此列表中没有任务。", "overdue": "已逾期", "today": "今天", @@ -357,7 +363,7 @@ "advancedSection": "高级", "addCategory": "添加类别", "list": "列表", - "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。", + "microsoftMoveUnsupported": "此版本不支持在 Microsoft To Do 账户的列表之间移动任务。", "createSubtask": "创建子任务", "subtasks": "子任务", "moveToTop": "移到顶部", @@ -375,7 +381,7 @@ "localState": "本地状态", "pendingSync": "等待同步", "synced": "已同步", - "account": "帐户", + "account": "账户", "sync": "同步", "forceFullResync": "强制完全重新同步", "forceFullResyncDescription": "从每个已连接的账号中重新完整加载所有数据。请仅在排查同步问题时使用此功能。", @@ -493,7 +499,7 @@ "davPermissionChanged": "服务器权限已更改。待处理的编辑已暂停。", "davUnsupportedServer": "不支持此服务器或提供商配置。", "collectionSettings": "日历和任务列表", - "calendarContent": "日历活动", + "calendarContent": "日历日程", "taskContent": "任务", "readOnlySharedCollection": "只读", "pendingLocally": "本地待处理", @@ -526,9 +532,9 @@ } } }, - "collectionSupportsEvents": "活动日历", + "collectionSupportsEvents": "日程日历", "collectionSupportsTasks": "任务列表", - "collectionSupportsEventsAndTasks": "活动和任务", + "collectionSupportsEventsAndTasks": "日程和任务", "writableCollection": "可写", "sharedCollection": "已共享", "collectionLastSynced": "上次同步:{time}", @@ -565,17 +571,17 @@ } }, "conflictResolutionFailed": "无法解决冲突。", - "recurringEventScope": "重复活动范围", + "recurringEventScope": "重复日程范围", "entireSeries": "整个系列", "singleOccurrence": "此事件", "thisAndFollowingEvents": "此事件及后续事件", "thisAndFutureUnavailable": "此提供商不支持。", - "thisAndFutureMoveUnavailable": "无法安全移动此活动及后续活动。请选择此活动或整个系列。", - "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此活动。", - "copyEventAndDeleteOriginal": "复制活动并删除原活动?", - "copyEventMoveWarning": "BusyMax 无法将此活动直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", + "thisAndFutureMoveUnavailable": "无法安全移动此日程及后续日程。请选择此日程或整个系列。", + "entireSeriesMoveUnavailable": "本地没有可用的重复规则。请仅移动此日程。", + "copyEventAndDeleteOriginal": "复制日程并删除原日程?", + "copyEventMoveWarning": "BusyMax 无法将此日程直接从 {source} 移动到 {destination}。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。", "copyAndDelete": "复制并删除", - "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。", + "chooseRecurringEventScope": "选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。", "taskDueBeforeStart": "截止时间不能早于开始时间。", "taskStartDueTimeModeMismatch": "请同时设置开始和截止时间,或将任务设为全天。", "taskListCreateFailed": "无法创建任务列表:{error}", @@ -683,13 +689,13 @@ "allowNewTimeProposals": "允许提出新时间", "allowNewTimeProposalsDescription": "参与者可以建议其他会议时间。", "notifyGuestsTitle": "通知参与者?", - "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或活动更新吗?", + "notifyGuestsSaveMessage": "此会议有参与者。保存时发送邀请或日程更新吗?", "notifyGuestsDeleteMessage": "此会议有参与者。删除时发送取消通知吗?", "sendUpdates": "发送更新", "sendCancellation": "发送取消通知", "doNotSend": "不发送", "microsoftNotifyGuestsSaveTitle": "保存会议?", - "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或活动更新。", + "microsoftNotifyGuestsSaveMessage": "Microsoft 将向参与者发送邀请或日程更新。", "microsoftNotifyGuestsDeleteTitle": "删除会议?", "microsoftNotifyGuestsDeleteMessage": "Microsoft 将向参与者发送取消通知。", "organizer": "组织者", @@ -719,7 +725,7 @@ "setAction": "设置", "removeFromMyCalendars": "从“我的日历”中移除", "removeAction": "移除", - "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其活动不会被删除。", + "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其日程不会被删除。", "calendarCannotRemove": "无法从此账户删除或移除此日历。", "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", "calendarSubscriptions": "日历订阅", @@ -783,7 +789,7 @@ } } }, - "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的活动。已发布的日历不会更改。", + "unsubscribeCalendarConfirmation": "这会移除本地订阅及其缓存的日程。已发布的日历不会更改。", "addSubscriptionAction": "添加订阅", "subscriptionOperationFailed": "日历订阅失败:{error}", "@subscriptionOperationFailed": { @@ -795,10 +801,10 @@ }, "subscriptions": "订阅", "calendarImport": "日历导入", - "calendarImportDescription": "选择文件,查看其中的活动,然后选择接收这些活动的可写日历。", + "calendarImportDescription": "选择文件,查看其中的日程,然后选择接收这些日程的可写日历。", "importIcsFile": "导入 .ics 文件", - "importIcsPreview": "导入日历活动", - "importEventsFound": "可导入活动集:{count}", + "importIcsPreview": "导入日历日程", + "importEventsFound": "可导入日程集:{count}", "@importEventsFound": { "placeholders": { "count": { @@ -806,7 +812,7 @@ } } }, - "importInvalidEvents": "无效活动:{count}", + "importInvalidEvents": "无效日程:{count}", "@importInvalidEvents": { "placeholders": { "count": { @@ -824,7 +830,7 @@ }, "noWritableCalendars": "没有可用的可写目标日历。", "importDestinationCalendar": "目标日历", - "importIcsConfirm": "导入活动", + "importIcsConfirm": "导入日程", "importIcsComplete": "导入完成", "importQueued": "已导入或已排队:{count}", "@importQueued": { diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 764f2de4..751461e8 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,5 +1,11 @@ { "@@locale": "zh_Hant", + "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", + "repeatOnTwoMonthDaysSummary": "{first}、{second}", + "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaySummary": "{frequency}{firstMonth}和{secondMonth}的{day}", + "repeatYearlyInTwoMonthsOnTwoMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{firstDay}和{secondDay}", + "repeatYearlyInTwoMonthsOnMonthDaysSummary": "{frequency}{firstMonth}和{secondMonth}的{days}", "appTitle": "BusyMax", "connectGoogleAccount": "連接 Google、Microsoft、Apple iCloud Calendar 或 Nextcloud 帳戶。", "googlePermissionsConsentNotice": "在 Google 權限畫面中,同時選取行事曆和待辦事項權限。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index e0352ebe..4b7c16fa 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -125,6 +125,7 @@ abstract class AppLocalizations { Locale('ja'), Locale('ko'), Locale('pt'), + Locale('pt', 'PT'), Locale('ru'), Locale('vi'), Locale('zh'), @@ -132,6 +133,63 @@ abstract class AppLocalizations { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; + /// Sentence-form weekday used inside a weekly recurrence summary. {dayKey} is MO through SU; {day} is the locale-formatted fallback. + /// + /// In en, this message translates to: + /// **'{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} other{{day}}}'** + String repeatWeeklyDaySummary(String dayKey, String day); + + /// Sentence fragment for a monthly recurrence on exactly two dates. + /// + /// In en, this message translates to: + /// **'on days {first} and {second}'** + String repeatOnTwoMonthDaysSummary(String first, String second); + + /// Complete yearly recurrence summary for exactly two dates in one month. + /// + /// In en, this message translates to: + /// **'{frequency} on days {firstDay} and {secondDay} of {month}'** + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ); + + /// Complete yearly recurrence summary for one date in exactly two months. + /// + /// In en, this message translates to: + /// **'{frequency} on day {day} of {firstMonth} and {secondMonth}'** + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ); + + /// Complete yearly recurrence summary for exactly two dates in exactly two months. + /// + /// In en, this message translates to: + /// **'{frequency} on days {firstDay} and {secondDay} of {firstMonth} and {secondMonth}'** + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ); + + /// Complete yearly recurrence summary for three or more dates in exactly two months. + /// + /// In en, this message translates to: + /// **'{frequency} on days {days} of {firstMonth} and {secondMonth}'** + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ); + /// No description provided for @appTitle. /// /// In en, this message translates to: @@ -4305,6 +4363,18 @@ AppLocalizations lookupAppLocalizations(Locale locale) { } } + // Lookup logic when language+country codes are specified. + switch (locale.languageCode) { + case 'pt': + { + switch (locale.countryCode) { + case 'PT': + return AppLocalizationsPtPt(); + } + break; + } + } + // Lookup logic when only language code is specified. switch (locale.languageCode) { case 'ar': diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index e022335e..d2435ee0 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'الاثنين', + 'TU': 'الثلاثاء', + 'WE': 'الأربعاء', + 'TH': 'الخميس', + 'FR': 'الجمعة', + 'SA': 'السبت', + 'SU': 'الأحد', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'في يومي $first و$second من الشهر'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency في يومي $firstDay و$secondDay من $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency في اليوم $day من شهري $firstMonth و$secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency في يومي $firstDay و$secondDay من شهري $firstMonth و$secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency في الأيام $days من شهري $firstMonth و$secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -138,7 +199,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String calendarCreateFailed(String error) { - return 'تعذّر إنشاء التقويم: $error'; + return 'تعذّر إنشاء التقويم: ⁨$error⁩'; } @override @@ -147,12 +208,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String calendarUpdateFailed(String error) { - return 'تعذّر تحديث التقويم: $error'; + return 'تعذّر تحديث التقويم: ⁨$error⁩'; } @override String calendarDeleteFailed(String error) { - return 'تعذّر حذف التقويم: $error'; + return 'تعذّر حذف التقويم: ⁨$error⁩'; } @override @@ -178,7 +239,20 @@ class AppLocalizationsAr extends AppLocalizations { @override String moreItems(int count) { - return '+⁨$count⁩ عناصر أخرى'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '+⁨$countString⁩ عنصر آخر', + many: '+⁨$countString⁩ عنصرًا آخر', + few: '+⁨$countString⁩ عناصر أخرى', + two: '+عنصران آخران', + one: '+عنصر واحد آخر', + ); + return '$_temp0'; } @override @@ -249,9 +323,9 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count مهمة مستحقة اليوم', - many: '$count مهمة مستحقة اليوم', - few: '$count مهام مستحقة اليوم', + other: '⁨$count⁩ مهمة مستحقة اليوم', + many: '⁨$count⁩ مهمة مستحقة اليوم', + few: '⁨$count⁩ مهام مستحقة اليوم', two: 'مهمتان مستحقتان اليوم', one: 'مهمة واحدة مستحقة اليوم', zero: 'لا توجد مهام مستحقة اليوم', @@ -282,7 +356,10 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'تمت المزامنة قبل $count دقائق', + other: 'تمت المزامنة قبل ⁨$count⁩ دقيقة', + many: 'تمت المزامنة قبل ⁨$count⁩ دقيقة', + few: 'تمت المزامنة قبل ⁨$count⁩ دقائق', + two: 'تمت المزامنة قبل دقيقتين', one: 'تمت المزامنة قبل دقيقة واحدة', ); return '$_temp0'; @@ -293,7 +370,10 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'تمت المزامنة قبل $count ساعات', + other: 'تمت المزامنة قبل ⁨$count⁩ ساعة', + many: 'تمت المزامنة قبل ⁨$count⁩ ساعة', + few: 'تمت المزامنة قبل ⁨$count⁩ ساعات', + two: 'تمت المزامنة قبل ساعتين', one: 'تمت المزامنة قبل ساعة واحدة', ); return '$_temp0'; @@ -304,7 +384,10 @@ class AppLocalizationsAr extends AppLocalizations { String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'تمت المزامنة قبل $count أيام', + other: 'تمت المزامنة قبل ⁨$count⁩ يوم', + many: 'تمت المزامنة قبل ⁨$count⁩ يومًا', + few: 'تمت المزامنة قبل ⁨$count⁩ أيام', + two: 'تمت المزامنة قبل يومين', one: 'تمت المزامنة قبل يوم واحد', ); return '$_temp0'; @@ -396,7 +479,7 @@ class AppLocalizationsAr extends AppLocalizations { String get guests => 'المدعوون'; @override - String get noGuests => 'لا يوجد ضيوف'; + String get noGuests => 'لا يوجد مدعوون'; @override String get attendeeRequired => 'مطلوب'; @@ -420,32 +503,32 @@ class AppLocalizationsAr extends AppLocalizations { String get requestResponses => 'طلب الردود'; @override - String get requestResponsesDescription => 'اطلب من الضيوف الرد على الدعوة.'; + String get requestResponsesDescription => 'اطلب من المدعوين الرد على الدعوة.'; @override - String get hideGuestList => 'إخفاء قائمة الضيوف'; + String get hideGuestList => 'إخفاء قائمة المدعوين'; @override String get hideGuestListDescription => - 'لا يمكن للضيوف رؤية المدعوين الآخرين.'; + 'لا يمكن للمدعوين رؤية المدعوين الآخرين.'; @override String get allowNewTimeProposals => 'السماح باقتراح أوقات جديدة'; @override String get allowNewTimeProposalsDescription => - 'يمكن للضيوف اقتراح وقت مختلف للاجتماع.'; + 'يمكن للمدعوين اقتراح وقت مختلف للاجتماع.'; @override - String get notifyGuestsTitle => 'إبلاغ الضيوف؟'; + String get notifyGuestsTitle => 'إبلاغ المدعوين؟'; @override String get notifyGuestsSaveMessage => - 'يضم هذا الاجتماع ضيوفًا. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟'; + 'يضم هذا الاجتماع مدعوين. هل تريد إرسال الدعوات أو تحديثات الحدث عند حفظه؟'; @override String get notifyGuestsDeleteMessage => - 'يضم هذا الاجتماع ضيوفًا. هل تريد إرسال إلغاء عند حذفه؟'; + 'يضم هذا الاجتماع مدعوين. هل تريد إرسال إلغاء عند حذفه؟'; @override String get sendUpdates => 'إرسال التحديثات'; @@ -461,14 +544,14 @@ class AppLocalizationsAr extends AppLocalizations { @override String get microsoftNotifyGuestsSaveMessage => - 'سترسل Microsoft الدعوات أو تحديثات الحدث إلى الضيوف.'; + 'سترسل Microsoft الدعوات أو تحديثات الحدث إلى المدعوين.'; @override String get microsoftNotifyGuestsDeleteTitle => 'حذف الاجتماع؟'; @override String get microsoftNotifyGuestsDeleteMessage => - 'سترسل Microsoft إلغاءً إلى الضيوف.'; + 'سترسل Microsoft إلغاءً إلى المدعوين.'; @override String get organizer => 'المنظّم'; @@ -477,7 +560,7 @@ class AppLocalizationsAr extends AppLocalizations { String get yourResponse => 'ردك'; @override - String get guestResponses => 'ردود الضيوف'; + String get guestResponses => 'ردود المدعوين'; @override String get respond => 'الرد'; @@ -514,7 +597,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String invitationResponseFailed(String error) { - return 'تعذّر إرسال ردك: $error'; + return 'تعذّر إرسال ردك: ⁨$error⁩'; } @override @@ -564,12 +647,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: 'قبل ⁨$minutes⁩ دقيقة', - many: 'قبل ⁨$minutes⁩ دقيقة', - few: 'قبل ⁨$minutes⁩ دقائق', + other: 'قبل ⁨$minutesString⁩ دقيقة', + many: 'قبل ⁨$minutesString⁩ دقيقة', + few: 'قبل ⁨$minutesString⁩ دقائق', two: 'قبل دقيقتين', one: 'قبل دقيقة واحدة', zero: 'عند البدء', @@ -582,12 +669,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: 'قبل ⁨$hours⁩ ساعة', - many: 'قبل ⁨$hours⁩ ساعة', - few: 'قبل ⁨$hours⁩ ساعات', + other: 'قبل ⁨$hoursString⁩ ساعة', + many: 'قبل ⁨$hoursString⁩ ساعة', + few: 'قبل ⁨$hoursString⁩ ساعات', two: 'قبل ساعتين', one: 'قبل ساعة واحدة', zero: 'عند البدء', @@ -597,12 +688,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: 'قبل ⁨$days⁩ يوم', - many: 'قبل ⁨$days⁩ يومًا', - few: 'قبل ⁨$days⁩ أيام', + other: 'قبل ⁨$daysString⁩ يوم', + many: 'قبل ⁨$daysString⁩ يومًا', + few: 'قبل ⁨$daysString⁩ أيام', two: 'قبل يومين', one: 'قبل يوم واحد', zero: 'في اليوم نفسه', @@ -648,7 +744,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String tasksInList(String title) { - return 'المهام في ⁨$title⁩'; + return 'المهام في ⁨⁨$title⁩⁩'; } @override @@ -828,7 +924,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String feedbackSuccess(String id) { - return 'تم إرسال الملاحظات. المرجع: ⁨$id⁩'; + return 'تم إرسال الملاحظات. المرجع: ⁨⁨$id⁩⁩'; } @override @@ -876,7 +972,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String removeAccountTitle(String account) { - return 'إزالة ⁨$account⁩ من BusyMax؟'; + return 'إزالة ⁨⁨$account⁩⁩ من BusyMax؟'; } @override @@ -906,17 +1002,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'تعذّر إنشاء قائمة المهام: $error'; + return 'تعذّر إنشاء قائمة المهام: ⁨$error⁩'; } @override String taskListRenameFailed(String error) { - return 'تعذّر إعادة تسمية قائمة المهام: $error'; + return 'تعذّر إعادة تسمية قائمة المهام: ⁨$error⁩'; } @override String taskListDeleteFailed(String error) { - return 'تعذّر حذف قائمة المهام: $error'; + return 'تعذّر حذف قائمة المهام: ⁨$error⁩'; } @override @@ -960,17 +1056,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteListConfirmation(String title) { - return 'حذف \"⁨$title⁩\" من Google Tasks؟'; + return 'حذف \"⁨⁨$title⁩⁩\" من Google Tasks؟'; } @override String deleteTaskListConfirmation(String title) { - return 'حذف \"$title\" وجميع مهامها؟'; + return 'حذف \"⁨$title⁩\" وجميع مهامها؟'; } @override String unshareTaskListConfirmation(String title) { - return 'إلغاء مشاركة \"$title\" من هذا الحساب؟'; + return 'إلغاء مشاركة \"⁨$title⁩\" من هذا الحساب؟'; } @override @@ -1002,17 +1098,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String exportedFile(String path) { - return 'تم التصدير إلى ⁨$path⁩'; + return 'تم التصدير إلى ⁨⁨$path⁩⁩'; } @override String exportFailed(String error) { - return 'فشل التصدير: ⁨$error⁩'; + return 'فشل التصدير: ⁨⁨$error⁩⁩'; } @override String refreshFailed(String error) { - return 'فشل التحديث: ⁨$error⁩'; + return 'فشل التحديث: ⁨⁨$error⁩⁩'; } @override @@ -1053,12 +1149,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String duePrefix(String date) { - return 'مستحقة في ⁨$date⁩'; + return 'مستحقة في ⁨⁨$date⁩⁩'; } @override String dateTimeDisplay(String date, String time) { - return '⁨$date⁩ · ⁨$time⁩'; + return '⁨⁨$date⁩⁩ · ⁨⁨$time⁩⁩'; } @override @@ -1114,7 +1210,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String completionPercent(int percent) { - return 'اكتمل بنسبة $percent٪'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'اكتمل بنسبة $percentString٪'; } @override @@ -1128,17 +1228,29 @@ class AppLocalizationsAr extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'الأولوية $priority · عالية'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'الأولوية $priorityString · عالية'; } @override String priorityMediumValue(int priority) { - return 'الأولوية $priority · متوسطة'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'الأولوية $priorityString · متوسطة'; } @override String priorityLowValue(int priority) { - return 'الأولوية $priority · منخفضة'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'الأولوية $priorityString · منخفضة'; } @override @@ -1267,7 +1379,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'يحتوي هذا التاريخ على $count من التذكيرات المرتبطة. هل تريد الاحتفاظ بها في تاريخها ووقتها الحاليين؟'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'يحتوي هذا التاريخ على $countString من التذكيرات المرتبطة. هل تريد الاحتفاظ بها في تاريخها ووقتها الحاليين؟'; } @override @@ -1392,12 +1508,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatEveryDays(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'كل $count يوم', - many: 'كل $count يومًا', - few: 'كل $count أيام', + other: 'كل $countString يوم', + many: 'كل $countString يومًا', + few: 'كل $countString أيام', two: 'كل يومين', one: 'كل يوم', ); @@ -1406,12 +1526,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatEveryWeeks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'كل $count أسبوع', - many: 'كل $count أسبوعًا', - few: 'كل $count أسابيع', + other: 'كل $countString أسبوع', + many: 'كل $countString أسبوعًا', + few: 'كل $countString أسابيع', two: 'كل أسبوعين', one: 'كل أسبوع', ); @@ -1420,12 +1544,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatEveryMonths(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'كل $count شهر', - many: 'كل $count شهرًا', - few: 'كل $count أشهر', + other: 'كل $countString شهر', + many: 'كل $countString شهرًا', + few: 'كل $countString أشهر', two: 'كل شهرين', one: 'كل شهر', ); @@ -1434,12 +1562,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatEveryYears(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'كل $count سنة', - many: 'كل $count سنة', - few: 'كل $count سنوات', + other: 'كل $countString سنة', + many: 'كل $countString سنة', + few: 'كل $countString سنوات', two: 'كل سنتين', one: 'كل سنة', ); @@ -1478,12 +1610,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count مرة', - many: '$count مرة', - few: '$count مرات', + other: '$countString مرة', + many: '$countString مرة', + few: '$countString مرات', two: 'مرتين', one: 'مرة واحدة', ); @@ -1492,7 +1628,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatUntilSummary(String date) { - return 'حتى $date'; + return 'حتى ⁨$date⁩'; } @override @@ -1501,7 +1637,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String recurrenceUnsupportedByProvider(String provider) { - return 'لا يمكن استخدام هذا التكرار مع $provider.'; + return 'لا يمكن استخدام هذا التكرار مع ⁨$provider⁩.'; } @override @@ -1564,7 +1700,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String taskDuplicateFailed(String error) { - return 'تعذّر تكرار المهمة: $error'; + return 'تعذّر تكرار المهمة: ⁨$error⁩'; } @override @@ -1584,7 +1720,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return 'حذف «⁨$title⁩»؟'; + return 'حذف «⁨⁨$title⁩⁩»؟'; } @override @@ -1661,7 +1797,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String syncFailed(String error) { - return 'فشلت المزامنة: ⁨$error⁩'; + return 'فشلت المزامنة: ⁨⁨$error⁩⁩'; } @override @@ -1757,7 +1893,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'مراجعة Discovery: ⁨$revision⁩'; + return 'مراجعة Discovery: ⁨⁨$revision⁩⁩'; } @override @@ -1784,17 +1920,21 @@ class AppLocalizationsAr extends AppLocalizations { @override String pendingOpListId(String id) { - return 'القائمة=⁨$id⁩'; + return 'القائمة=⁨⁨$id⁩⁩'; } @override String pendingOpTaskId(String id) { - return 'المهمة=⁨$id⁩'; + return 'المهمة=⁨⁨$id⁩⁩'; } @override String pendingOpAttempts(int count) { - return 'المحاولات=⁨$count⁩'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'المحاولات=⁨$countString⁩'; } @override @@ -1831,7 +1971,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String syncFailureNotificationBody(String message) { - return 'فشلت المزامنة في الخلفية. ⁨$message⁩'; + return 'فشلت المزامنة في الخلفية. ⁨⁨$message⁩⁩'; } @override @@ -1839,7 +1979,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'تم حظر تغيير محلي معلق. ⁨$summary⁩'; + return 'تم حظر تغيير محلي معلق. ⁨⁨$summary⁩⁩'; } @override @@ -1847,12 +1987,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', - many: 'هناك ⁨$count⁩ مهمة مستحقة اليوم.', - few: 'هناك ⁨$count⁩ مهام مستحقة اليوم.', + other: 'هناك ⁨$countString⁩ مهمة مستحقة اليوم.', + many: 'هناك ⁨$countString⁩ مهمة مستحقة اليوم.', + few: 'هناك ⁨$countString⁩ مهام مستحقة اليوم.', two: 'هناك مهمتان مستحقتان اليوم.', one: 'هناك مهمة واحدة مستحقة اليوم.', zero: 'لا توجد مهام مستحقة اليوم.', @@ -1905,7 +2049,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'الأسبوع ⁨$number⁩'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'الأسبوع ⁨$numberString⁩'; } @override @@ -1913,12 +2061,16 @@ class AppLocalizationsAr extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '⁨$count⁩ عنصر', - many: '⁨$count⁩ عنصرًا', - few: '⁨$count⁩ عناصر', + other: '⁨$countString⁩ عنصر', + many: '⁨$countString⁩ عنصرًا', + few: '⁨$countString⁩ عناصر', two: 'عنصران', one: 'عنصر واحد', zero: 'لا عناصر', @@ -2076,7 +2228,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String davLastSuccessfulSync(String time) { - return 'آخر مزامنة ناجحة: $time'; + return 'آخر مزامنة ناجحة: ⁨$time⁩'; } @override @@ -2087,7 +2239,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String nextcloudServerHost(String host) { - return 'الخادم: $host'; + return 'الخادم: ⁨$host⁩'; } @override @@ -2107,12 +2259,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String collectionLastSynced(String time) { - return 'آخر مزامنة: $time'; + return 'آخر مزامنة: ⁨$time⁩'; } @override String collectionSyncError(String code) { - return 'مشكلة في المزامنة: $code'; + return 'مشكلة في المزامنة: ⁨$code⁩'; } @override @@ -2120,12 +2272,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String remoteChangedAt(String time) { - return 'تم التغيير على الخادم في: ⁨$time⁩'; + return 'تم التغيير على الخادم في: ⁨⁨$time⁩⁩'; } @override String localPendingEdit(String summary) { - return 'تعديل محلي: $summary'; + return 'تعديل محلي: ⁨$summary⁩'; } @override @@ -2159,7 +2311,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String copyEventMoveWarning(String source, String destination) { - return 'لا يستطيع BusyMax نقل هذا الحدث مباشرةً من $source إلى $destination. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.'; + return 'لا يستطيع BusyMax نقل هذا الحدث مباشرةً من ⁨$source⁩ إلى ⁨$destination⁩. سينشئ النسخة أولاً ولن يحذف الأصل إلا بعد نجاح النسخ. ستتغير معرّفات الحدث؛ وقد تُعاد تعيين حالات استجابة الحاضرين وتُرسل دعوات أو إلغاءات؛ وقد لا تُنقل روابط الاجتماعات والمرفقات والتذكيرات والحقول الخاصة بموفّر الخدمة واستثناءات التكرار.'; } @override @@ -2178,7 +2330,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return 'حذف «⁨$title⁩»؟'; + return 'حذف «⁨⁨$title⁩⁩»؟'; } @override @@ -2195,7 +2347,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String removeCalendarConfirmation(String title) { - return 'هل تريد إزالة \"$title\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.'; + return 'هل تريد إزالة \"⁨$title⁩\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.'; } @override @@ -2257,7 +2409,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String subscriptionSafeOrigin(String origin) { - return 'المصدر: $origin'; + return 'المصدر: ⁨$origin⁩'; } @override @@ -2272,12 +2424,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String subscriptionLastRefresh(String time) { - return 'آخر تحديث ناجح: $time'; + return 'آخر تحديث ناجح: ⁨$time⁩'; } @override String subscriptionNextRefresh(String time) { - return 'التحديث التالي: $time'; + return 'التحديث التالي: ⁨$time⁩'; } @override @@ -2285,7 +2437,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String subscriptionStatusIssue(String code) { - return 'مشكلة في التحديث: $code'; + return 'مشكلة في التحديث: ⁨$code⁩'; } @override @@ -2296,7 +2448,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String unsubscribeCalendarTitle(String name) { - return 'إلغاء الاشتراك من «$name»؟'; + return 'إلغاء الاشتراك من «⁨$name⁩»؟'; } @override @@ -2308,7 +2460,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String subscriptionOperationFailed(String error) { - return 'فشل اشتراك التقويم: $error'; + return 'فشل اشتراك التقويم: ⁨$error⁩'; } @override @@ -2339,7 +2491,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String importFieldsOmitted(String fields) { - return 'تم الاستبعاد عمدًا: $fields'; + return 'تم الاستبعاد عمدًا: ⁨$fields⁩'; } @override @@ -2371,7 +2523,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String importIcsFailed(String error) { - return 'تعذّر استيراد ملف التقويم: $error'; + return 'تعذّر استيراد ملف التقويم: ⁨$error⁩'; } @override @@ -2387,7 +2539,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return 'في أيام الشهر $days'; + return 'في الأيام $days من الشهر'; } @override diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 5be31ce7..03442437 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'montags', + 'TU': 'dienstags', + 'WE': 'mittwochs', + 'TH': 'donnerstags', + 'FR': 'freitags', + 'SA': 'samstags', + 'SU': 'sonntags', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'an den Tagen $first und $second des Monats'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency an den Tagen $firstDay und $secondDay im $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency jeweils am $day. im $firstMonth und $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency jeweils an den Tagen $firstDay und $secondDay im $firstMonth und $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency jeweils an den Tagen $days im $firstMonth und $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -178,7 +239,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String moreItems(int count) { - return '+$count mehr'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString mehr'; } @override @@ -622,7 +687,7 @@ class AppLocalizationsDe extends AppLocalizations { String get visibilityConfidential => 'Vertraulich'; @override - String get sensitivityNormal => 'Gewöhnlich'; + String get sensitivityNormal => 'Normal'; @override String get sensitivityPersonal => 'Persönlich'; @@ -1109,7 +1174,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% abgeschlossen'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% abgeschlossen'; } @override @@ -1123,17 +1192,29 @@ class AppLocalizationsDe extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Priorität $priority · Hoch'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorität $priorityString · Hoch'; } @override String priorityMediumValue(int priority) { - return 'Priorität $priority · Mittel'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorität $priorityString · Mittel'; } @override String priorityLowValue(int priority) { - return 'Priorität $priority · Niedrig'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorität $priorityString · Niedrig'; } @override @@ -1265,7 +1346,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Für dieses Datum gibt es $count verknüpfte Erinnerungen. Sollen sie ihr aktuelles Datum und ihre aktuelle Uhrzeit behalten?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Für dieses Datum gibt es $countString verknüpfte Erinnerungen. Sollen sie ihr aktuelles Datum und ihre aktuelle Uhrzeit behalten?'; } @override @@ -1390,27 +1475,43 @@ class AppLocalizationsDe extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Alle $count Tage'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Tage'; } @override String repeatEveryWeeks(int count) { - return 'Alle $count Wochen'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Wochen'; } @override String repeatEveryMonths(int count) { - return 'Alle $count Monate'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Monate'; } @override String repeatEveryYears(int count) { - return 'Alle $count Jahre'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Alle $countString Jahre'; } @override String repeatOnDaysSummary(String days) { - return 'an $days'; + return '$days'; } @override @@ -1440,11 +1541,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count-mal', - one: '$count Mal', + other: '$countString-mal', + one: '$countString Mal', ); return '$_temp0'; } @@ -1760,7 +1865,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'Versuche=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Versuche=$countString'; } @override @@ -1814,10 +1923,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count Aufgaben sind heute fällig.', + other: '$countString Aufgaben sind heute fällig.', one: 'Eine Aufgabe ist heute fällig.', ); return '$_temp0'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index ef7e995e..8ae20f90 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'on days $first and $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency on day $day of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency on days $days of $firstMonth and $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -125,7 +186,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String calendarColorOption(int number) { - return 'Color $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Color $numberString'; } @override @@ -178,7 +243,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String moreItems(int count) { - return '+$count more'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString more'; } @override @@ -246,10 +315,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String trayTasksDueToday(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks due today', + other: '$countString tasks due today', one: '1 task due today', ); return '$_temp0'; @@ -275,10 +348,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String trayLastSyncedMinutesAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count minutes ago', + other: 'Last synced $countString minutes ago', one: 'Last synced 1 minute ago', ); return '$_temp0'; @@ -286,10 +363,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String trayLastSyncedHoursAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count hours ago', + other: 'Last synced $countString hours ago', one: 'Last synced 1 hour ago', ); return '$_temp0'; @@ -297,10 +378,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String trayLastSyncedDaysAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Last synced $count days ago', + other: 'Last synced $countString days ago', one: 'Last synced 1 day ago', ); return '$_temp0'; @@ -561,10 +646,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minutes before', + other: '$minutesString minutes before', one: '1 minute before', ); return '$_temp0'; @@ -575,10 +664,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours hours before', + other: '$hoursString hours before', one: '1 hour before', ); return '$_temp0'; @@ -586,10 +679,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days days before', + other: '$daysString days before', one: '1 day before', ); return '$_temp0'; @@ -1100,7 +1198,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% completed'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completed'; } @override @@ -1114,17 +1216,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Priority $priority · High'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · High'; } @override String priorityMediumValue(int priority) { - return 'Priority $priority · Medium'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Medium'; } @override String priorityLowValue(int priority) { - return 'Priority $priority · Low'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Low'; } @override @@ -1253,7 +1367,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'This date has $count related reminders. Keep them at their current date and time?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'This date has $countString related reminders. Keep them at their current date and time?'; } @override @@ -1378,22 +1496,38 @@ class AppLocalizationsEn extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Every $count days'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString days'; } @override String repeatEveryWeeks(int count) { - return 'Every $count weeks'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString weeks'; } @override String repeatEveryMonths(int count) { - return 'Every $count months'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString months'; } @override String repeatEveryYears(int count) { - return 'Every $count years'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString years'; } @override @@ -1428,11 +1562,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count times', - one: '$count time', + other: '$countString times', + one: '$countString time', ); return '$_temp0'; } @@ -1742,7 +1880,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'attempts=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'attempts=$countString'; } @override @@ -1795,10 +1937,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tasks are due today.', + other: '$countString tasks are due today.', one: 'One task is due today.', ); return '$_temp0'; @@ -1849,7 +1995,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Week $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Week $numberString'; } @override @@ -1857,10 +2007,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count items', + other: '$countString items', one: '1 item', ); return '$_temp0'; @@ -2269,12 +2423,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String importEventsFound(int count) { - return 'Importable event sets: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Importable event sets: $countString'; } @override String importInvalidEvents(int count) { - return 'Invalid events: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Invalid events: $countString'; } @override @@ -2297,17 +2459,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String importQueued(int count) { - return 'Imported or queued: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Imported or queued: $countString'; } @override String importDuplicatesSkipped(int count) { - return 'Duplicates skipped: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Duplicates skipped: $countString'; } @override String importUnsupportedSets(int count) { - return 'Unsupported recurrence sets: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Unsupported recurrence sets: $countString'; } @override diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 6be1df43..9bd9bfa6 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'lunes', + 'TU': 'martes', + 'WE': 'miércoles', + 'TH': 'jueves', + 'FR': 'viernes', + 'SA': 'sábado', + 'SU': 'domingo', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'los días $first y $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency los días $firstDay y $secondDay de $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency el día $day de $firstMonth y $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency los días $firstDay y $secondDay de $firstMonth y $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency los días $days de $firstMonth y $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -180,7 +241,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String moreItems(int count) { - return '+$count más'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString más'; } @override @@ -623,10 +688,10 @@ class AppLocalizationsEs extends AppLocalizations { String get visibilityConfidential => 'Confidencial'; @override - String get sensitivityNormal => 'Habitual'; + String get sensitivityNormal => 'Normal'; @override - String get sensitivityPersonal => 'Personalizada'; + String get sensitivityPersonal => 'Personal'; @override String get tasks => 'Tareas'; @@ -1109,7 +1174,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% completada'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completada'; } @override @@ -1123,17 +1192,29 @@ class AppLocalizationsEs extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Prioridad $priority · Alta'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridad $priorityString · Alta'; } @override String priorityMediumValue(int priority) { - return 'Prioridad $priority · Media'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridad $priorityString · Media'; } @override String priorityLowValue(int priority) { - return 'Prioridad $priority · Baja'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridad $priorityString · Baja'; } @override @@ -1268,7 +1349,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Esta fecha tiene $count recordatorios relacionados. ¿Quieres conservarlos en su fecha y hora actuales?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Esta fecha tiene $countString recordatorios relacionados. ¿Quieres conservarlos en su fecha y hora actuales?'; } @override @@ -1393,22 +1478,38 @@ class AppLocalizationsEs extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Cada $count días'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString días'; } @override String repeatEveryWeeks(int count) { - return 'Cada $count semanas'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString semanas'; } @override String repeatEveryMonths(int count) { - return 'Cada $count meses'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString meses'; } @override String repeatEveryYears(int count) { - return 'Cada $count años'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cada $countString años'; } @override @@ -1443,11 +1544,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count veces', - one: '$count vez', + other: '$countString veces', + one: '$countString vez', ); return '$_temp0'; } @@ -1763,7 +1868,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'intentos=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'intentos=$countString'; } @override @@ -1818,10 +1927,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tareas vencen hoy.', + other: '$countString tareas vencen hoy.', one: 'Una tarea vence hoy.', ); return '$_temp0'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 445fd01a..b05ac08d 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -12,16 +12,77 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'esmaspäeviti', + 'TU': 'teisipäeviti', + 'WE': 'kolmapäeviti', + 'TH': 'neljapäeviti', + 'FR': 'reedeti', + 'SA': 'laupäeviti', + 'SU': 'pühapäeviti', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first ja $second päeval'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month $firstDay ja $secondDay päeval'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth ja $secondMonth $day päeval'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth ja $secondMonth $firstDay ja $secondDay päeval'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth ja $secondMonth $days päeval'; + } + @override String get appTitle => 'BusyMax'; @override String get connectGoogleAccount => - 'Ühenda Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.'; + 'Ühendage Google’i, Microsofti, Apple iCloud Calendari või Nextcloudi kontod.'; @override String get googlePermissionsConsentNotice => - 'Valige Google\'i õiguste kuval nii kalendri kui ka ülesannete õigused.'; + 'Valige Google’i õiguste kuval nii kalendri kui ka ülesannete õigused.'; @override String get googlePermissionsRequiredRetry => @@ -41,7 +102,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get onboardingAccountsStepDescription => - 'Lisa kõik kontod, mida soovid kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.'; + 'Lisage kõik kontod, mida soovite kasutada. BusyMax sünkroonib igast kontost toetatud kalendreid, sündmusi, ülesandeloendeid ja ülesandeid.'; @override String get onboardingPreferencesStepTitle => 'Süsteemiseadete valimine'; @@ -51,7 +112,7 @@ class AppLocalizationsEt extends AppLocalizations { 'Enne ajakava avamist määrake töölauakäitumine, meeldetuletused, teavituste üksikasjalikkus ja välimus.'; @override - String get signInWithGoogle => 'Logi Google\'iga sisse'; + String get signInWithGoogle => 'Logi Google’iga sisse'; @override String get signInWithMicrosoft => 'Logi Microsoftiga sisse'; @@ -66,7 +127,7 @@ class AppLocalizationsEt extends AppLocalizations { String get providerNotConfigured => 'See teenusepakkuja pole seadistatud.'; @override - String get waitingForGoogleSignIn => 'Google\'isse sisselogimise ootel...'; + String get waitingForGoogleSignIn => 'Google’isse sisselogimise ootel...'; @override String get waitingForMicrosoftSignIn => 'Microsofti sisselogimise ootel...'; @@ -417,7 +478,8 @@ class AppLocalizationsEt extends AppLocalizations { String get requestResponses => 'Küsi vastuseid'; @override - String get requestResponsesDescription => 'Palu külalistel kutsele vastata.'; + String get requestResponsesDescription => + 'Paluge külalistel kutsele vastata.'; @override String get hideGuestList => 'Peida külaliste loend'; @@ -833,7 +895,7 @@ class AppLocalizationsEt extends AppLocalizations { String get switchAccount => 'Vaheta kontot'; @override - String get addGoogleAccount => 'Lisa Google\'i konto'; + String get addGoogleAccount => 'Lisa Google’i konto'; @override String get addMicrosoftAccount => 'Lisa Microsofti konto'; @@ -868,7 +930,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get revokeGoogleAccess => - 'Tühista ka BusyMaxi juurdepääs sellele Google\'i kontole'; + 'Tühista ka BusyMaxi juurdepääs sellele Google’i kontole'; @override String get revokeGoogleAccessDescription => @@ -883,7 +945,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get accountRemovedGoogleRevokeFailed => - 'Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google\'i kontole ei saanud tühistada. Saate selle oma Google\'i kontol käsitsi tühistada.'; + 'Konto eemaldati sellest seadmest, kuid BusyMaxi juurdepääsu teie Google’i kontole ei saanud tühistada. Saate selle oma Google’i kontol käsitsi tühistada.'; @override String get newTaskList => 'Uus ülesandeloend'; @@ -1103,7 +1165,11 @@ class AppLocalizationsEt extends AppLocalizations { @override String completionPercent(int percent) { - return 'Lõpetatud $percent%'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Lõpetatud $percentString%'; } @override @@ -1117,24 +1183,36 @@ class AppLocalizationsEt extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Prioriteet $priority · kõrge'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioriteet $priorityString · kõrge'; } @override String priorityMediumValue(int priority) { - return 'Prioriteet $priority · keskmine'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioriteet $priorityString · keskmine'; } @override String priorityLowValue(int priority) { - return 'Prioriteet $priority · madal'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioriteet $priorityString · madal'; } @override String get taskUrl => 'Ülesande URL'; @override - String get invalidTaskUrl => 'Sisesta absoluutne URL koos skeemiga.'; + String get invalidTaskUrl => 'Sisestage absoluutne URL koos skeemiga.'; @override String get classification => 'Klassifikatsioon'; @@ -1256,7 +1334,11 @@ class AppLocalizationsEt extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Sellel kuupäeval on $count seotud meeldetuletust. Kas säilitada need praegusel kuupäeval ja kellaajal?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Sellel kuupäeval on $countString seotud meeldetuletust. Kas säilitada need praegusel kuupäeval ja kellaajal?'; } @override @@ -1381,27 +1463,43 @@ class AppLocalizationsEt extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Iga $count päeva järel'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString päeva järel'; } @override String repeatEveryWeeks(int count) { - return 'Iga $count nädala järel'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString nädala järel'; } @override String repeatEveryMonths(int count) { - return 'Iga $count kuu järel'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString kuu järel'; } @override String repeatEveryYears(int count) { - return 'Iga $count aasta järel'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Iga $countString aasta järel'; } @override String repeatOnDaysSummary(String days) { - return 'päevadel $days'; + return '$days'; } @override @@ -1431,11 +1529,15 @@ class AppLocalizationsEt extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count korda', - one: '$count kord', + other: '$countString korda', + one: '$countString kord', ); return '$_temp0'; } @@ -1598,7 +1700,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get launchAtLoginDescription => - 'Käivita BusyMax taustal, et meeldetuletused töötaksid pärast sisselogimist.'; + 'BusyMax käivitatakse taustal, et meeldetuletused töötaksid pärast sisselogimist.'; @override String get launchAtLoginFailed => @@ -1887,7 +1989,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get providerConnectionDescription => - 'Ühenda kalendrid ja ülesanded ühega neist teenusepakkujatest.'; + 'Ühendage kalendrid ja ülesanded ühega neist teenusepakkujatest.'; @override String get appleICloudProvider => 'Apple iCloudi kalender'; @@ -1938,7 +2040,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get nextcloudServerUrlHelp => - 'Sisesta Nextcloudi serveri URL või kleebi Nextcloudist kopeeritud peamine CalDAV-aadress.'; + 'Sisestage Nextcloudi serveri URL või kleepige Nextcloudist kopeeritud peamine CalDAV-aadress.'; @override String get nextcloudBrowserAuthorizationHelp => @@ -2093,7 +2195,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get thisAndFutureMoveUnavailable => - 'Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Vali see sündmus või kogu sari.'; + 'Seda ja järgnevaid sündmusi ei saa turvaliselt teisaldada. Valige see sündmus või kogu sari.'; @override String get entireSeriesMoveUnavailable => @@ -2113,14 +2215,14 @@ class AppLocalizationsEt extends AppLocalizations { @override String get chooseRecurringEventScope => - 'Vali, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.'; + 'Valige, kas see muudatus rakendub kogu sarjale, ainult sellele sündmusele või sellele ja järgnevatele sündmustele.'; @override String get taskDueBeforeStart => 'Tähtaeg ei tohi olla enne algust.'; @override String get taskStartDueTimeModeMismatch => - 'Määra nii algus- kui ka tähtaja kellaaeg või tee ülesanne kogu päeva ülesandeks.'; + 'Määrake nii algus- kui ka tähtaja kellaaeg või tehke ülesanne kogu päeva ülesandeks.'; @override String deleteCalendarConfirmation(String title) { @@ -2141,7 +2243,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String removeCalendarConfirmation(String title) { - return 'Kas eemaldada „$title” sinu Google\'i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.'; + return 'Kas eemaldada „$title” teie Google’i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.'; } @override @@ -2150,14 +2252,14 @@ class AppLocalizationsEt extends AppLocalizations { @override String get calendarPendingChangesPreventRemoval => - 'Enne kalendri kustutamist või eemaldamist oota, kuni selle ootel muudatused on sünkroonitud.'; + 'Enne kalendri kustutamist või eemaldamist oodake, kuni selle ootel muudatused on sünkroonitud.'; @override String get calendarSubscriptions => 'Kalendritellimused'; @override String get calendarSubscriptionsDescription => - 'Lisa kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.'; + 'Lisage kirjutuskaitstud kalendreid, mida värskendatakse turvalise WebCali URL-i kaudu.'; @override String get addCalendarSubscription => 'Lisa kalendritellimus'; @@ -2170,22 +2272,22 @@ class AppLocalizationsEt extends AppLocalizations { @override String get subscriptionUrlHelp => - 'Sisesta HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.'; + 'Sisestage HTTPS- või webcal-URL. BusyMax hoiab täielikku URL-i turvalises salvestusruumis.'; @override String get subscriptionUrlInvalid => - 'Sisesta kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.'; + 'Sisestage kehtiv HTTPS- või webcal-URL ilma kasutajateabe või fragmendita.'; @override String get subscriptionColor => 'Kohalik värv'; @override String get subscriptionColorHelp => - 'Kasuta kuuekohalist värvi, näiteks #3584E4.'; + 'Kasutage kuuekohalist värvi, näiteks #3584E4.'; @override String get subscriptionColorInvalid => - 'Sisesta kuuekohaline kuueteistkümnendsüsteemi värv.'; + 'Sisestage kuuekohaline kuueteistkümnendsüsteemi värv.'; @override String get subscriptionRefreshMode => 'Värskendussagedus'; @@ -2266,7 +2368,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get calendarImportDescription => - 'Vali fail, vaata selle sündmused üle ja vali seejärel kirjutatav kalender, kuhu need lisada.'; + 'Valige fail, vaadake selle sündmused üle ja valige seejärel kirjutatav kalender, kuhu need lisada.'; @override String get importIcsFile => 'Impordi .ics-fail'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 895d8134..4a96e407 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'دوشنبه', + 'TU': 'سه‌شنبه', + 'WE': 'چهارشنبه', + 'TH': 'پنجشنبه', + 'FR': 'جمعه', + 'SA': 'شنبه', + 'SU': 'یکشنبه', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'در روزهای $first و $second ماه'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency در روزهای $firstDay و $secondDay ماه $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency در روز $day ماه‌های $firstMonth و $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency در روزهای $firstDay و $secondDay ماه‌های $firstMonth و $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency در روزهای $days ماه‌های $firstMonth و $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -125,7 +186,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String calendarColorOption(int number) { - return 'رنگ $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'رنگ $numberString'; } @override @@ -137,7 +202,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String calendarCreateFailed(String error) { - return 'ایجاد تقویم ممکن نشد: $error'; + return 'ایجاد تقویم ممکن نشد: ⁨$error⁩'; } @override @@ -146,12 +211,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String calendarUpdateFailed(String error) { - return 'به‌روزرسانی تقویم ممکن نشد: $error'; + return 'به‌روزرسانی تقویم ممکن نشد: ⁨$error⁩'; } @override String calendarDeleteFailed(String error) { - return 'حذف تقویم ممکن نشد: $error'; + return 'حذف تقویم ممکن نشد: ⁨$error⁩'; } @override @@ -250,10 +315,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String trayTasksDueToday(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count کار امروز سررسید دارند', + other: '$countString کار امروز سررسید دارند', one: '۱ کار امروز سررسید دارد', ); return '$_temp0'; @@ -279,10 +348,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String trayLastSyncedMinutesAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count دقیقه پیش همگام شد', + other: '$countString دقیقه پیش همگام شد', one: '۱ دقیقه پیش همگام شد', ); return '$_temp0'; @@ -290,10 +363,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String trayLastSyncedHoursAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count ساعت پیش همگام شد', + other: '$countString ساعت پیش همگام شد', one: '۱ ساعت پیش همگام شد', ); return '$_temp0'; @@ -301,10 +378,14 @@ class AppLocalizationsFa extends AppLocalizations { @override String trayLastSyncedDaysAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count روز پیش همگام شد', + other: '$countString روز پیش همگام شد', one: '۱ روز پیش همگام شد', ); return '$_temp0'; @@ -396,7 +477,7 @@ class AppLocalizationsFa extends AppLocalizations { String get guests => 'مهمانان'; @override - String get noGuests => 'مهمانی وجود ندارد'; + String get noGuests => 'فهرست مهمانان خالی است'; @override String get attendeeRequired => 'ضروری'; @@ -515,7 +596,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String invitationResponseFailed(String error) { - return 'ارسال پاسخ شما ممکن نیست: $error'; + return 'ارسال پاسخ شما ممکن نیست: ⁨$error⁩'; } @override @@ -653,7 +734,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String tasksInList(String title) { - return 'کارهای ⁨$title⁩'; + return 'کارهای ⁨⁨$title⁩⁩'; } @override @@ -832,7 +913,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String feedbackSuccess(String id) { - return 'بازخورد ارسال شد. شناسهٔ پیگیری: ⁨$id⁩'; + return 'بازخورد ارسال شد. شناسهٔ پیگیری: ⁨⁨$id⁩⁩'; } @override @@ -880,7 +961,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String removeAccountTitle(String account) { - return '⁨$account⁩ از BusyMax حذف شود؟'; + return '⁨⁨$account⁩⁩ از BusyMax حذف شود؟'; } @override @@ -910,17 +991,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String taskListCreateFailed(String error) { - return 'ایجاد فهرست کار ممکن نیست: $error'; + return 'ایجاد فهرست کار ممکن نیست: ⁨$error⁩'; } @override String taskListRenameFailed(String error) { - return 'تغییر نام فهرست کار ممکن نیست: $error'; + return 'تغییر نام فهرست کار ممکن نیست: ⁨$error⁩'; } @override String taskListDeleteFailed(String error) { - return 'حذف فهرست کار ممکن نیست: $error'; + return 'حذف فهرست کار ممکن نیست: ⁨$error⁩'; } @override @@ -964,17 +1045,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteListConfirmation(String title) { - return '‏«⁨$title⁩» از Google Tasks حذف شود؟'; + return '‏«⁨⁨$title⁩⁩» از Google Tasks حذف شود؟'; } @override String deleteTaskListConfirmation(String title) { - return '‏«$title» و همهٔ کارهای آن حذف شوند؟'; + return '‏«⁨$title⁩» و همهٔ کارهای آن حذف شوند؟'; } @override String unshareTaskListConfirmation(String title) { - return 'اشتراک‌گذاری «$title» از این حساب لغو شود؟'; + return 'اشتراک‌گذاری «⁨$title⁩» از این حساب لغو شود؟'; } @override @@ -1006,17 +1087,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String exportedFile(String path) { - return 'در ⁨$path⁩ خروجی گرفته شد'; + return 'در ⁨⁨$path⁩⁩ خروجی گرفته شد'; } @override String exportFailed(String error) { - return 'خروجی گرفتن ناموفق بود: ⁨$error⁩'; + return 'خروجی گرفتن ناموفق بود: ⁨⁨$error⁩⁩'; } @override String refreshFailed(String error) { - return 'تازه‌سازی ناموفق بود: ⁨$error⁩'; + return 'تازه‌سازی ناموفق بود: ⁨⁨$error⁩⁩'; } @override @@ -1059,12 +1140,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String duePrefix(String date) { - return 'سررسید: ⁨$date⁩'; + return 'سررسید: ⁨⁨$date⁩⁩'; } @override String dateTimeDisplay(String date, String time) { - return '⁨$date⁩ · ⁨$time⁩'; + return '⁨⁨$date⁩⁩ · ⁨⁨$time⁩⁩'; } @override @@ -1121,7 +1202,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent٪ تکمیل‌شده'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString٪ تکمیل‌شده'; } @override @@ -1135,17 +1220,29 @@ class AppLocalizationsFa extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'اولویت $priority · زیاد'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'اولویت $priorityString · زیاد'; } @override String priorityMediumValue(int priority) { - return 'اولویت $priority · متوسط'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'اولویت $priorityString · متوسط'; } @override String priorityLowValue(int priority) { - return 'اولویت $priority · کم'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'اولویت $priorityString · کم'; } @override @@ -1275,7 +1372,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'این تاریخ $count یادآور مرتبط دارد. آن‌ها در تاریخ و زمان فعلی نگه داشته شوند؟'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'این تاریخ $countString یادآور مرتبط دارد. آن‌ها در تاریخ و زمان فعلی نگه داشته شوند؟'; } @override @@ -1400,22 +1501,38 @@ class AppLocalizationsFa extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'هر $count روز'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString روز'; } @override String repeatEveryWeeks(int count) { - return 'هر $count هفته'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString هفته'; } @override String repeatEveryMonths(int count) { - return 'هر $count ماه'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString ماه'; } @override String repeatEveryYears(int count) { - return 'هر $count سال'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'هر $countString سال'; } @override @@ -1450,18 +1567,22 @@ class AppLocalizationsFa extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count بار', - one: '$count بار', + other: '$countString بار', + one: '$countString بار', ); return '$_temp0'; } @override String repeatUntilSummary(String date) { - return 'تا $date'; + return 'تا ⁨$date⁩'; } @override @@ -1470,7 +1591,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String recurrenceUnsupportedByProvider(String provider) { - return 'این تکرار را نمی‌توان با $provider استفاده کرد.'; + return 'این تکرار را نمی‌توان با ⁨$provider⁩ استفاده کرد.'; } @override @@ -1533,7 +1654,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String taskDuplicateFailed(String error) { - return 'تکرار کار ممکن نیست: $error'; + return 'تکرار کار ممکن نیست: ⁨$error⁩'; } @override @@ -1553,7 +1674,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return '«⁨$title⁩» حذف شود؟'; + return '«⁨⁨$title⁩⁩» حذف شود؟'; } @override @@ -1629,7 +1750,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String syncFailed(String error) { - return 'همگام‌سازی ناموفق بود: ⁨$error⁩'; + return 'همگام‌سازی ناموفق بود: ⁨⁨$error⁩⁩'; } @override @@ -1726,7 +1847,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'بازبینی Discovery: ⁨$revision⁩'; + return 'بازبینی Discovery: ⁨⁨$revision⁩⁩'; } @override @@ -1755,12 +1876,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String pendingOpListId(String id) { - return 'فهرست=⁨$id⁩'; + return 'فهرست=⁨⁨$id⁩⁩'; } @override String pendingOpTaskId(String id) { - return 'کار=⁨$id⁩'; + return 'کار=⁨⁨$id⁩⁩'; } @override @@ -1806,7 +1927,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String syncFailureNotificationBody(String message) { - return 'همگام‌سازی پس‌زمینه ناموفق بود. ⁨$message⁩'; + return 'همگام‌سازی پس‌زمینه ناموفق بود. ⁨⁨$message⁩⁩'; } @override @@ -1814,7 +1935,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String conflictNotificationBody(String summary) { - return 'یک تغییر محلی در انتظار مسدود شد. ⁨$summary⁩'; + return 'یک تغییر محلی در انتظار مسدود شد. ⁨⁨$summary⁩⁩'; } @override @@ -2057,7 +2178,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String davLastSuccessfulSync(String time) { - return 'آخرین همگام‌سازی موفق: $time'; + return 'آخرین همگام‌سازی موفق: ⁨$time⁩'; } @override @@ -2068,7 +2189,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String nextcloudServerHost(String host) { - return 'سرور: $host'; + return 'سرور: ⁨$host⁩'; } @override @@ -2088,12 +2209,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String collectionLastSynced(String time) { - return 'آخرین همگام‌سازی: $time'; + return 'آخرین همگام‌سازی: ⁨$time⁩'; } @override String collectionSyncError(String code) { - return 'مشکل همگام‌سازی: $code'; + return 'مشکل همگام‌سازی: ⁨$code⁩'; } @override @@ -2101,12 +2222,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String remoteChangedAt(String time) { - return 'تغییر سرور: $time'; + return 'تغییر سرور: ⁨$time⁩'; } @override String localPendingEdit(String summary) { - return 'ویرایش محلی: $summary'; + return 'ویرایش محلی: ⁨$summary⁩'; } @override @@ -2141,7 +2262,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String copyEventMoveWarning(String source, String destination) { - return 'BusyMax نمی‌تواند این رویداد را مستقیماً از $source به $destination منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.'; + return 'BusyMax نمی‌تواند این رویداد را مستقیماً از ⁨$source⁩ به ⁨$destination⁩ منتقل کند. ابتدا کپی را ایجاد می‌کند و فقط پس از موفقیت کپی، نسخهٔ اصلی را حذف می‌کند. شناسه‌های رویداد تغییر می‌کنند؛ ممکن است وضعیت پاسخ شرکت‌کنندگان بازنشانی شود و دعوت‌نامه یا لغو ارسال شود؛ و شاید پیوندهای جلسه، پیوست‌ها، یادآورها، فیلدهای ویژهٔ ارائه‌دهنده و استثناهای تکرار منتقل نشوند.'; } @override @@ -2160,7 +2281,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return '«⁨$title⁩» حذف شود؟'; + return '«⁨⁨$title⁩⁩» حذف شود؟'; } @override @@ -2177,7 +2298,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String removeCalendarConfirmation(String title) { - return '«$title» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.'; + return '«⁨$title⁩» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.'; } @override @@ -2238,7 +2359,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String subscriptionSafeOrigin(String origin) { - return 'مبدأ: $origin'; + return 'مبدأ: ⁨$origin⁩'; } @override @@ -2253,12 +2374,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String subscriptionLastRefresh(String time) { - return 'آخرین تازه‌سازی موفق: $time'; + return 'آخرین تازه‌سازی موفق: ⁨$time⁩'; } @override String subscriptionNextRefresh(String time) { - return 'تازه‌سازی بعدی: $time'; + return 'تازه‌سازی بعدی: ⁨$time⁩'; } @override @@ -2266,7 +2387,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String subscriptionStatusIssue(String code) { - return 'مشکل تازه‌سازی: $code'; + return 'مشکل تازه‌سازی: ⁨$code⁩'; } @override @@ -2277,7 +2398,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String unsubscribeCalendarTitle(String name) { - return 'اشتراک «$name» لغو شود؟'; + return 'اشتراک «⁨$name⁩» لغو شود؟'; } @override @@ -2289,7 +2410,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String subscriptionOperationFailed(String error) { - return 'اشتراک تقویم ناموفق بود: $error'; + return 'اشتراک تقویم ناموفق بود: ⁨$error⁩'; } @override @@ -2310,17 +2431,25 @@ class AppLocalizationsFa extends AppLocalizations { @override String importEventsFound(int count) { - return 'مجموعه رویدادهای قابل درون‌ریزی: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'مجموعه رویدادهای قابل درون‌ریزی: $countString'; } @override String importInvalidEvents(int count) { - return 'رویدادهای نامعتبر: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'رویدادهای نامعتبر: $countString'; } @override String importFieldsOmitted(String fields) { - return 'عمداً حذف‌شده: $fields'; + return 'عمداً حذف‌شده: ⁨$fields⁩'; } @override @@ -2337,22 +2466,34 @@ class AppLocalizationsFa extends AppLocalizations { @override String importQueued(int count) { - return 'درون‌ریزی‌شده یا در صف: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'درون‌ریزی‌شده یا در صف: $countString'; } @override String importDuplicatesSkipped(int count) { - return 'تکراری‌ها رد شدند: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'تکراری‌ها رد شدند: $countString'; } @override String importUnsupportedSets(int count) { - return 'مجموعه‌های تکرار پشتیبانی‌نشده: $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'مجموعه‌های تکرار پشتیبانی‌نشده: $countString'; } @override String importIcsFailed(String error) { - return 'درون‌ریزی فایل تقویم ممکن نیست: $error'; + return 'درون‌ریزی فایل تقویم ممکن نیست: ⁨$error⁩'; } @override diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 892c64f8..c949338b 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsFi extends AppLocalizations { AppLocalizationsFi([String locale = 'fi']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'maanantaisin', + 'TU': 'tiistaisin', + 'WE': 'keskiviikkoisin', + 'TH': 'torstaisin', + 'FR': 'perjantaisin', + 'SA': 'lauantaisin', + 'SU': 'sunnuntaisin', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'kuukauden $first ja $second päivänä'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month $firstDay ja $secondDay päivänä'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth ja $secondMonth $day päivänä'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth ja $secondMonth $firstDay ja $secondDay päivänä'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth ja $secondMonth $days päivänä'; + } + @override String get appTitle => 'BusyMax'; @@ -180,7 +241,17 @@ class AppLocalizationsFi extends AppLocalizations { @override String moreItems(int count) { - return '+$count muuta'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '+$countString muuta', + one: '+1 muu', + ); + return '$_temp0'; } @override @@ -564,10 +635,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minuuttia ennen', + other: '$minutesString minuuttia ennen', one: '1 minuutti ennen', ); return '$_temp0'; @@ -578,10 +653,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours tuntia ennen', + other: '$hoursString tuntia ennen', one: '1 tunti ennen', ); return '$_temp0'; @@ -589,10 +668,15 @@ class AppLocalizationsFi extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days päivää ennen', + other: '$daysString päivää ennen', one: '1 päivä ennen', ); return '$_temp0'; @@ -1109,7 +1193,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String completionPercent(int percent) { - return 'Valmis $percent %'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Valmis $percentString %'; } @override @@ -1123,17 +1211,29 @@ class AppLocalizationsFi extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Tärkeys $priority · korkea'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Tärkeys $priorityString · korkea'; } @override String priorityMediumValue(int priority) { - return 'Tärkeys $priority · keskitaso'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Tärkeys $priorityString · keskitaso'; } @override String priorityLowValue(int priority) { - return 'Tärkeys $priority · matala'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Tärkeys $priorityString · matala'; } @override @@ -1262,7 +1362,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Tällä päivämäärällä on $count liittyvää muistutusta. Säilytetäänkö ne nykyisessä päivämäärässä ja kellonajassa?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Tällä päivämäärällä on $countString liittyvää muistutusta. Säilytetäänkö ne nykyisessä päivämäärässä ja kellonajassa?'; } @override @@ -1387,32 +1491,48 @@ class AppLocalizationsFi extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Joka $count. päivä'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. päivä'; } @override String repeatEveryWeeks(int count) { - return 'Joka $count. viikko'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. viikko'; } @override String repeatEveryMonths(int count) { - return 'Joka $count. kuukausi'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. kuukausi'; } @override String repeatEveryYears(int count) { - return 'Joka $count. vuosi'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Joka $countString. vuosi'; } @override String repeatOnDaysSummary(String days) { - return 'päivinä $days'; + return '$days'; } @override String repeatOnMonthDaysSummary(String days) { - return 'kuukauden päivinä $days'; + return 'kuukauden $days päivänä'; } @override @@ -1437,11 +1557,15 @@ class AppLocalizationsFi extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count kertaa', - one: '$count kerran', + other: '$countString kertaa', + one: '$countString kerran', ); return '$_temp0'; } @@ -1753,7 +1877,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'yritykset=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'yritykset=$countString'; } @override @@ -1807,10 +1935,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tehtävää erääntyy tänään.', + other: '$countString tehtävää erääntyy tänään.', one: 'Yksi tehtävä erääntyy tänään.', ); return '$_temp0'; @@ -1861,7 +1993,11 @@ class AppLocalizationsFi extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Viikko $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Viikko $numberString'; } @override @@ -1869,10 +2005,14 @@ class AppLocalizationsFi extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count kohdetta', + other: '$countString kohdetta', one: '1 kohde', ); return '$_temp0'; @@ -2345,7 +2485,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return 'kuukauden päivinä $days'; + return 'kuukauden $days päivänä'; } @override @@ -2353,7 +2493,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String repeatMonthDayValue(String day) { - return '$day'; + return '$day.'; } @override @@ -2422,7 +2562,7 @@ class AppLocalizationsFi extends AppLocalizations { String month, String day, ) { - return '$frequency $month $day. päivänä'; + return '$frequency $month $day päivänä'; } @override @@ -2431,7 +2571,7 @@ class AppLocalizationsFi extends AppLocalizations { String month, String days, ) { - return '$frequency $month päivinä $days'; + return '$frequency $month $days päivänä'; } @override @@ -2440,7 +2580,7 @@ class AppLocalizationsFi extends AppLocalizations { String months, String day, ) { - return '$frequency $months $day. päivänä'; + return '$frequency $months $day päivänä'; } @override @@ -2449,7 +2589,7 @@ class AppLocalizationsFi extends AppLocalizations { String months, String days, ) { - return '$frequency $months päivinä $days'; + return '$frequency $months $days päivänä'; } @override diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index a619aa33..b2ebbd31 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'le lundi', + 'TU': 'le mardi', + 'WE': 'le mercredi', + 'TH': 'le jeudi', + 'FR': 'le vendredi', + 'SA': 'le samedi', + 'SU': 'le dimanche', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'les jours $first et $second du mois'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency les jours $firstDay et $secondDay de $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency le $day de $firstMonth et $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency les jours $firstDay et $secondDay de $firstMonth et $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency les jours $days de $firstMonth et $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -139,7 +200,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String calendarCreateFailed(String error) { - return 'Impossible de créer le calendrier : $error'; + return 'Impossible de créer le calendrier : $error'; } @override @@ -148,12 +209,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String calendarUpdateFailed(String error) { - return 'Impossible de mettre à jour le calendrier : $error'; + return 'Impossible de mettre à jour le calendrier : $error'; } @override String calendarDeleteFailed(String error) { - return 'Impossible de supprimer le calendrier : $error'; + return 'Impossible de supprimer le calendrier : $error'; } @override @@ -179,7 +240,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String moreItems(int count) { - return '+$count de plus'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString de plus'; } @override @@ -801,7 +866,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get feedbackTimeoutError => - 'La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.'; + 'La demande a expiré. Vos commentaires n’ont pas été effacés ; réessayez.'; @override String get feedbackRateLimitedError => @@ -813,11 +878,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get feedbackServerError => - 'BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.'; + 'BusyStack ne peut pas accepter vos commentaires pour le moment. Vos commentaires n’ont pas été effacés ; réessayez.'; @override String feedbackSuccess(String id) { - return 'Commentaires envoyés. Référence : $id'; + return 'Commentaires envoyés. Référence : $id'; } @override @@ -998,12 +1063,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String exportFailed(String error) { - return 'Échec de l’export : $error'; + return 'Échec de l’export : $error'; } @override String refreshFailed(String error) { - return 'Échec de l’actualisation : $error'; + return 'Échec de l’actualisation : $error'; } @override @@ -1108,7 +1173,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent % terminée'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString % terminée'; } @override @@ -1122,17 +1191,29 @@ class AppLocalizationsFr extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Priorité $priority · élevée'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorité $priorityString · élevée'; } @override String priorityMediumValue(int priority) { - return 'Priorité $priority · moyenne'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorité $priorityString · moyenne'; } @override String priorityLowValue(int priority) { - return 'Priorité $priority · faible'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorité $priorityString · faible'; } @override @@ -1266,7 +1347,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Cette date comporte $count rappels associés. Les conserver à leur date et heure actuelles ?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Cette date comporte $countString rappels associés. Les conserver à leur date et heure actuelles ?'; } @override @@ -1391,27 +1476,43 @@ class AppLocalizationsFr extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Tous les $count jours'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Tous les $countString jours'; } @override String repeatEveryWeeks(int count) { - return 'Toutes les $count semaines'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Toutes les $countString semaines'; } @override String repeatEveryMonths(int count) { - return 'Tous les $count mois'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Tous les $countString mois'; } @override String repeatEveryYears(int count) { - return 'Toutes les $count années'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Toutes les $countString années'; } @override String repeatOnDaysSummary(String days) { - return 'les $days'; + return '$days'; } @override @@ -1441,11 +1542,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count fois', - one: '$count fois', + other: '$countString fois', + one: '$countString fois', ); return '$_temp0'; } @@ -1544,7 +1649,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String deleteTaskConfirmation(String title) { - return 'Supprimer « $title » ?'; + return 'Supprimer « $title » ?'; } @override @@ -1624,7 +1729,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String syncFailed(String error) { - return 'Échec de la synchronisation : $error'; + return 'Échec de la synchronisation : $error'; } @override @@ -1721,7 +1826,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String discoveryRevision(String revision) { - return 'Révision Discovery : $revision'; + return 'Révision Discovery : $revision'; } @override @@ -1760,7 +1865,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'tentatives=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'tentatives=$countString'; } @override @@ -1773,7 +1882,7 @@ class AppLocalizationsFr extends AppLocalizations { String get discardChangesAction => 'Abandonner'; @override - String get discardChanges => 'Abandonner les modifications ?'; + String get discardChanges => 'Abandonner les modifications ?'; @override String get discardChangesConfirmation => @@ -1783,7 +1892,7 @@ class AppLocalizationsFr extends AppLocalizations { String get retryCompleted => 'Nouvelle tentative terminée.'; @override - String get discardPendingOperation => 'Abandonner l’opération en attente ?'; + String get discardPendingOperation => 'Abandonner l’opération en attente ?'; @override String get discardPendingOperationConfirmation => @@ -1814,10 +1923,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count tâches sont dues aujourd’hui.', + other: '$countString tâches sont dues aujourd’hui.', one: 'Une tâche est due aujourd’hui.', ); return '$_temp0'; @@ -2145,7 +2258,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String deleteCalendarConfirmation(String title) { - return 'Supprimer « $title » ?'; + return 'Supprimer « $title » ?'; } @override @@ -2162,7 +2275,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String removeCalendarConfirmation(String title) { - return 'Retirer « $title » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.'; + return 'Retirer « $title » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.'; } @override diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 29d3f78f..fa6d163c 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'सोमवार', + 'TU': 'मंगलवार', + 'WE': 'बुधवार', + 'TH': 'गुरुवार', + 'FR': 'शुक्रवार', + 'SA': 'शनिवार', + 'SU': 'रविवार', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'की $first और $second तारीखों को'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month की $firstDay और $secondDay तारीखों को'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth और $secondMonth की $day तारीख को'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth और $secondMonth की $firstDay और $secondDay तारीखों को'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth और $secondMonth की $days तारीखों को'; + } + @override String get appTitle => 'BusyMax'; @@ -108,7 +169,7 @@ class AppLocalizationsHi extends AppLocalizations { String get all => 'सभी'; @override - String get calendarEvents => 'ईवेंट'; + String get calendarEvents => 'इवेंट'; @override String get calendarTasks => 'कार्य'; @@ -158,7 +219,7 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get newEvent => 'नया ईवेंट'; + String get newEvent => 'नया इवेंट'; @override String get refreshCalendar => 'कैलेंडर रीफ़्रेश करें'; @@ -180,11 +241,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String moreItems(int count) { - return '+$count और'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString और'; } @override - String get noEventsOrTasks => 'कोई ईवेंट या कार्य नहीं'; + String get noEventsOrTasks => 'कोई इवेंट या कार्य नहीं'; @override String get scheduleLoading => 'शेड्यूल लोड हो रहा है...'; @@ -208,7 +273,7 @@ class AppLocalizationsHi extends AppLocalizations { 'कैलेंडर और कार्य सिंक करने के लिए साइन इन करें।'; @override - String get scheduleNoSearchResults => 'कोई मिलता-जुलता ईवेंट या कार्य नहीं'; + String get scheduleNoSearchResults => 'कोई मिलता-जुलता इवेंट या कार्य नहीं'; @override String get scheduleNoSearchResultsDescription => @@ -363,16 +428,16 @@ class AppLocalizationsHi extends AppLocalizations { String get createChoiceTitle => 'बनाएँ'; @override - String get createEventAtTime => 'ईवेंट'; + String get createEventAtTime => 'इवेंट'; @override String get createTaskAtDate => 'कार्य'; @override - String get editEvent => 'ईवेंट संपादित करें'; + String get editEvent => 'इवेंट संपादित करें'; @override - String get eventTitle => 'ईवेंट का शीर्षक'; + String get eventTitle => 'इवेंट का शीर्षक'; @override String get location => 'स्थान'; @@ -565,10 +630,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes मिनट पहले', + other: '$minutesString मिनट पहले', one: '1 मिनट पहले', ); return '$_temp0'; @@ -579,10 +648,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours घंटे पहले', + other: '$hoursString घंटे पहले', one: '1 घंटा पहले', ); return '$_temp0'; @@ -590,10 +663,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days दिन पहले', + other: '$daysString दिन पहले', one: '1 दिन पहले', ); return '$_temp0'; @@ -700,10 +778,10 @@ class AppLocalizationsHi extends AppLocalizations { String get shortcutGroupCreateAndEdit => 'बनाएँ और संपादित करें'; @override - String get shortcutSaveItem => 'ईवेंट या कार्य सहेजें'; + String get shortcutSaveItem => 'इवेंट या कार्य सहेजें'; @override - String get shortcutDeleteItem => 'ईवेंट या कार्य मिटाएँ'; + String get shortcutDeleteItem => 'इवेंट या कार्य मिटाएँ'; @override String get shortcutGroupTaskEditing => 'कार्य संपादन'; @@ -964,7 +1042,7 @@ class AppLocalizationsHi extends AppLocalizations { } @override - String get deleteEvent => 'ईवेंट मिटाएँ'; + String get deleteEvent => 'इवेंट मिटाएँ'; @override String get title => 'शीर्षक'; @@ -1107,7 +1185,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% पूर्ण'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% पूर्ण'; } @override @@ -1121,17 +1203,29 @@ class AppLocalizationsHi extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'प्राथमिकता $priority · उच्च'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'प्राथमिकता $priorityString · उच्च'; } @override String priorityMediumValue(int priority) { - return 'प्राथमिकता $priority · मध्यम'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'प्राथमिकता $priorityString · मध्यम'; } @override String priorityLowValue(int priority) { - return 'प्राथमिकता $priority · निम्न'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'प्राथमिकता $priorityString · निम्न'; } @override @@ -1260,7 +1354,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'इस तारीख पर $count संबंधित रिमाइंडर हैं। इन्हें उनकी मौजूदा तारीख और समय पर रखें?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'इस तारीख पर $countString संबंधित रिमाइंडर हैं। इन्हें उनकी मौजूदा तारीख और समय पर रखें?'; } @override @@ -1385,22 +1483,38 @@ class AppLocalizationsHi extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'हर $count दिन'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString दिन'; } @override String repeatEveryWeeks(int count) { - return 'हर $count सप्ताह'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString सप्ताह'; } @override String repeatEveryMonths(int count) { - return 'हर $count महीने'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString महीने'; } @override String repeatEveryYears(int count) { - return 'हर $count साल'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'हर $countString साल'; } @override @@ -1410,7 +1524,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String repeatOnMonthDaysSummary(String days) { - return 'के $daysवें दिन'; + return 'की $days तारीख को'; } @override @@ -1435,11 +1549,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count बार', - one: '$count बार', + other: '$countString बार', + one: '$countString बार', ); return '$_temp0'; } @@ -1628,7 +1746,7 @@ class AppLocalizationsHi extends AppLocalizations { String get notifyDueToday => 'आज देय कार्यों की सूचनाएँ'; @override - String get eventReminders => 'ईवेंट रिमाइंडर'; + String get eventReminders => 'इवेंट रिमाइंडर'; @override String get onState => 'चालू'; @@ -1748,7 +1866,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'प्रयास=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'प्रयास=$countString'; } @override @@ -1801,23 +1923,27 @@ class AppLocalizationsHi extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'आज $count कार्य देय हैं।', + other: 'आज $countString कार्य देय हैं।', one: 'आज एक कार्य देय है।', ); return '$_temp0'; } @override - String get eventReminderNotificationTitle => 'ईवेंट रिमाइंडर'; + String get eventReminderNotificationTitle => 'इवेंट रिमाइंडर'; @override String get taskReminderNotificationTitle => 'कार्य रिमाइंडर'; @override - String get eventReminderNotificationBody => 'ईवेंट जल्द शुरू होगा।'; + String get eventReminderNotificationBody => 'इवेंट जल्द शुरू होगा।'; @override String get taskReminderNotificationBody => 'कार्य जल्द देय है।'; @@ -1855,7 +1981,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'सप्ताह $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'सप्ताह $numberString'; } @override @@ -1863,10 +1993,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count आइटम', + other: '$countString आइटम', one: '1 आइटम', ); return '$_temp0'; @@ -2337,7 +2471,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String repeatOnMonthDaysSummaryMultiple(String days) { - return 'के $daysवें दिन'; + return 'की $days तारीखों को'; } @override diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index d864489e..3ce43806 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'il lunedì', + 'TU': 'il martedì', + 'WE': 'il mercoledì', + 'TH': 'il giovedì', + 'FR': 'il venerdì', + 'SA': 'il sabato', + 'SU': 'la domenica', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'nei giorni $first e $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency nei giorni $firstDay e $secondDay di $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency il giorno $day di $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency nei giorni $firstDay e $secondDay di $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency nei giorni $days di $firstMonth e $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -180,7 +241,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String moreItems(int count) { - return '+$count in più'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString in più'; } @override @@ -565,10 +630,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minuti prima', + other: '$minutesString minuti prima', one: '1 minuto prima', ); return '$_temp0'; @@ -579,10 +648,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours ore prima', + other: '$hoursString ore prima', one: '1 ora prima', ); return '$_temp0'; @@ -590,10 +663,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days giorni prima', + other: '$daysString giorni prima', one: '1 giorno prima', ); return '$_temp0'; @@ -1110,7 +1188,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% completata'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completata'; } @override @@ -1124,17 +1206,29 @@ class AppLocalizationsIt extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Priorità $priority · alta'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorità $priorityString · alta'; } @override String priorityMediumValue(int priority) { - return 'Priorità $priority · media'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorità $priorityString · media'; } @override String priorityLowValue(int priority) { - return 'Priorità $priority · bassa'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priorità $priorityString · bassa'; } @override @@ -1268,7 +1362,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Questa data ha $count promemoria correlati. Conservarli alla data e all’ora attuali?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Questa data ha $countString promemoria correlati. Conservarli alla data e all’ora attuali?'; } @override @@ -1393,27 +1491,43 @@ class AppLocalizationsIt extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Ogni $count giorni'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString giorni'; } @override String repeatEveryWeeks(int count) { - return 'Ogni $count settimane'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString settimane'; } @override String repeatEveryMonths(int count) { - return 'Ogni $count mesi'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString mesi'; } @override String repeatEveryYears(int count) { - return 'Ogni $count anni'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ogni $countString anni'; } @override String repeatOnDaysSummary(String days) { - return 'il $days'; + return '$days'; } @override @@ -1443,11 +1557,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count volte', - one: '$count volta', + other: '$countString volte', + one: '$countString volta', ); return '$_temp0'; } @@ -1762,7 +1880,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'tentativi=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'tentativi=$countString'; } @override @@ -1817,10 +1939,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count attività scadono oggi.', + other: '$countString attività scadono oggi.', one: 'Un’attività scade oggi.', ); return '$_temp0'; @@ -1871,7 +1997,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Settimana $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Settimana $numberString'; } @override @@ -1880,10 +2010,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count elementi', + other: '$countString elementi', one: '1 elemento', ); return '$_temp0'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 02ab8c68..7a62fb2a 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '月曜日', + 'TU': '火曜日', + 'WE': '水曜日', + 'TH': '木曜日', + 'FR': '金曜日', + 'SA': '土曜日', + 'SU': '日曜日', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$monthの$firstDayと$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonthと$secondMonthの$day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonthと$secondMonthの$firstDayと$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonthと$secondMonthの$days'; + } + @override String get appTitle => 'BusyMax'; @@ -177,7 +238,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String moreItems(int count) { - return '他 $count 件'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '他 $countString 件'; } @override @@ -553,10 +618,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes分前', + other: '$minutesString分前', one: '1分前', ); return '$_temp0'; @@ -567,10 +636,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours時間前', + other: '$hoursString時間前', one: '1時間前', ); return '$_temp0'; @@ -578,10 +651,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days日前', + other: '$daysString日前', one: '1日前', ); return '$_temp0'; @@ -1079,7 +1157,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% 完了'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% 完了'; } @override @@ -1093,17 +1175,29 @@ class AppLocalizationsJa extends AppLocalizations { @override String priorityHighValue(int priority) { - return '優先度 $priority · 高'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先度 $priorityString · 高'; } @override String priorityMediumValue(int priority) { - return '優先度 $priority · 中'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先度 $priorityString · 中'; } @override String priorityLowValue(int priority) { - return '優先度 $priority · 低'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先度 $priorityString · 低'; } @override @@ -1231,7 +1325,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'この日付には関連するリマインダーが $count 件あります。現在の日付と時刻を保持しますか?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'この日付には関連するリマインダーが $countString 件あります。現在の日付と時刻を保持しますか?'; } @override @@ -1356,22 +1454,38 @@ class AppLocalizationsJa extends AppLocalizations { @override String repeatEveryDays(int count) { - return '$count 日ごと'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString 日ごと'; } @override String repeatEveryWeeks(int count) { - return '$count 週間ごと'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString 週間ごと'; } @override String repeatEveryMonths(int count) { - return '$count か月ごと'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString か月ごと'; } @override String repeatEveryYears(int count) { - return '$count 年ごと'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString 年ごと'; } @override @@ -1406,10 +1520,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count 回', + other: '$countString 回', ); return '$_temp0'; } @@ -1715,7 +1833,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String pendingOpAttempts(int count) { - return '試行回数=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '試行回数=$countString'; } @override @@ -1767,10 +1889,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '今日が期限のタスクが$count件あります。', + other: '今日が期限のタスクが$countString件あります。', one: '今日が期限のタスクが1件あります。', ); return '$_temp0'; @@ -1820,7 +1946,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String weekNumberTooltip(int number) { - return '第$number週'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第$numberString週'; } @override @@ -1828,10 +1958,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count件', + other: '$countString件', one: '1件', ); return '$_temp0'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 57e53d46..9fde4a36 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '월요일', + 'TU': '화요일', + 'WE': '수요일', + 'TH': '목요일', + 'FR': '금요일', + 'SA': '토요일', + 'SU': '일요일', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first과 $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency $month의 $firstDay과 $secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency $firstMonth과 $secondMonth의 $day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency $firstMonth과 $secondMonth의 $firstDay과 $secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency $firstMonth과 $secondMonth의 $days'; + } + @override String get appTitle => 'BusyMax'; @@ -177,7 +238,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String moreItems(int count) { - return '+$count개 더 보기'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString개 더 보기'; } @override @@ -388,7 +453,7 @@ class AppLocalizationsKo extends AppLocalizations { String get guests => '참석자'; @override - String get noGuests => '게스트 없음'; + String get noGuests => '참석자 없음'; @override String get attendeeRequired => '필수'; @@ -412,29 +477,29 @@ class AppLocalizationsKo extends AppLocalizations { String get requestResponses => '응답 요청'; @override - String get requestResponsesDescription => '게스트에게 초대에 응답하도록 요청합니다.'; + String get requestResponsesDescription => '참석자에게 초대에 응답하도록 요청합니다.'; @override - String get hideGuestList => '게스트 목록 숨기기'; + String get hideGuestList => '참석자 목록 숨기기'; @override - String get hideGuestListDescription => '게스트는 다른 초대 대상자를 볼 수 없습니다.'; + String get hideGuestListDescription => '참석자는 다른 초대 대상자를 볼 수 없습니다.'; @override String get allowNewTimeProposals => '새 시간 제안 허용'; @override - String get allowNewTimeProposalsDescription => '게스트가 다른 회의 시간을 제안할 수 있습니다.'; + String get allowNewTimeProposalsDescription => '참석자가 다른 회의 시간을 제안할 수 있습니다.'; @override - String get notifyGuestsTitle => '게스트에게 알릴까요?'; + String get notifyGuestsTitle => '참석자에게 알릴까요?'; @override String get notifyGuestsSaveMessage => - '이 회의에는 게스트가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?'; + '이 회의에는 참석자가 있습니다. 저장할 때 초대 또는 일정 업데이트를 보낼까요?'; @override - String get notifyGuestsDeleteMessage => '이 회의에는 게스트가 있습니다. 삭제할 때 취소를 보낼까요?'; + String get notifyGuestsDeleteMessage => '이 회의에는 참석자가 있습니다. 삭제할 때 취소를 보낼까요?'; @override String get sendUpdates => '업데이트 보내기'; @@ -450,14 +515,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get microsoftNotifyGuestsSaveMessage => - 'Microsoft에서 게스트에게 초대 또는 일정 업데이트를 보냅니다.'; + 'Microsoft에서 참석자에게 초대 또는 일정 업데이트를 보냅니다.'; @override String get microsoftNotifyGuestsDeleteTitle => '회의를 삭제할까요?'; @override String get microsoftNotifyGuestsDeleteMessage => - 'Microsoft에서 게스트에게 취소를 보냅니다.'; + 'Microsoft에서 참석자에게 취소를 보냅니다.'; @override String get organizer => '주최자'; @@ -466,7 +531,7 @@ class AppLocalizationsKo extends AppLocalizations { String get yourResponse => '내 응답'; @override - String get guestResponses => '게스트 응답'; + String get guestResponses => '참석자 응답'; @override String get respond => '응답'; @@ -553,10 +618,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes분 전', + other: '$minutesString분 전', one: '1분 전', ); return '$_temp0'; @@ -567,10 +636,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours시간 전', + other: '$hoursString시간 전', one: '1시간 전', ); return '$_temp0'; @@ -578,10 +651,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days일 전', + other: '$daysString일 전', one: '1일 전', ); return '$_temp0'; @@ -1081,7 +1159,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% 완료'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% 완료'; } @override @@ -1095,17 +1177,29 @@ class AppLocalizationsKo extends AppLocalizations { @override String priorityHighValue(int priority) { - return '우선순위 $priority · 높음'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '우선순위 $priorityString · 높음'; } @override String priorityMediumValue(int priority) { - return '우선순위 $priority · 중간'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '우선순위 $priorityString · 중간'; } @override String priorityLowValue(int priority) { - return '우선순위 $priority · 낮음'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '우선순위 $priorityString · 낮음'; } @override @@ -1233,7 +1327,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return '이 날짜에 관련 알림이 $count개 있습니다. 현재 날짜와 시간에 유지할까요?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '이 날짜에 관련 알림이 $countString개 있습니다. 현재 날짜와 시간에 유지할까요?'; } @override @@ -1358,22 +1456,38 @@ class AppLocalizationsKo extends AppLocalizations { @override String repeatEveryDays(int count) { - return '$count일마다'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString일마다'; } @override String repeatEveryWeeks(int count) { - return '$count주마다'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString주마다'; } @override String repeatEveryMonths(int count) { - return '$count개월마다'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString개월마다'; } @override String repeatEveryYears(int count) { - return '$count년마다'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '$countString년마다'; } @override @@ -1408,10 +1522,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count회', + other: '$countString회', ); return '$_temp0'; } @@ -1717,7 +1835,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String pendingOpAttempts(int count) { - return '시도=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '시도=$countString'; } @override @@ -1769,10 +1891,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '오늘 마감인 할 일이 $count개 있습니다.', + other: '오늘 마감인 할 일이 $countString개 있습니다.', one: '오늘 마감인 할 일이 1개 있습니다.', ); return '$_temp0'; @@ -1822,7 +1948,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String weekNumberTooltip(int number) { - return '$number주차'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '$numberString주차'; } @override @@ -1830,10 +1960,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '항목 $count개', + other: '항목 $countString개', one: '항목 1개', ); return '$_temp0'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 779d2c90..85e923b7 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -12,6 +12,2695 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'on days $first and $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency on day $day of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency on days $firstDay and $secondDay of $firstMonth and $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency on days $days of $firstMonth and $secondMonth'; + } + + @override + String get appTitle => 'BusyMax'; + + @override + String get connectGoogleAccount => + 'Connect Google, Microsoft, Apple iCloud Calendar, or Nextcloud accounts.'; + + @override + String get googlePermissionsConsentNotice => + 'On the Google permission screen, select both Calendar and Tasks permissions.'; + + @override + String get googlePermissionsRequiredRetry => + 'Google Calendar and Google Tasks permissions are required. Please try again and select both checkboxes.'; + + @override + String get finishSetup => 'Finish setup'; + + @override + String get continueSetup => 'Continue'; + + @override + String get onboardingSetupTitle => 'Set Up BusyMax'; + + @override + String get onboardingAccountsStepTitle => 'Connect accounts'; + + @override + String get onboardingAccountsStepDescription => + 'Add every account you want to use. BusyMax syncs supported calendars, events, task lists, and tasks from each account.'; + + @override + String get onboardingPreferencesStepTitle => 'Choose system settings'; + + @override + String get onboardingPreferencesStepDescription => + 'Set desktop behavior, reminders, notification detail, and appearance before opening your schedule.'; + + @override + String get signInWithGoogle => 'Sign in with Google'; + + @override + String get signInWithMicrosoft => 'Sign in with Microsoft'; + + @override + String get googleTasksProvider => 'Google Tasks'; + + @override + String get microsoftTodoProvider => 'Microsoft To Do'; + + @override + String get providerNotConfigured => 'This provider is not configured.'; + + @override + String get waitingForGoogleSignIn => 'Waiting for Google sign-in...'; + + @override + String get waitingForMicrosoftSignIn => 'Waiting for Microsoft sign-in...'; + + @override + String get microsoftSignInNotConfigured => + 'Microsoft sign-in is not configured. Set MICROSOFT_OAUTH_CLIENT_ID.'; + + @override + String get cancel => 'Cancel'; + + @override + String get close => 'Close'; + + @override + String get exit => 'Exit'; + + @override + String get options => 'Options'; + + @override + String get hide => 'Hide'; + + @override + String get show => 'Show'; + + @override + String get export => 'Export'; + + @override + String get save => 'Save'; + + @override + String get settings => 'Settings'; + + @override + String get all => 'All'; + + @override + String get calendarEvents => 'Events'; + + @override + String get calendarTasks => 'Tasks'; + + @override + String get calendar => 'Calendar'; + + @override + String get calendars => 'Calendars'; + + @override + String get newCalendar => 'New calendar'; + + @override + String get calendarColor => 'Calendar color'; + + @override + String calendarColorOption(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Color $numberString'; + } + + @override + String get calendarManagementUnsupported => + 'This provider does not support calendar management in BusyMax.'; + + @override + String get primaryCalendarCannotDelete => + 'The primary calendar cannot be deleted.'; + + @override + String calendarCreateFailed(String error) { + return 'Could not create the calendar: $error'; + } + + @override + String get calendarCreatedRefreshPending => + 'The calendar was created, but BusyMax could not refresh the account. It will appear after the next sync.'; + + @override + String calendarUpdateFailed(String error) { + return 'Could not update the calendar: $error'; + } + + @override + String calendarDeleteFailed(String error) { + return 'Could not delete the calendar: $error'; + } + + @override + String get newEvent => 'New event'; + + @override + String get refreshCalendar => 'Refresh calendar'; + + @override + String get openInProvider => 'Open in provider'; + + @override + String get hideFromSchedule => 'Hide from schedule'; + + @override + String get showInSchedule => 'Show in schedule'; + + @override + String get noCalendarsSynced => 'No calendars synced yet.'; + + @override + String get allDay => 'All day'; + + @override + String moreItems(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString more'; + } + + @override + String get noEventsOrTasks => 'No events or tasks'; + + @override + String get scheduleLoading => 'Loading schedule...'; + + @override + String get scheduleUnavailable => 'Schedule unavailable'; + + @override + String get scheduleNoSources => 'No visible calendars or task lists'; + + @override + String get scheduleNoSourcesDescription => + 'Choose what to show in Settings, then refresh.'; + + @override + String get scheduleSignInRequired => 'Connect an account'; + + @override + String get scheduleSignInDescription => + 'Sign in to sync calendars and tasks.'; + + @override + String get scheduleNoSearchResults => 'No matching events or tasks'; + + @override + String get scheduleNoSearchResultsDescription => + 'Try a different search or clear the current filters.'; + + @override + String get refresh => 'Refresh'; + + @override + String get trayOpenBusyMax => 'Open BusyMax'; + + @override + String get trayShowBusyMax => 'Show BusyMax'; + + @override + String get trayNewEvent => 'New event…'; + + @override + String get trayNewTask => 'New task…'; + + @override + String get trayToday => 'Today'; + + @override + String get trayAllDay => 'All day'; + + @override + String get trayNow => 'Now'; + + @override + String get trayCalendarEvent => 'Calendar event'; + + @override + String get trayUntitledEvent => 'Untitled event'; + + @override + String get trayNothingElseToday => 'Nothing else today'; + + @override + String trayTasksDueToday(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks due today', + one: '1 task due today', + ); + return '$_temp0'; + } + + @override + String get trayOpenTodayAgenda => 'Open today’s agenda'; + + @override + String get traySyncNow => 'Sync now'; + + @override + String get traySyncing => 'Syncing…'; + + @override + String get trayNotConnected => 'Not connected'; + + @override + String get trayNotYetSynced => 'Not yet synced'; + + @override + String get trayLastSyncedJustNow => 'Last synced just now'; + + @override + String trayLastSyncedMinutesAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString minutes ago', + one: 'Last synced 1 minute ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedHoursAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString hours ago', + one: 'Last synced 1 hour ago', + ); + return '$_temp0'; + } + + @override + String trayLastSyncedDaysAgo(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Last synced $countString days ago', + one: 'Last synced 1 day ago', + ); + return '$_temp0'; + } + + @override + String get traySettings => 'Settings'; + + @override + String get trayQuitBusyMax => 'Quit BusyMax'; + + @override + String get agendaLoadMoreOverdue => 'Load more overdue tasks'; + + @override + String get agendaLoadMoreNoDate => 'Load more no-date tasks'; + + @override + String get viewDay => 'Day'; + + @override + String get viewWeek => 'Week'; + + @override + String get viewMonth => 'Month'; + + @override + String get viewYear => 'Year'; + + @override + String get viewAgenda => 'Agenda'; + + @override + String get scheduleSettings => 'Schedule'; + + @override + String get scheduleDisplaySettings => 'Schedule display'; + + @override + String get scheduleDisplayHoursDescription => + 'Day and Week views open within these hours. Early and late items expand the range when needed.'; + + @override + String get scheduleDayStartsAt => 'Day starts at'; + + @override + String get scheduleDayEndsAt => 'Day ends at'; + + @override + String get sourceCalendar => 'Calendar'; + + @override + String get sourceTaskList => 'Task list'; + + @override + String get createChoiceTitle => 'Create'; + + @override + String get createEventAtTime => 'Event'; + + @override + String get createTaskAtDate => 'Task'; + + @override + String get editEvent => 'Edit event'; + + @override + String get eventTitle => 'Event title'; + + @override + String get location => 'Location'; + + @override + String get timeSlot => 'Time slot'; + + @override + String get startDateTime => 'Start date/time'; + + @override + String get endDateTime => 'End date/time'; + + @override + String get doesNotRepeat => 'Does not repeat'; + + @override + String get defaultReminder => 'Default reminder'; + + @override + String get guests => 'Guests'; + + @override + String get noGuests => 'No guests'; + + @override + String get attendeeRequired => 'Required'; + + @override + String get attendeeOptional => 'Optional'; + + @override + String get meetingSection => 'Meeting'; + + @override + String get addGoogleMeet => 'Add Google Meet'; + + @override + String get addTeamsMeeting => 'Add Microsoft Teams meeting'; + + @override + String get onlineMeetingAdded => 'Online meeting added'; + + @override + String get requestResponses => 'Request responses'; + + @override + String get requestResponsesDescription => + 'Ask guests to respond to the invitation.'; + + @override + String get hideGuestList => 'Hide guest list'; + + @override + String get hideGuestListDescription => + 'Guests cannot see who else was invited.'; + + @override + String get allowNewTimeProposals => 'Allow new time proposals'; + + @override + String get allowNewTimeProposalsDescription => + 'Guests can suggest a different meeting time.'; + + @override + String get notifyGuestsTitle => 'Notify guests?'; + + @override + String get notifyGuestsSaveMessage => + 'This meeting has guests. Send invitations or event updates when it is saved?'; + + @override + String get notifyGuestsDeleteMessage => + 'This meeting has guests. Send a cancellation when it is deleted?'; + + @override + String get sendUpdates => 'Send updates'; + + @override + String get sendCancellation => 'Send cancellation'; + + @override + String get doNotSend => 'Don’t send'; + + @override + String get microsoftNotifyGuestsSaveTitle => 'Save meeting?'; + + @override + String get microsoftNotifyGuestsSaveMessage => + 'Microsoft will send invitations or event updates to guests.'; + + @override + String get microsoftNotifyGuestsDeleteTitle => 'Delete meeting?'; + + @override + String get microsoftNotifyGuestsDeleteMessage => + 'Microsoft will send a cancellation to guests.'; + + @override + String get organizer => 'Organizer'; + + @override + String get yourResponse => 'Your response'; + + @override + String get guestResponses => 'Guest responses'; + + @override + String get respond => 'Respond'; + + @override + String get acceptInvitation => 'Accept'; + + @override + String get tentativeInvitation => 'Tentative'; + + @override + String get declineInvitation => 'Decline'; + + @override + String get joinMeeting => 'Join meeting'; + + @override + String get responseAccepted => 'Accepted'; + + @override + String get responseTentative => 'Tentative'; + + @override + String get responseDeclined => 'Declined'; + + @override + String get responseNeedsAction => 'Awaiting response'; + + @override + String get responseNotResponded => 'Not responded'; + + @override + String get responseOrganizer => 'Organizer'; + + @override + String invitationResponseFailed(String error) { + return 'Could not send your response: $error'; + } + + @override + String get joinMeetingFailed => 'Could not open the meeting link.'; + + @override + String get description => 'Description'; + + @override + String get availabilityShowAs => 'Availability / Show as'; + + @override + String get busy => 'Busy'; + + @override + String get visibility => 'Visibility'; + + @override + String get defaultVisibility => 'Default visibility'; + + @override + String get conference => 'Conference'; + + @override + String get noConference => 'No conference'; + + @override + String get providerCalendar => 'Provider calendar'; + + @override + String get formatBoldShortLabel => 'B'; + + @override + String get formatBoldTooltip => 'Bold'; + + @override + String get formatItalicShortLabel => 'I'; + + @override + String get formatItalicTooltip => 'Italic'; + + @override + String get formatUnderlineShortLabel => 'U'; + + @override + String get formatUnderlineTooltip => 'Underline'; + + @override + String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + + String _temp0 = intl.Intl.pluralLogic( + minutes, + locale: localeName, + other: '$minutesString minutes before', + one: '1 minute before', + ); + return '$_temp0'; + } + + @override + String get reminderAtStart => 'At start'; + + @override + String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + + String _temp0 = intl.Intl.pluralLogic( + hours, + locale: localeName, + other: '$hoursString hours before', + one: '1 hour before', + ); + return '$_temp0'; + } + + @override + String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + + String _temp0 = intl.Intl.pluralLogic( + days, + locale: localeName, + other: '$daysString days before', + one: '1 day before', + ); + return '$_temp0'; + } + + @override + String get availabilityFree => 'Free'; + + @override + String get availabilityTentative => 'Tentative'; + + @override + String get availabilityOutOfOffice => 'Out of office'; + + @override + String get availabilityWorkingElsewhere => 'Working elsewhere'; + + @override + String get visibilityDefault => 'Default'; + + @override + String get visibilityPublic => 'Public'; + + @override + String get visibilityPrivate => 'Private'; + + @override + String get visibilityConfidential => 'Confidential'; + + @override + String get sensitivityNormal => 'Normal'; + + @override + String get sensitivityPersonal => 'Personal'; + + @override + String get tasks => 'Tasks'; + + @override + String get allTasks => 'All tasks'; + + @override + String tasksInList(String title) { + return 'Tasks in $title'; + } + + @override + String get taskLists => 'Task lists'; + + @override + String get navigation => 'Navigation'; + + @override + String get mainMenu => 'Main Menu'; + + @override + String get keyboardShortcuts => 'Keyboard Shortcuts'; + + @override + String get shortcutGroupGeneral => 'General'; + + @override + String get shortcutKeyboardShortcutsDescription => + 'Show this shortcuts reference'; + + @override + String get shortcutGroupNavigation => 'Navigation'; + + @override + String get shortcutNextPeriod => 'Next period'; + + @override + String get shortcutNextPeriodDescription => + 'Next week in week view, next month in month view, and so on'; + + @override + String get shortcutPreviousPeriod => 'Previous period'; + + @override + String get shortcutPreviousPeriodDescription => + 'Previous week in week view, previous month in month view, and so on'; + + @override + String get shortcutJumpToToday => 'Jump to today'; + + @override + String get shortcutGroupView => 'View'; + + @override + String get shortcutDayView => 'Day view'; + + @override + String get shortcutWeekView => 'Week view'; + + @override + String get shortcutMonthView => 'Month view'; + + @override + String get shortcutYearView => 'Year view'; + + @override + String get shortcutAgendaView => 'Agenda view'; + + @override + String get shortcutGroupCreateAndEdit => 'Create and Edit'; + + @override + String get shortcutSaveItem => 'Save event or task'; + + @override + String get shortcutDeleteItem => 'Delete event or task'; + + @override + String get shortcutGroupTaskEditing => 'Task editing'; + + @override + String get shortcutCancelEditing => 'Cancel editing'; + + @override + String get shortcutCancelEditingDescription => + 'Close task editing or task details'; + + @override + String get aboutBusyMax => 'About BusyMax'; + + @override + String get aboutBusyMaxDescription => 'Calendar and tasks'; + + @override + String get license => 'License'; + + @override + String get apacheLicenseName => 'Apache License 2.0'; + + @override + String get website => 'Website'; + + @override + String get sourceCode => 'Source code'; + + @override + String get reportAnIssue => 'Report an issue'; + + @override + String get sendFeedback => 'Send feedback'; + + @override + String get feedbackSubmit => 'Submit'; + + @override + String get feedbackCategory => 'Category'; + + @override + String get feedbackSelectCategory => 'Select a category'; + + @override + String get feedbackCategoryProblem => 'Problem or bug'; + + @override + String get feedbackCategoryFeature => 'Feature request'; + + @override + String get feedbackCategoryPrivacySecurity => 'Privacy or security concern'; + + @override + String get feedbackCategoryUsability => 'Usability concern'; + + @override + String get feedbackCategoryOther => 'Other'; + + @override + String get feedbackSubject => 'Subject'; + + @override + String get feedbackDetailedMessage => 'Detailed message'; + + @override + String get feedbackReplyEmail => 'Reply email (optional)'; + + @override + String get feedbackIncludeTechnicalDetails => 'Include technical details'; + + @override + String get feedbackTechnicalDetailsDisclosure => + 'Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.'; + + @override + String get feedbackCategoryRequired => 'Select a category.'; + + @override + String get feedbackSubjectLengthError => + 'Subject must be between 3 and 120 characters.'; + + @override + String get feedbackMessageLengthError => + 'Message must be between 10 and 5,000 characters.'; + + @override + String get feedbackInvalidEmail => 'Enter a valid email address.'; + + @override + String get feedbackConnectionError => + 'Could not connect to BusyStack. Check your connection and try again.'; + + @override + String get feedbackTimeoutError => + 'The request timed out. Your feedback has not been cleared; please try again.'; + + @override + String get feedbackRateLimitedError => + 'Too many feedback submissions have been sent from this network. Please wait and try again.'; + + @override + String get feedbackRejectedError => + 'The server rejected the submission. Review the fields and try again.'; + + @override + String get feedbackServerError => + 'BusyStack could not accept your feedback right now. Your feedback has not been cleared; please try again.'; + + @override + String feedbackSuccess(String id) { + return 'Feedback sent. Reference: $id'; + } + + @override + String get toggleSidebar => 'Toggle Sidebar'; + + @override + String get showSidebar => 'Show sidebar panel'; + + @override + String get hideSidebar => 'Hide sidebar panel'; + + @override + String get accounts => 'Accounts'; + + @override + String get currentAccount => 'Current account'; + + @override + String get switchAccount => 'Switch account'; + + @override + String get addGoogleAccount => 'Add Google account'; + + @override + String get addMicrosoftAccount => 'Add Microsoft account'; + + @override + String get googleProvider => 'Google'; + + @override + String get microsoftProvider => 'Microsoft'; + + @override + String get signedInAccount => 'Signed in'; + + @override + String get removeAccount => 'Remove account…'; + + @override + String get removingAccount => 'Removing account…'; + + @override + String get removeAccountDescription => + 'Stop syncing and remove this account’s data from this device.'; + + @override + String removeAccountTitle(String account) { + return 'Remove $account from BusyMax?'; + } + + @override + String get removeAccountConfirmation => + 'This deletes cached tasks, calendars, events, reminders, and pending offline changes from this device. Unsynced changes will be lost. Provider copies of calendars, events, task lists, and tasks are not deleted.'; + + @override + String get revokeGoogleAccess => + 'Also revoke BusyMax’s access to this Google Account'; + + @override + String get revokeGoogleAccessDescription => + 'You will need to grant access again before reconnecting.'; + + @override + String get removeAccountAction => 'Remove account'; + + @override + String get removeAccountFailed => + 'Could not finish removing the account. Try again.'; + + @override + String get accountRemovedGoogleRevokeFailed => + 'The account was removed from this device, but BusyMax could not revoke Google access. You can revoke it from your Google Account.'; + + @override + String get newTaskList => 'New task list'; + + @override + String taskListCreateFailed(String error) { + return 'Could not create the task list: $error'; + } + + @override + String taskListRenameFailed(String error) { + return 'Could not rename the task list: $error'; + } + + @override + String taskListDeleteFailed(String error) { + return 'Could not delete the task list: $error'; + } + + @override + String get signInToViewTaskLists => 'Sign in to view task lists.'; + + @override + String get noTaskListsSynced => 'No task lists synced yet.'; + + @override + String get listActions => 'List actions'; + + @override + String get rename => 'Rename'; + + @override + String get delete => 'Delete'; + + @override + String get renameList => 'Rename list'; + + @override + String get deleteList => 'Delete list'; + + @override + String get unshare => 'Unshare'; + + @override + String get readOnlyTaskListCannotRename => + 'This task list is read-only and cannot be renamed.'; + + @override + String get taskListCannotDelete => + 'This task list cannot be deleted with your current permissions.'; + + @override + String get builtInMicrosoftList => 'Built-in'; + + @override + String get builtInMicrosoftListCannotRenameDelete => + 'Built-in Microsoft To Do lists cannot be renamed or deleted.'; + + @override + String deleteListConfirmation(String title) { + return 'Delete \"$title\" from Google Tasks?'; + } + + @override + String deleteTaskListConfirmation(String title) { + return 'Delete \"$title\" and all of its tasks?'; + } + + @override + String unshareTaskListConfirmation(String title) { + return 'Unshare \"$title\" from this account?'; + } + + @override + String get deleteEvent => 'Delete Event'; + + @override + String get title => 'Title'; + + @override + String get create => 'Create'; + + @override + String get newTask => 'New task'; + + @override + String get clearCompleted => 'Clear completed'; + + @override + String get refreshList => 'Refresh list'; + + @override + String get refreshAll => 'Refresh all'; + + @override + String get listRefreshed => 'List refreshed.'; + + @override + String get allTasksRefreshed => 'All accounts refreshed.'; + + @override + String exportedFile(String path) { + return 'Exported to $path'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String refreshFailed(String error) { + return 'Refresh failed: $error'; + } + + @override + String get selectOrCreateTaskList => 'Select or create a task list to begin.'; + + @override + String get signInToViewTasks => 'Sign in to view tasks.'; + + @override + String get noTasks => 'No tasks.'; + + @override + String get noTasksYet => 'No tasks yet'; + + @override + String get noTasksYetMessage => + 'Create a task or refresh your accounts to get started.'; + + @override + String get noTasksInList => 'No tasks in this list.'; + + @override + String get overdue => 'Overdue'; + + @override + String get today => 'Today'; + + @override + String get tomorrow => 'Tomorrow'; + + @override + String get upcoming => 'Upcoming'; + + @override + String get noDate => 'No date'; + + @override + String get completed => 'Completed'; + + @override + String duePrefix(String date) { + return 'Due $date'; + } + + @override + String dateTimeDisplay(String date, String time) { + return '$date · $time'; + } + + @override + String get taskDetails => 'Task details'; + + @override + String get editTask => 'Edit Task'; + + @override + String get noTaskSelected => 'No task selected.'; + + @override + String get noTaskSelectedHelper => 'Select a task to view and edit details.'; + + @override + String get taskUnavailable => 'Task unavailable.'; + + @override + String get signInToEditTasks => 'Sign in to edit tasks.'; + + @override + String get refreshTask => 'Refresh task'; + + @override + String get primarySection => 'Primary'; + + @override + String get statusSection => 'Status'; + + @override + String get openStatus => 'Open'; + + @override + String get doneStatus => 'Done'; + + @override + String get taskStatus => 'Status'; + + @override + String get taskStatusNone => 'No status'; + + @override + String get taskStatusNeedsAction => 'Needs action'; + + @override + String get taskStatusInProcess => 'In progress'; + + @override + String get taskStatusCompleted => 'Completed'; + + @override + String get taskStatusCancelled => 'Cancelled'; + + @override + String completionPercent(int percent) { + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% completed'; + } + + @override + String get completionDate => 'Completion date'; + + @override + String get priority => 'Priority'; + + @override + String get priorityNone => 'No priority'; + + @override + String priorityHighValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · High'; + } + + @override + String priorityMediumValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Medium'; + } + + @override + String priorityLowValue(int priority) { + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Priority $priorityString · Low'; + } + + @override + String get taskUrl => 'URL'; + + @override + String get invalidTaskUrl => 'Enter an absolute URL, including its scheme.'; + + @override + String get classification => 'Classification'; + + @override + String get classificationPublic => 'When shared, show the full task'; + + @override + String get classificationConfidential => 'When shared, show only busy'; + + @override + String get classificationPrivate => 'When shared, hide this task'; + + @override + String get pinTask => 'Pin task'; + + @override + String get notes => 'Notes'; + + @override + String get dueDate => 'Due date'; + + @override + String get clearDueDate => 'Clear due date'; + + @override + String get dueTime => 'Due time'; + + @override + String get startDate => 'Start date'; + + @override + String get startTime => 'Start time'; + + @override + String get endDate => 'End Date'; + + @override + String get endTime => 'End Time'; + + @override + String get reminderDate => 'Reminder date'; + + @override + String get reminderTime => 'Reminder time'; + + @override + String get reminder => 'Reminder'; + + @override + String get addReminder => 'Add Reminder'; + + @override + String get reminders => 'Reminders'; + + @override + String get noReminders => 'No reminders'; + + @override + String get editReminder => 'Edit reminder'; + + @override + String get beforeTaskStarts => 'Before the task starts'; + + @override + String get beforeTaskDue => 'Before the task is due'; + + @override + String get afterTaskStarts => 'After the task starts'; + + @override + String get afterTaskDue => 'After the task is due'; + + @override + String get relativeToTaskStart => 'Relative to the task start date'; + + @override + String get relativeToTaskDue => 'Relative to the task due date'; + + @override + String get reminderTimeOfDay => 'Time of day'; + + @override + String get absoluteReminder => 'At a date and time'; + + @override + String get reminderAmount => 'Amount'; + + @override + String get reminderUnit => 'Unit'; + + @override + String get reminderUnitSeconds => 'Seconds'; + + @override + String get reminderUnitMinutes => 'Minutes'; + + @override + String get reminderUnitHours => 'Hours'; + + @override + String get reminderUnitDays => 'Days'; + + @override + String get reminderUnitWeeks => 'Weeks'; + + @override + String get reminderAtTaskStart => 'At the task start'; + + @override + String get reminderAtTaskDue => 'At the task due time'; + + @override + String get unsupportedReminder => + 'This reminder type is preserved but its time cannot be edited.'; + + @override + String get relatedRemindersTitle => 'Keep related reminders?'; + + @override + String relatedRemindersDescription(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'This date has $countString related reminders. Keep them at their current date and time?'; + } + + @override + String get discardRelatedReminders => 'Discard reminders'; + + @override + String get keepRelatedReminders => 'Keep reminders'; + + @override + String get addGuest => 'Add Guest'; + + @override + String get addGuestEmail => 'Add guest email'; + + @override + String get removeReminder => 'Remove reminder'; + + @override + String get off => 'Off'; + + @override + String get repeat => 'Repeat'; + + @override + String get repeatNone => 'None'; + + @override + String get noneValue => 'None'; + + @override + String get repeatDaily => 'Daily'; + + @override + String get repeatWeekly => 'Weekly'; + + @override + String get repeatMonthly => 'Monthly'; + + @override + String get repeatYearly => 'Yearly'; + + @override + String get repeatEvery => 'Interval'; + + @override + String get repeatOn => 'Repeat on'; + + @override + String get repeatEnd => 'End repeat'; + + @override + String get repeatNever => 'Never'; + + @override + String get repeatUntil => 'On date'; + + @override + String get repeatAfter => 'After a number of occurrences'; + + @override + String get repeatCount => 'Occurrences'; + + @override + String get repeatDayOfMonth => 'Days of month'; + + @override + String get repeatMonths => 'Months'; + + @override + String get repeatOrdinal => 'Weekday position'; + + @override + String get repeatSpecificDays => 'Specific days'; + + @override + String get repeatFirst => 'First'; + + @override + String get repeatSecond => 'Second'; + + @override + String get repeatThird => 'Third'; + + @override + String get repeatFourth => 'Fourth'; + + @override + String get repeatFifth => 'Fifth'; + + @override + String get repeatSecondToLast => 'Second to last'; + + @override + String get repeatLast => 'Last'; + + @override + String get repeatAnyDay => 'Day'; + + @override + String get repeatWeekday => 'Weekday'; + + @override + String get repeatWeekendDay => 'Weekend day'; + + @override + String repeatOrdinalDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + 'day': 'day', + 'weekday': 'weekday', + 'weekend': 'weekend day', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatEveryDays(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString days'; + } + + @override + String repeatEveryWeeks(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString weeks'; + } + + @override + String repeatEveryMonths(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString months'; + } + + @override + String repeatEveryYears(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Every $countString years'; + } + + @override + String repeatOnDaysSummary(String days) { + return 'on $days'; + } + + @override + String repeatOnMonthDaysSummary(String days) { + return 'on day $days'; + } + + @override + String repeatOnOrdinalSummary(String position, String days) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days', + 'second': 'on the second $days', + 'third': 'on the third $days', + 'fourth': 'on the fourth $days', + 'fifth': 'on the fifth $days', + 'secondToLast': 'on the second to last $days', + 'last': 'on the last $days', + 'other': 'on $days', + }); + return '$_temp0'; + } + + @override + String repeatInMonthsSummary(String months) { + return 'in $months'; + } + + @override + String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString times', + one: '$countString time', + ); + return '$_temp0'; + } + + @override + String repeatUntilSummary(String date) { + return 'until $date'; + } + + @override + String get unsupportedRecurrencePreserved => + 'This recurrence rule uses options that this editor does not change.'; + + @override + String recurrenceUnsupportedByProvider(String provider) { + return 'This recurrence cannot be used with $provider.'; + } + + @override + String get importance => 'Importance'; + + @override + String get importanceLow => 'Low'; + + @override + String get importanceNormal => 'Normal'; + + @override + String get importanceHigh => 'High'; + + @override + String get categories => 'Categories'; + + @override + String get scheduleSection => 'Schedule'; + + @override + String get dueGroup => 'Due'; + + @override + String get startGroup => 'Start'; + + @override + String get reminderGroup => 'Reminder'; + + @override + String get organizationSection => 'Organization'; + + @override + String get actionsSection => 'Actions'; + + @override + String get advancedSection => 'Advanced'; + + @override + String get addCategory => 'Add category'; + + @override + String get list => 'List'; + + @override + String get microsoftMoveUnsupported => + 'Moving between lists is not supported for Microsoft To Do accounts in this version.'; + + @override + String get createSubtask => 'Create subtask'; + + @override + String get subtasks => 'Subtasks'; + + @override + String get duplicateTask => 'Duplicate task'; + + @override + String get taskDuplicated => 'Task duplicated.'; + + @override + String taskDuplicateFailed(String error) { + return 'Could not duplicate the task: $error'; + } + + @override + String get hideSubtasks => 'Hide subtasks'; + + @override + String get hideClosedSubtasks => 'Hide closed subtasks'; + + @override + String get moveToTop => 'Move to top'; + + @override + String get deleteTask => 'Delete Task'; + + @override + String get newSubtask => 'New subtask'; + + @override + String deleteTaskConfirmation(String title) { + return 'Delete \"$title\"?'; + } + + @override + String get metadata => 'Metadata'; + + @override + String get id => 'ID'; + + @override + String get etag => 'ETag'; + + @override + String get updated => 'Updated'; + + @override + String get parent => 'Parent'; + + @override + String get position => 'Position'; + + @override + String get webLink => 'Web link'; + + @override + String get assignment => 'Assignment'; + + @override + String get localState => 'Local state'; + + @override + String get pendingSync => 'Pending sync'; + + @override + String get synced => 'Synced'; + + @override + String get account => 'Account'; + + @override + String get sync => 'Sync'; + + @override + String get forceFullResync => 'Force full resync'; + + @override + String get forceFullResyncDescription => + 'Completely reload data from every connected account. Use this only to troubleshoot synchronization problems.'; + + @override + String get runInBackgroundWhenClosed => + 'Continue running when the window is closed'; + + @override + String get showTrayIcon => 'Show tray icon'; + + @override + String get startMinimizedToTray => 'Start minimized to the tray'; + + @override + String get launchAtLogin => 'Launch at login'; + + @override + String get launchAtLoginDescription => + 'Start BusyMax in the background so reminders work after you sign in.'; + + @override + String get launchAtLoginFailed => + 'Could not update the launch-at-login setting.'; + + @override + String get requiresTrayIcon => 'Requires the tray icon.'; + + @override + String get syncComplete => 'Sync complete.'; + + @override + String syncFailed(String error) { + return 'Sync failed: $error'; + } + + @override + String get notifySyncFailures => 'Notifications on sync failure'; + + @override + String get notifyConflicts => 'Notifications on conflicts'; + + @override + String get notifyDueToday => 'Due-today notifications'; + + @override + String get eventReminders => 'Event reminders'; + + @override + String get onState => 'On'; + + @override + String get taskReminders => 'Task reminders'; + + @override + String get notificationDetailLevel => 'Notification detail level'; + + @override + String get notificationDetailPrivate => 'Private'; + + @override + String get notificationDetailNormal => 'Normal'; + + @override + String get quietHours => 'Quiet hours'; + + @override + String get quietHoursDescription => 'Pause notifications during this period.'; + + @override + String get quietHoursStart => 'Quiet hours start'; + + @override + String get quietHoursEnd => 'Quiet hours end'; + + @override + String get notifications => 'Notifications'; + + @override + String get appearance => 'Appearance'; + + @override + String get theme => 'Theme'; + + @override + String get themeSystem => 'System'; + + @override + String get settingsSystem => 'System'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get themeFamily => 'Theme family'; + + @override + String get themeFamilyYaru => 'Native Ubuntu (Yaru)'; + + @override + String get localization => 'Localization'; + + @override + String get currentLocale => 'Current locale'; + + @override + String get privacy => 'Privacy'; + + @override + String get redactTaskContentInDiagnostics => + 'Redact task content in diagnostics'; + + @override + String get developerDiagnostics => 'Developer diagnostics'; + + @override + String get diagnostics => 'Diagnostics'; + + @override + String get apiInspectorDisabled => 'Show API inspector'; + + @override + String get googleTasksApi => 'Google Tasks API'; + + @override + String discoveryRevision(String revision) { + return 'Discovery revision: $revision'; + } + + @override + String get implementedMethods => 'Implemented methods'; + + @override + String get supportsTasksScopes => 'Supports tasks and tasks.readonly scopes'; + + @override + String get requiresTasksScope => 'Requires tasks scope'; + + @override + String get blockedPendingOperations => 'Blocked pending operations'; + + @override + String get signInToInspectPendingOperations => + 'Sign in to inspect pending operations.'; + + @override + String get noBlockedPendingOperations => 'No blocked pending operations.'; + + @override + String get operationActions => 'Operation actions'; + + @override + String pendingOpListId(String id) { + return 'list=$id'; + } + + @override + String pendingOpTaskId(String id) { + return 'task=$id'; + } + + @override + String pendingOpAttempts(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'attempts=$countString'; + } + + @override + String get retry => 'Retry'; + + @override + String get discard => 'Discard'; + + @override + String get discardChangesAction => 'Discard'; + + @override + String get discardChanges => 'Discard changes?'; + + @override + String get discardChangesConfirmation => + 'This discards unsaved edits to this task.'; + + @override + String get retryCompleted => 'Retry completed.'; + + @override + String get discardPendingOperation => 'Discard pending operation?'; + + @override + String get discardPendingOperationConfirmation => + 'This removes the blocked local operation. The next sync will refresh from Google Tasks.'; + + @override + String get pendingOperationDiscarded => 'Pending operation discarded.'; + + @override + String get syncFailureNotificationTitle => 'BusyMax sync failed'; + + @override + String syncFailureNotificationBody(String message) { + return 'Background sync failed. $message'; + } + + @override + String get conflictNotificationTitle => 'BusyMax sync conflict'; + + @override + String conflictNotificationBody(String summary) { + return 'A pending local change was blocked. $summary'; + } + + @override + String get dueTodayNotificationTitle => 'Tasks due today'; + + @override + String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString tasks are due today.', + one: 'One task is due today.', + ); + return '$_temp0'; + } + + @override + String get eventReminderNotificationTitle => 'Event reminder'; + + @override + String get taskReminderNotificationTitle => 'Task reminder'; + + @override + String get eventReminderNotificationBody => 'Event starts soon.'; + + @override + String get taskReminderNotificationBody => 'Task is due soon.'; + + @override + String get notificationOpenAction => 'Open'; + + @override + String get notificationSnoozeAction => 'Snooze 10 minutes'; + + @override + String get notificationDismissAction => 'Dismiss'; + + @override + String get notificationDetailsHidden => + 'Details are hidden by privacy settings.'; + + @override + String get previousMonth => 'Previous month'; + + @override + String get nextMonth => 'Next month'; + + @override + String get openMonthView => 'Open month view'; + + @override + String get previousYear => 'Previous year'; + + @override + String get nextYear => 'Next year'; + + @override + String get openYearView => 'Open year view'; + + @override + String weekNumberTooltip(int number) { + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Week $numberString'; + } + + @override + String get resizeAllDayPanel => 'Resize the all-day panel'; + + @override + String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$countString items', + one: '1 item', + ); + return '$_temp0'; + } + + @override + String get readOnlyCalendar => 'This calendar is read-only.'; + + @override + String get selectTimeZone => 'Select Timezone'; + + @override + String get searchLocations => 'Search locations'; + + @override + String get noLocationsFound => 'No locations found'; + + @override + String get requiredField => 'This field is required.'; + + @override + String get providerConnectionDescription => + 'Connect calendars and tasks from one of these providers.'; + + @override + String get appleICloudProvider => 'Apple iCloud Calendar'; + + @override + String get nextcloudProvider => 'Nextcloud'; + + @override + String get appleICloudTasksProvider => 'Apple iCloud'; + + @override + String get nextcloudTasksProvider => 'Nextcloud Tasks'; + + @override + String get addAppleICloudAccount => 'Add Apple iCloud Calendar account'; + + @override + String get addNextcloudAccount => 'Add Nextcloud account'; + + @override + String get waitingForAppleICloud => 'Connecting to Apple iCloud…'; + + @override + String get waitingForNextcloud => 'Waiting for Nextcloud authorization…'; + + @override + String get connectAppleICloudTitle => 'Connect Apple iCloud Calendar'; + + @override + String get appleAccountEmail => 'Apple Account email'; + + @override + String get appleAppSpecificPassword => 'App-specific password'; + + @override + String get appleAppSpecificPasswordHelp => + 'Create an app-specific password after enabling two-factor authentication for your Apple Account.'; + + @override + String get appleAppSpecificPasswordResetWarning => + 'Resetting your Apple Account password revokes app-specific passwords.'; + + @override + String get connectNextcloudTitle => 'Connect Nextcloud'; + + @override + String get nextcloudServerUrl => 'Nextcloud server or CalDAV address'; + + @override + String get nextcloudServerUrlHelp => + 'Enter your Nextcloud server URL, or paste the primary CalDAV address copied from Nextcloud.'; + + @override + String get nextcloudBrowserAuthorizationHelp => + 'BusyMax will open your browser. Approve access there, then return to BusyMax.'; + + @override + String get connectAccountAction => 'Connect'; + + @override + String get cancelAccountConnection => 'Cancel connection'; + + @override + String get nextcloudAccountRemovedRevokeFailed => + 'The account was removed locally, but its Nextcloud app password could not be revoked.'; + + @override + String get davCachedOfflineNotice => + 'Calendar and task data is cached locally for offline use.'; + + @override + String get davReauthenticationRequired => + 'Reconnect this account to resume synchronization.'; + + @override + String get davTemporarilyUnavailable => + 'This account is temporarily unavailable.'; + + @override + String get davPermissionChanged => + 'Server permissions changed. Pending edits are paused.'; + + @override + String get davUnsupportedServer => + 'This server or provider profile is not supported.'; + + @override + String get collectionSettings => 'Calendars and task lists'; + + @override + String get calendarContent => 'Calendar events'; + + @override + String get taskContent => 'Tasks'; + + @override + String get readOnlySharedCollection => 'Read-only'; + + @override + String get pendingLocally => 'Pending locally'; + + @override + String get conflictBlocked => 'Blocked by conflict'; + + @override + String get authenticationBlocked => 'Blocked until reconnect'; + + @override + String get operationFailed => 'Operation failed'; + + @override + String get keepServerVersion => 'Keep server version'; + + @override + String get reapplyLocalChange => 'Review and reapply local change'; + + @override + String get duplicateLocalItem => 'Duplicate as new item'; + + @override + String get davConnectionState => 'Connection state'; + + @override + String get davConnected => 'Connected'; + + @override + String get davConnecting => 'Connecting…'; + + @override + String get davSignedOut => 'Signed out'; + + @override + String davLastSuccessfulSync(String time) { + return 'Last successful sync: $time'; + } + + @override + String get davNeverSynced => 'Not synchronized yet'; + + @override + String get refreshCollections => 'Refresh calendars and task lists'; + + @override + String nextcloudServerHost(String host) { + return 'Server: $host'; + } + + @override + String get collectionSupportsEvents => 'Event calendar'; + + @override + String get collectionSupportsTasks => 'Task list'; + + @override + String get collectionSupportsEventsAndTasks => 'Events and tasks'; + + @override + String get writableCollection => 'Writable'; + + @override + String get sharedCollection => 'Shared'; + + @override + String collectionLastSynced(String time) { + return 'Last synchronized: $time'; + } + + @override + String collectionSyncError(String code) { + return 'Sync issue: $code'; + } + + @override + String get syncConflicts => 'Synchronization conflicts'; + + @override + String remoteChangedAt(String time) { + return 'Server changed: $time'; + } + + @override + String localPendingEdit(String summary) { + return 'Local edit: $summary'; + } + + @override + String get conflictResolutionFailed => 'The conflict could not be resolved.'; + + @override + String get recurringEventScope => 'Recurring event scope'; + + @override + String get entireSeries => 'Entire series'; + + @override + String get singleOccurrence => 'This event'; + + @override + String get thisAndFollowingEvents => 'This and following events'; + + @override + String get thisAndFutureUnavailable => 'Not supported by this provider.'; + + @override + String get thisAndFutureMoveUnavailable => + 'This and following events cannot be moved safely. Choose this event or the entire series.'; + + @override + String get entireSeriesMoveUnavailable => + 'The recurrence rule is not available locally. Move this event instead.'; + + @override + String get copyEventAndDeleteOriginal => 'Copy event and delete original?'; + + @override + String copyEventMoveWarning(String source, String destination) { + return 'BusyMax cannot move this event directly from $source to $destination. It will create the copy first and delete the original only after the copy succeeds. Event IDs will change; attendee response statuses may reset and invitations or cancellations may be sent; conference links, attachments, reminders, provider-specific fields, and recurrence exceptions may not carry over.'; + } + + @override + String get copyAndDelete => 'Copy and delete'; + + @override + String get chooseRecurringEventScope => + 'Choose whether this change applies to the entire series, only this occurrence, or this and following events.'; + + @override + String get taskDueBeforeStart => 'Due must not be before start.'; + + @override + String get taskStartDueTimeModeMismatch => + 'Set times for both start and due, or make the task all day.'; + + @override + String deleteCalendarConfirmation(String title) { + return 'Delete \"$title\"?'; + } + + @override + String get setCustomCalendarName => 'Set custom name'; + + @override + String get setAction => 'Set'; + + @override + String get removeFromMyCalendars => 'Remove from my calendars'; + + @override + String get removeAction => 'Remove'; + + @override + String removeCalendarConfirmation(String title) { + return 'Remove \"$title\" from your Google Calendar list? The shared calendar and its events will not be deleted.'; + } + + @override + String get calendarCannotRemove => + 'This calendar cannot be deleted or removed from this account.'; + + @override + String get calendarPendingChangesPreventRemoval => + 'Wait for this calendar’s pending changes to finish syncing before deleting or removing it.'; + + @override + String get calendarSubscriptions => 'Calendar subscriptions'; + + @override + String get calendarSubscriptionsDescription => + 'Add read-only calendars that refresh from a secure WebCal URL.'; + + @override + String get addCalendarSubscription => 'Add calendar subscription'; + + @override + String get subscriptionName => 'Local name'; + + @override + String get subscriptionUrl => 'Subscription URL'; + + @override + String get subscriptionUrlHelp => + 'Enter an HTTPS or webcal URL. BusyMax keeps the complete URL in secure storage.'; + + @override + String get subscriptionUrlInvalid => + 'Enter a valid HTTPS or webcal URL without user information or a fragment.'; + + @override + String get subscriptionColor => 'Local color'; + + @override + String get subscriptionColorHelp => 'Use a six-digit color such as #3584E4.'; + + @override + String get subscriptionColorInvalid => 'Enter a six-digit hexadecimal color.'; + + @override + String get subscriptionRefreshMode => 'Refresh frequency'; + + @override + String get subscriptionAutomatic => 'Automatic'; + + @override + String get subscriptionHourly => 'Hourly'; + + @override + String get subscriptionSixHours => 'Every six hours'; + + @override + String get subscriptionDaily => 'Daily'; + + @override + String subscriptionSafeOrigin(String origin) { + return 'Source: $origin'; + } + + @override + String get subscriptionSafeOriginUnavailable => + 'Enter a valid URL to preview its safe origin.'; + + @override + String get subscriptionReadOnly => 'Read-only subscription'; + + @override + String get subscriptionNeverRefreshed => 'Not refreshed yet'; + + @override + String subscriptionLastRefresh(String time) { + return 'Last successful refresh: $time'; + } + + @override + String subscriptionNextRefresh(String time) { + return 'Next refresh: $time'; + } + + @override + String get subscriptionStatusHealthy => 'Up to date'; + + @override + String subscriptionStatusIssue(String code) { + return 'Refresh issue: $code'; + } + + @override + String get refreshNow => 'Refresh now'; + + @override + String get unsubscribe => 'Unsubscribe'; + + @override + String unsubscribeCalendarTitle(String name) { + return 'Unsubscribe from “$name”?'; + } + + @override + String get unsubscribeCalendarConfirmation => + 'This removes the local subscription and its cached events. The published calendar is not changed.'; + + @override + String get addSubscriptionAction => 'Add subscription'; + + @override + String subscriptionOperationFailed(String error) { + return 'Calendar subscription failed: $error'; + } + + @override + String get subscriptions => 'Subscriptions'; + + @override + String get calendarImport => 'Calendar import'; + + @override + String get calendarImportDescription => + 'Select a file, review its events, then choose the writable calendar that should receive them.'; + + @override + String get importIcsFile => 'Import .ics file'; + + @override + String get importIcsPreview => 'Import calendar events'; + + @override + String importEventsFound(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Importable event sets: $countString'; + } + + @override + String importInvalidEvents(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Invalid events: $countString'; + } + + @override + String importFieldsOmitted(String fields) { + return 'Intentionally omitted: $fields'; + } + + @override + String get noWritableCalendars => + 'No writable destination calendar is available.'; + + @override + String get importDestinationCalendar => 'Destination calendar'; + + @override + String get importIcsConfirm => 'Import events'; + + @override + String get importIcsComplete => 'Import complete'; + + @override + String importQueued(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Imported or queued: $countString'; + } + + @override + String importDuplicatesSkipped(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Duplicates skipped: $countString'; + } + + @override + String importUnsupportedSets(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Unsupported recurrence sets: $countString'; + } + + @override + String importIcsFailed(String error) { + return 'Could not import the calendar file: $error'; + } + + @override + String get networkOffline => 'Offline'; + + @override + String get networkOfflineDescription => + 'Changes will sync when the connection is restored.'; + + @override + String get networkOfflineTryAgain => + 'You’re offline. Connect to the internet and try again.'; + + @override + String repeatOnMonthDaysSummaryMultiple(String days) { + return 'on days $days'; + } + + @override + String get repeatSummarySeparator => ' '; + + @override + String repeatMonthDayValue(String day) { + return '$day'; + } + + @override + String repeatWeekdayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatWeekdayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthValue(String month, String monthKey) { + String _temp0 = intl.Intl.selectLogic(monthKey, {'other': '$month'}); + return '$_temp0'; + } + + @override + String repeatYearlyMonthDayListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthDayListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyMonthListPair(String first, String second) { + return '$first and $second'; + } + + @override + String repeatYearlyMonthListStart(String first, String rest) { + return '$first, $rest'; + } + + @override + String repeatYearlyOnMonthDaySummary( + String frequency, + String month, + String day, + ) { + return '$frequency on $month $day'; + } + + @override + String repeatYearlyOnMonthDaysSummary( + String frequency, + String month, + String days, + ) { + return '$frequency on days $days of $month'; + } + + @override + String repeatYearlyInMonthsOnMonthDaySummary( + String frequency, + String months, + String day, + ) { + return '$frequency on day $day of $months'; + } + + @override + String repeatYearlyInMonthsOnMonthDaysSummary( + String frequency, + String months, + String days, + ) { + return '$frequency on days $days of $months'; + } + + @override + String repeatYearlyOnOrdinalSummary( + String frequency, + String month, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $month', + 'second': 'on the second $days of $month', + 'third': 'on the third $days of $month', + 'fourth': 'on the fourth $days of $month', + 'fifth': 'on the fifth $days of $month', + 'secondToLast': 'on the second to last $days of $month', + 'last': 'on the last $days of $month', + 'other': 'on $days of $month', + }); + return '$frequency $_temp0'; + } + + @override + String repeatYearlyInMonthsOnOrdinalSummary( + String frequency, + String months, + String position, + String days, + ) { + String _temp0 = intl.Intl.selectLogic(position, { + 'first': 'on the first $days of $months', + 'second': 'on the second $days of $months', + 'third': 'on the third $days of $months', + 'fourth': 'on the fourth $days of $months', + 'fifth': 'on the fifth $days of $months', + 'secondToLast': 'on the second to last $days of $months', + 'last': 'on the last $days of $months', + 'other': 'on $days of $months', + }); + return '$frequency $_temp0'; + } +} + +/// The translations for Portuguese, as used in Portugal (`pt_PT`). +class AppLocalizationsPtPt extends AppLocalizationsPt { + AppLocalizationsPtPt() : super('pt_PT'); + + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'à segunda-feira', + 'TU': 'à terça-feira', + 'WE': 'à quarta-feira', + 'TH': 'à quinta-feira', + 'FR': 'à sexta-feira', + 'SA': 'ao sábado', + 'SU': 'ao domingo', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'nos dias $first e $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency nos dias $firstDay e $secondDay de $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency no dia $day de $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency nos dias $firstDay e $secondDay de $firstMonth e $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency nos dias $days de $firstMonth e $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -180,7 +2869,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String moreItems(int count) { - return '+$count mais'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString mais'; } @override @@ -566,10 +3259,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes minutos antes', + other: '$minutesString minutos antes', one: '1 minuto antes', ); return '$_temp0'; @@ -580,10 +3277,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours horas antes', + other: '$hoursString horas antes', one: '1 hora antes', ); return '$_temp0'; @@ -591,10 +3292,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days dias antes', + other: '$daysString dias antes', one: '1 dia antes', ); return '$_temp0'; @@ -625,7 +3331,7 @@ class AppLocalizationsPt extends AppLocalizations { String get visibilityConfidential => 'Confidencial'; @override - String get sensitivityNormal => 'Habitual'; + String get sensitivityNormal => 'Normal'; @override String get sensitivityPersonal => 'Pessoal'; @@ -1111,7 +3817,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String completionPercent(int percent) { - return '$percent% concluída'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '$percentString% concluída'; } @override @@ -1125,17 +3835,29 @@ class AppLocalizationsPt extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Prioridade $priority · Alta'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridade $priorityString · Alta'; } @override String priorityMediumValue(int priority) { - return 'Prioridade $priority · Média'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridade $priorityString · Média'; } @override String priorityLowValue(int priority) { - return 'Prioridade $priority · Baixa'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Prioridade $priorityString · Baixa'; } @override @@ -1267,7 +3989,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Esta data tem $count lembretes relacionados. Mantê-los na data e hora atuais?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Esta data tem $countString lembretes relacionados. Mantê-los na data e hora atuais?'; } @override @@ -1392,27 +4118,43 @@ class AppLocalizationsPt extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'A cada $count dias'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString dias'; } @override String repeatEveryWeeks(int count) { - return 'A cada $count semanas'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString semanas'; } @override String repeatEveryMonths(int count) { - return 'A cada $count meses'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString meses'; } @override String repeatEveryYears(int count) { - return 'A cada $count anos'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'A cada $countString anos'; } @override String repeatOnDaysSummary(String days) { - return 'em $days'; + return '$days'; } @override @@ -1442,11 +4184,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count vezes', - one: '$count vez', + other: '$countString vezes', + one: '$countString vez', ); return '$_temp0'; } @@ -1760,7 +4506,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'tentativas=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'tentativas=$countString'; } @override @@ -1815,10 +4565,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Há $count tarefas com prazo para hoje.', + other: 'Há $countString tarefas com prazo para hoje.', one: 'Há uma tarefa com prazo para hoje.', ); return '$_temp0'; @@ -1869,7 +4623,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Semana $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Semana $numberString'; } @override @@ -1877,10 +4635,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count itens', + other: '$countString itens', one: '1 item', ); return '$_temp0'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 749a9ca7..3e4d744e 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'понедельникам', + 'TU': 'вторникам', + 'WE': 'средам', + 'TH': 'четвергам', + 'FR': 'пятницам', + 'SA': 'субботам', + 'SU': 'воскресеньям', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first и $second числа каждого месяца'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency: $month, $firstDay и $secondDay числа'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency: $firstMonth и $secondMonth, $day числа'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency: $firstMonth и $secondMonth, $firstDay и $secondDay числа'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency: $firstMonth и $secondMonth, $days числа'; + } + @override String get appTitle => 'BusyMax'; @@ -178,7 +239,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String moreItems(int count) { - return '+ ещё $count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+ ещё $countString'; } @override @@ -568,13 +633,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: 'За $minutes минуты', - many: 'За $minutes минут', - few: 'За $minutes минуты', - one: 'За $minutes минуту', + other: 'За $minutesString минуты', + many: 'За $minutesString минут', + few: 'За $minutesString минуты', + one: 'За $minutesString минуту', ); return '$_temp0'; } @@ -584,26 +653,35 @@ class AppLocalizationsRu extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: 'За $hours часа', - many: 'За $hours часов', - few: 'За $hours часа', - one: 'За $hours час', + other: 'За $hoursString часа', + many: 'За $hoursString часов', + few: 'За $hoursString часа', + one: 'За $hoursString час', ); return '$_temp0'; } @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: 'За $days дня', - many: 'За $days дней', - few: 'За $days дня', - one: 'За $days день', + other: 'За $daysString дня', + many: 'За $daysString дней', + few: 'За $daysString дня', + one: 'За $daysString день', ); return '$_temp0'; } @@ -1119,7 +1197,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String completionPercent(int percent) { - return 'Выполнено на $percent%'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Выполнено на $percentString%'; } @override @@ -1133,17 +1215,29 @@ class AppLocalizationsRu extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Приоритет $priority · высокий'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Приоритет $priorityString · высокий'; } @override String priorityMediumValue(int priority) { - return 'Приоритет $priority · средний'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Приоритет $priorityString · средний'; } @override String priorityLowValue(int priority) { - return 'Приоритет $priority · низкий'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Приоритет $priorityString · низкий'; } @override @@ -1274,7 +1368,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'На эту дату приходится $count связанных напоминаний. Сохранить их текущие дату и время?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'На эту дату приходится $countString связанных напоминаний. Сохранить их текущие дату и время?'; } @override @@ -1399,27 +1497,43 @@ class AppLocalizationsRu extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Каждые $count дн.'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString дн.'; } @override String repeatEveryWeeks(int count) { - return 'Каждые $count нед.'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString нед.'; } @override String repeatEveryMonths(int count) { - return 'Каждые $count мес.'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString мес.'; } @override String repeatEveryYears(int count) { - return 'Каждые $count г.'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Каждые $countString г.'; } @override String repeatOnDaysSummary(String days) { - return 'в дни $days'; + return 'по $days'; } @override @@ -1449,13 +1563,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count раза', - many: '$count раз', - few: '$count раза', - one: '$count раз', + other: '$countString раза', + many: '$countString раз', + few: '$countString раза', + one: '$countString раз', ); return '$_temp0'; } @@ -1767,7 +1885,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'попытки=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'попытки=$countString'; } @override @@ -1820,13 +1942,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Сегодня нужно выполнить $count задачи.', - many: 'Сегодня нужно выполнить $count задач.', - few: 'Сегодня нужно выполнить $count задачи.', - one: 'Сегодня нужно выполнить $count задачу.', + other: 'Сегодня нужно выполнить $countString задачи.', + many: 'Сегодня нужно выполнить $countString задач.', + few: 'Сегодня нужно выполнить $countString задачи.', + one: 'Сегодня нужно выполнить $countString задачу.', ); return '$_temp0'; } @@ -1877,7 +2003,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Неделя $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Неделя $numberString'; } @override @@ -1885,13 +2015,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count записи', - many: '$count записей', - few: '$count записи', - one: '$count запись', + other: '$countString записи', + many: '$countString записей', + few: '$countString записи', + one: '$countString запись', ); return '$_temp0'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 70f3bbf2..99cbd0ac 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': 'Thứ Hai', + 'TU': 'Thứ Ba', + 'WE': 'Thứ Tư', + 'TH': 'Thứ Năm', + 'FR': 'Thứ Sáu', + 'SA': 'Thứ Bảy', + 'SU': 'Chủ Nhật', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return 'vào các ngày $first và $second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency vào các ngày $firstDay và $secondDay của $month'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency vào ngày $day của $firstMonth và $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency vào các ngày $firstDay và $secondDay của $firstMonth và $secondMonth'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency vào các ngày $days của $firstMonth và $secondMonth'; + } + @override String get appTitle => 'BusyMax'; @@ -177,7 +238,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String moreItems(int count) { - return '+$count mục khác'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '+$countString mục khác'; } @override @@ -561,10 +626,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: 'Trước $minutes phút', + other: 'Trước $minutesString phút', one: 'Trước 1 phút', ); return '$_temp0'; @@ -575,10 +644,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: 'Trước $hours giờ', + other: 'Trước $hoursString giờ', one: 'Trước 1 giờ', ); return '$_temp0'; @@ -586,10 +659,15 @@ class AppLocalizationsVi extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: 'Trước $days ngày', + other: 'Trước $daysString ngày', one: 'Trước 1 ngày', ); return '$_temp0'; @@ -1104,7 +1182,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String completionPercent(int percent) { - return 'Đã hoàn thành $percent%'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return 'Đã hoàn thành $percentString%'; } @override @@ -1118,17 +1200,29 @@ class AppLocalizationsVi extends AppLocalizations { @override String priorityHighValue(int priority) { - return 'Ưu tiên $priority · Cao'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Ưu tiên $priorityString · Cao'; } @override String priorityMediumValue(int priority) { - return 'Ưu tiên $priority · Trung bình'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Ưu tiên $priorityString · Trung bình'; } @override String priorityLowValue(int priority) { - return 'Ưu tiên $priority · Thấp'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return 'Ưu tiên $priorityString · Thấp'; } @override @@ -1258,7 +1352,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return 'Ngày này có $count lời nhắc liên quan. Giữ chúng ở ngày và giờ hiện tại?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Ngày này có $countString lời nhắc liên quan. Giữ chúng ở ngày và giờ hiện tại?'; } @override @@ -1383,22 +1481,38 @@ class AppLocalizationsVi extends AppLocalizations { @override String repeatEveryDays(int count) { - return 'Mỗi $count ngày'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString ngày'; } @override String repeatEveryWeeks(int count) { - return 'Mỗi $count tuần'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString tuần'; } @override String repeatEveryMonths(int count) { - return 'Mỗi $count tháng'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString tháng'; } @override String repeatEveryYears(int count) { - return 'Mỗi $count năm'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'Mỗi $countString năm'; } @override @@ -1433,10 +1547,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count lần', + other: '$countString lần', ); return '$_temp0'; } @@ -1747,7 +1865,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String pendingOpAttempts(int count) { - return 'số_lần_thử=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return 'số_lần_thử=$countString'; } @override @@ -1800,10 +1922,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: 'Có $count công việc đến hạn hôm nay.', + other: 'Có $countString công việc đến hạn hôm nay.', one: 'Có một công việc đến hạn hôm nay.', ); return '$_temp0'; @@ -1854,7 +1980,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String weekNumberTooltip(int number) { - return 'Tuần $number'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return 'Tuần $numberString'; } @override @@ -1862,10 +1992,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count mục', + other: '$countString mục', one: '1 mục', ); return '$_temp0'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 10577861..6767327f 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -12,6 +12,67 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$month的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonth和$secondMonth的$day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonth和$secondMonth的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonth和$secondMonth的$days'; + } + @override String get appTitle => 'BusyMax'; @@ -36,11 +97,11 @@ class AppLocalizationsZh extends AppLocalizations { String get onboardingSetupTitle => '设置 BusyMax'; @override - String get onboardingAccountsStepTitle => '连接帐户'; + String get onboardingAccountsStepTitle => '连接账户'; @override String get onboardingAccountsStepDescription => - '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。'; + '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。'; @override String get onboardingPreferencesStepTitle => '选择系统设置'; @@ -175,7 +236,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String moreItems(int count) { - return '还有 $count 项'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '还有 $countString 项'; } @override @@ -194,7 +259,7 @@ class AppLocalizationsZh extends AppLocalizations { String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; @override - String get scheduleSignInRequired => '连接帐户'; + String get scheduleSignInRequired => '连接账户'; @override String get scheduleSignInDescription => '登录以同步日历和任务。'; @@ -215,7 +280,7 @@ class AppLocalizationsZh extends AppLocalizations { String get trayShowBusyMax => '显示 BusyMax'; @override - String get trayNewEvent => '新活动…'; + String get trayNewEvent => '新日程…'; @override String get trayNewTask => '新任务…'; @@ -230,10 +295,10 @@ class AppLocalizationsZh extends AppLocalizations { String get trayNow => '现在'; @override - String get trayCalendarEvent => '日历活动'; + String get trayCalendarEvent => '日历日程'; @override - String get trayUntitledEvent => '无标题活动'; + String get trayUntitledEvent => '无标题日程'; @override String get trayNothingElseToday => '今天没有其他内容'; @@ -428,7 +493,7 @@ class AppLocalizationsZh extends AppLocalizations { String get notifyGuestsTitle => '通知参与者?'; @override - String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或活动更新吗?'; + String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或日程更新吗?'; @override String get notifyGuestsDeleteMessage => '此会议有参与者。删除时发送取消通知吗?'; @@ -446,7 +511,7 @@ class AppLocalizationsZh extends AppLocalizations { String get microsoftNotifyGuestsSaveTitle => '保存会议?'; @override - String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或活动更新。'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或日程更新。'; @override String get microsoftNotifyGuestsDeleteTitle => '删除会议?'; @@ -548,10 +613,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes 分钟前', + other: '$minutesString 分钟前', one: '1 分钟前', ); return '$_temp0'; @@ -562,10 +631,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours 小时前', + other: '$hoursString 小时前', one: '1 小时前', ); return '$_temp0'; @@ -573,10 +646,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days 天前', + other: '$daysString 天前', one: '1 天前', ); return '$_temp0'; @@ -757,7 +835,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; @override String get feedbackCategoryRequired => '请选择类别。'; @@ -801,19 +879,19 @@ class AppLocalizationsZh extends AppLocalizations { String get hideSidebar => '隐藏侧边栏面板'; @override - String get accounts => '帐户'; + String get accounts => '账户'; @override - String get currentAccount => '当前帐户'; + String get currentAccount => '当前账户'; @override - String get switchAccount => '切换帐户'; + String get switchAccount => '切换账户'; @override - String get addGoogleAccount => '添加 Google 帐户'; + String get addGoogleAccount => '添加 Google 账户'; @override - String get addMicrosoftAccount => '添加 Microsoft 帐户'; + String get addMicrosoftAccount => '添加 Microsoft 账户'; @override String get googleProvider => 'Google'; @@ -825,13 +903,13 @@ class AppLocalizationsZh extends AppLocalizations { String get signedInAccount => '已登录'; @override - String get removeAccount => '移除帐户…'; + String get removeAccount => '移除账户…'; @override - String get removingAccount => '正在移除帐户…'; + String get removingAccount => '正在移除账户…'; @override - String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + String get removeAccountDescription => '停止同步并从此设备移除此账户的数据。'; @override String removeAccountTitle(String account) { @@ -840,23 +918,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get removeAccountConfirmation => - '这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。'; + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。'; @override - String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 账户的访问权限'; @override String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; @override - String get removeAccountAction => '移除帐户'; + String get removeAccountAction => '移除账户'; @override - String get removeAccountFailed => '无法完成帐户移除。请重试。'; + String get removeAccountFailed => '无法完成账户移除。请重试。'; @override String get accountRemovedGoogleRevokeFailed => - '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; + '该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。'; @override String get newTaskList => '新任务列表'; @@ -953,7 +1031,7 @@ class AppLocalizationsZh extends AppLocalizations { String get listRefreshed => '列表已刷新。'; @override - String get allTasksRefreshed => '所有帐户均已刷新。'; + String get allTasksRefreshed => '所有账户均已刷新。'; @override String exportedFile(String path) { @@ -983,7 +1061,7 @@ class AppLocalizationsZh extends AppLocalizations { String get noTasksYet => '还没有任务'; @override - String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + String get noTasksYetMessage => '创建任务或刷新账户以开始使用。'; @override String get noTasksInList => '此列表中没有任务。'; @@ -1069,7 +1147,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String completionPercent(int percent) { - return '已完成 $percent%'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '已完成 $percentString%'; } @override @@ -1083,17 +1165,29 @@ class AppLocalizationsZh extends AppLocalizations { @override String priorityHighValue(int priority) { - return '优先级 $priority · 高'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 高'; } @override String priorityMediumValue(int priority) { - return '优先级 $priority · 中'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 中'; } @override String priorityLowValue(int priority) { - return '优先级 $priority · 低'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 低'; } @override @@ -1221,7 +1315,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String relatedRemindersDescription(int count) { - return '此日期有 $count 个相关提醒。要保留它们当前的日期和时间吗?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '此日期有 $countString 个相关提醒。要保留它们当前的日期和时间吗?'; } @override @@ -1346,22 +1444,38 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatEveryDays(int count) { - return '每 $count 天'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 天'; } @override String repeatEveryWeeks(int count) { - return '每 $count 周'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 周'; } @override String repeatEveryMonths(int count) { - return '每 $count 个月'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 个月'; } @override String repeatEveryYears(int count) { - return '每 $count 年'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 年'; } @override @@ -1396,10 +1510,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '重复 $count 次', + other: '重复 $countString 次', ); return '$_temp0'; } @@ -1460,7 +1578,7 @@ class AppLocalizationsZh extends AppLocalizations { String get list => '列表'; @override - String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 账户的列表之间移动任务。'; @override String get createSubtask => '创建子任务'; @@ -1533,7 +1651,7 @@ class AppLocalizationsZh extends AppLocalizations { String get synced => '已同步'; @override - String get account => '帐户'; + String get account => '账户'; @override String get sync => '同步'; @@ -1702,7 +1820,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String pendingOpAttempts(int count) { - return '尝试次数=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '尝试次数=$countString'; } @override @@ -1754,10 +1876,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '今天有 $count 项任务到期。', + other: '今天有 $countString 项任务到期。', one: '今天有 1 项任务到期。', ); return '$_temp0'; @@ -1807,7 +1933,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String weekNumberTooltip(int number) { - return '第 $number 周'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第 $numberString 周'; } @override @@ -1815,10 +1945,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count 项', + other: '$countString 项', one: '1 项', ); return '$_temp0'; @@ -1925,7 +2059,7 @@ class AppLocalizationsZh extends AppLocalizations { String get collectionSettings => '日历和任务列表'; @override - String get calendarContent => '日历活动'; + String get calendarContent => '日历日程'; @override String get taskContent => '任务'; @@ -1983,13 +2117,13 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get collectionSupportsEvents => '活动日历'; + String get collectionSupportsEvents => '日程日历'; @override String get collectionSupportsTasks => '任务列表'; @override - String get collectionSupportsEventsAndTasks => '活动和任务'; + String get collectionSupportsEventsAndTasks => '日程和任务'; @override String get writableCollection => '可写'; @@ -2024,7 +2158,7 @@ class AppLocalizationsZh extends AppLocalizations { String get conflictResolutionFailed => '无法解决冲突。'; @override - String get recurringEventScope => '重复活动范围'; + String get recurringEventScope => '重复日程范围'; @override String get entireSeries => '整个系列'; @@ -2039,24 +2173,24 @@ class AppLocalizationsZh extends AppLocalizations { String get thisAndFutureUnavailable => '此提供商不支持。'; @override - String get thisAndFutureMoveUnavailable => '无法安全移动此活动及后续活动。请选择此活动或整个系列。'; + String get thisAndFutureMoveUnavailable => '无法安全移动此日程及后续日程。请选择此日程或整个系列。'; @override - String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此活动。'; + String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此日程。'; @override - String get copyEventAndDeleteOriginal => '复制活动并删除原活动?'; + String get copyEventAndDeleteOriginal => '复制日程并删除原日程?'; @override String copyEventMoveWarning(String source, String destination) { - return 'BusyMax 无法将此活动直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; + return 'BusyMax 无法将此日程直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; } @override String get copyAndDelete => '复制并删除'; @override - String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。'; + String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。'; @override String get taskDueBeforeStart => '截止时间不能早于开始时间。'; @@ -2083,7 +2217,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String removeCalendarConfirmation(String title) { - return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其活动不会被删除。'; + return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其日程不会被删除。'; } @override @@ -2183,7 +2317,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的活动。已发布的日历不会更改。'; + String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的日程。已发布的日历不会更改。'; @override String get addSubscriptionAction => '添加订阅'; @@ -2200,22 +2334,22 @@ class AppLocalizationsZh extends AppLocalizations { String get calendarImport => '日历导入'; @override - String get calendarImportDescription => '选择文件,查看其中的活动,然后选择接收这些活动的可写日历。'; + String get calendarImportDescription => '选择文件,查看其中的日程,然后选择接收这些日程的可写日历。'; @override String get importIcsFile => '导入 .ics 文件'; @override - String get importIcsPreview => '导入日历活动'; + String get importIcsPreview => '导入日历日程'; @override String importEventsFound(int count) { - return '可导入活动集:$count'; + return '可导入日程集:$count'; } @override String importInvalidEvents(int count) { - return '无效活动:$count'; + return '无效日程:$count'; } @override @@ -2230,7 +2364,7 @@ class AppLocalizationsZh extends AppLocalizations { String get importDestinationCalendar => '目标日历'; @override - String get importIcsConfirm => '导入活动'; + String get importIcsConfirm => '导入日程'; @override String get importIcsComplete => '导入完成'; @@ -2404,6 +2538,67 @@ class AppLocalizationsZh extends AppLocalizations { class AppLocalizationsZhHans extends AppLocalizationsZh { AppLocalizationsZhHans() : super('zh_Hans'); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$month的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonth和$secondMonth的$day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonth和$secondMonth的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonth和$secondMonth的$days'; + } + @override String get appTitle => 'BusyMax'; @@ -2428,11 +2623,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get onboardingSetupTitle => '设置 BusyMax'; @override - String get onboardingAccountsStepTitle => '连接帐户'; + String get onboardingAccountsStepTitle => '连接账户'; @override String get onboardingAccountsStepDescription => - '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、活动、任务列表和任务。'; + '添加所有要使用的账户。BusyMax 会同步每个账户中受支持的日历、日程、任务列表和任务。'; @override String get onboardingPreferencesStepTitle => '选择系统设置'; @@ -2567,7 +2762,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String moreItems(int count) { - return '还有 $count 项'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '还有 $countString 项'; } @override @@ -2586,7 +2785,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get scheduleNoSourcesDescription => '请在设置中选择要显示的内容,然后刷新。'; @override - String get scheduleSignInRequired => '连接帐户'; + String get scheduleSignInRequired => '连接账户'; @override String get scheduleSignInDescription => '登录以同步日历和任务。'; @@ -2607,7 +2806,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get trayShowBusyMax => '显示 BusyMax'; @override - String get trayNewEvent => '新活动…'; + String get trayNewEvent => '新日程…'; @override String get trayNewTask => '新任务…'; @@ -2622,10 +2821,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get trayNow => '现在'; @override - String get trayCalendarEvent => '日历活动'; + String get trayCalendarEvent => '日历日程'; @override - String get trayUntitledEvent => '无标题活动'; + String get trayUntitledEvent => '无标题日程'; @override String get trayNothingElseToday => '今天没有其他内容'; @@ -2820,7 +3019,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get notifyGuestsTitle => '通知参与者?'; @override - String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或活动更新吗?'; + String get notifyGuestsSaveMessage => '此会议有参与者。保存时发送邀请或日程更新吗?'; @override String get notifyGuestsDeleteMessage => '此会议有参与者。删除时发送取消通知吗?'; @@ -2838,7 +3037,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get microsoftNotifyGuestsSaveTitle => '保存会议?'; @override - String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或活动更新。'; + String get microsoftNotifyGuestsSaveMessage => 'Microsoft 将向参与者发送邀请或日程更新。'; @override String get microsoftNotifyGuestsDeleteTitle => '删除会议?'; @@ -2940,10 +3139,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes 分钟前', + other: '$minutesString 分钟前', one: '1 分钟前', ); return '$_temp0'; @@ -2954,10 +3157,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours 小时前', + other: '$hoursString 小时前', one: '1 小时前', ); return '$_temp0'; @@ -2965,10 +3172,15 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days 天前', + other: '$daysString 天前', one: '1 天前', ); return '$_temp0'; @@ -3149,7 +3361,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get feedbackTechnicalDetailsDisclosure => - '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、帐户数据、文件名或其他诊断信息。'; + '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; @override String get feedbackCategoryRequired => '请选择类别。'; @@ -3193,19 +3405,19 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get hideSidebar => '隐藏侧边栏面板'; @override - String get accounts => '帐户'; + String get accounts => '账户'; @override - String get currentAccount => '当前帐户'; + String get currentAccount => '当前账户'; @override - String get switchAccount => '切换帐户'; + String get switchAccount => '切换账户'; @override - String get addGoogleAccount => '添加 Google 帐户'; + String get addGoogleAccount => '添加 Google 账户'; @override - String get addMicrosoftAccount => '添加 Microsoft 帐户'; + String get addMicrosoftAccount => '添加 Microsoft 账户'; @override String get googleProvider => 'Google'; @@ -3217,13 +3429,13 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get signedInAccount => '已登录'; @override - String get removeAccount => '移除帐户…'; + String get removeAccount => '移除账户…'; @override - String get removingAccount => '正在移除帐户…'; + String get removingAccount => '正在移除账户…'; @override - String get removeAccountDescription => '停止同步并从此设备移除此帐户的数据。'; + String get removeAccountDescription => '停止同步并从此设备移除此账户的数据。'; @override String removeAccountTitle(String account) { @@ -3232,23 +3444,23 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get removeAccountConfirmation => - '这会从此设备删除缓存的任务、日历、活动、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、活动、任务列表和任务副本不会被删除。'; + '这会从此设备删除缓存的任务、日历、日程、提醒和待处理的离线更改。未同步的更改将会丢失。提供商中的日历、日程、任务列表和任务副本不会被删除。'; @override - String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 帐户的访问权限'; + String get revokeGoogleAccess => '同时撤销 BusyMax 对此 Google 账户的访问权限'; @override String get revokeGoogleAccessDescription => '重新连接之前,您需要再次授予访问权限。'; @override - String get removeAccountAction => '移除帐户'; + String get removeAccountAction => '移除账户'; @override - String get removeAccountFailed => '无法完成帐户移除。请重试。'; + String get removeAccountFailed => '无法完成账户移除。请重试。'; @override String get accountRemovedGoogleRevokeFailed => - '该帐户已从此设备移除,但无法撤销 BusyMax 对您的 Google 帐户的访问权限。您可以在 Google 帐户中手动撤销该权限。'; + '该账户已从此设备移除,但无法撤销 BusyMax 对您的 Google 账户的访问权限。您可以在 Google 账户中手动撤销该权限。'; @override String get newTaskList => '新任务列表'; @@ -3345,7 +3557,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get listRefreshed => '列表已刷新。'; @override - String get allTasksRefreshed => '所有帐户均已刷新。'; + String get allTasksRefreshed => '所有账户均已刷新。'; @override String exportedFile(String path) { @@ -3375,7 +3587,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get noTasksYet => '还没有任务'; @override - String get noTasksYetMessage => '创建任务或刷新帐户以开始使用。'; + String get noTasksYetMessage => '创建任务或刷新账户以开始使用。'; @override String get noTasksInList => '此列表中没有任务。'; @@ -3461,7 +3673,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String completionPercent(int percent) { - return '已完成 $percent%'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '已完成 $percentString%'; } @override @@ -3475,17 +3691,29 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String priorityHighValue(int priority) { - return '优先级 $priority · 高'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 高'; } @override String priorityMediumValue(int priority) { - return '优先级 $priority · 中'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 中'; } @override String priorityLowValue(int priority) { - return '优先级 $priority · 低'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '优先级 $priorityString · 低'; } @override @@ -3613,7 +3841,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String relatedRemindersDescription(int count) { - return '此日期有 $count 个相关提醒。要保留它们当前的日期和时间吗?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '此日期有 $countString 个相关提醒。要保留它们当前的日期和时间吗?'; } @override @@ -3738,22 +3970,38 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String repeatEveryDays(int count) { - return '每 $count 天'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 天'; } @override String repeatEveryWeeks(int count) { - return '每 $count 周'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 周'; } @override String repeatEveryMonths(int count) { - return '每 $count 个月'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 个月'; } @override String repeatEveryYears(int count) { - return '每 $count 年'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 年'; } @override @@ -3788,10 +4036,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '重复 $count 次', + other: '重复 $countString 次', ); return '$_temp0'; } @@ -3852,7 +4104,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get list => '列表'; @override - String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 帐户的列表之间移动任务。'; + String get microsoftMoveUnsupported => '此版本不支持在 Microsoft To Do 账户的列表之间移动任务。'; @override String get createSubtask => '创建子任务'; @@ -3925,7 +4177,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get synced => '已同步'; @override - String get account => '帐户'; + String get account => '账户'; @override String get sync => '同步'; @@ -4094,7 +4346,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String pendingOpAttempts(int count) { - return '尝试次数=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '尝试次数=$countString'; } @override @@ -4146,10 +4402,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '今天有 $count 项任务到期。', + other: '今天有 $countString 项任务到期。', one: '今天有 1 项任务到期。', ); return '$_temp0'; @@ -4199,7 +4459,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String weekNumberTooltip(int number) { - return '第 $number 周'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第 $numberString 周'; } @override @@ -4207,10 +4471,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count 项', + other: '$countString 项', one: '1 项', ); return '$_temp0'; @@ -4317,7 +4585,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get collectionSettings => '日历和任务列表'; @override - String get calendarContent => '日历活动'; + String get calendarContent => '日历日程'; @override String get taskContent => '任务'; @@ -4375,13 +4643,13 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { } @override - String get collectionSupportsEvents => '活动日历'; + String get collectionSupportsEvents => '日程日历'; @override String get collectionSupportsTasks => '任务列表'; @override - String get collectionSupportsEventsAndTasks => '活动和任务'; + String get collectionSupportsEventsAndTasks => '日程和任务'; @override String get writableCollection => '可写'; @@ -4416,7 +4684,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get conflictResolutionFailed => '无法解决冲突。'; @override - String get recurringEventScope => '重复活动范围'; + String get recurringEventScope => '重复日程范围'; @override String get entireSeries => '整个系列'; @@ -4431,24 +4699,24 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get thisAndFutureUnavailable => '此提供商不支持。'; @override - String get thisAndFutureMoveUnavailable => '无法安全移动此活动及后续活动。请选择此活动或整个系列。'; + String get thisAndFutureMoveUnavailable => '无法安全移动此日程及后续日程。请选择此日程或整个系列。'; @override - String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此活动。'; + String get entireSeriesMoveUnavailable => '本地没有可用的重复规则。请仅移动此日程。'; @override - String get copyEventAndDeleteOriginal => '复制活动并删除原活动?'; + String get copyEventAndDeleteOriginal => '复制日程并删除原日程?'; @override String copyEventMoveWarning(String source, String destination) { - return 'BusyMax 无法将此活动直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原活动。活动 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; + return 'BusyMax 无法将此日程直接从 $source 移动到 $destination。应用会先创建副本,并且仅在复制成功后删除原日程。日程 ID 将发生变化;参与者的回复状态可能会重置,并可能发送邀请或取消通知;会议链接、附件、提醒、提供商特有的字段和重复例外可能无法保留。'; } @override String get copyAndDelete => '复制并删除'; @override - String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此活动,还是此活动及后续活动。'; + String get chooseRecurringEventScope => '选择此更改适用于整个系列、仅此日程,还是此日程及后续日程。'; @override String get taskDueBeforeStart => '截止时间不能早于开始时间。'; @@ -4475,7 +4743,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String removeCalendarConfirmation(String title) { - return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其活动不会被删除。'; + return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其日程不会被删除。'; } @override @@ -4575,7 +4843,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { } @override - String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的活动。已发布的日历不会更改。'; + String get unsubscribeCalendarConfirmation => '这会移除本地订阅及其缓存的日程。已发布的日历不会更改。'; @override String get addSubscriptionAction => '添加订阅'; @@ -4592,22 +4860,22 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get calendarImport => '日历导入'; @override - String get calendarImportDescription => '选择文件,查看其中的活动,然后选择接收这些活动的可写日历。'; + String get calendarImportDescription => '选择文件,查看其中的日程,然后选择接收这些日程的可写日历。'; @override String get importIcsFile => '导入 .ics 文件'; @override - String get importIcsPreview => '导入日历活动'; + String get importIcsPreview => '导入日历日程'; @override String importEventsFound(int count) { - return '可导入活动集:$count'; + return '可导入日程集:$count'; } @override String importInvalidEvents(int count) { - return '无效活动:$count'; + return '无效日程:$count'; } @override @@ -4622,7 +4890,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get importDestinationCalendar => '目标日历'; @override - String get importIcsConfirm => '导入活动'; + String get importIcsConfirm => '导入日程'; @override String get importIcsComplete => '导入完成'; @@ -4796,6 +5064,67 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { class AppLocalizationsZhHant extends AppLocalizationsZh { AppLocalizationsZhHant() : super('zh_Hant'); + @override + String repeatWeeklyDaySummary(String dayKey, String day) { + String _temp0 = intl.Intl.selectLogic(dayKey, { + 'MO': '星期一', + 'TU': '星期二', + 'WE': '星期三', + 'TH': '星期四', + 'FR': '星期五', + 'SA': '星期六', + 'SU': '星期日', + 'other': '$day', + }); + return '$_temp0'; + } + + @override + String repeatOnTwoMonthDaysSummary(String first, String second) { + return '$first、$second'; + } + + @override + String repeatYearlyOnTwoMonthDaysSummary( + String frequency, + String month, + String firstDay, + String secondDay, + ) { + return '$frequency$month的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaySummary( + String frequency, + String firstMonth, + String secondMonth, + String day, + ) { + return '$frequency$firstMonth和$secondMonth的$day'; + } + + @override + String repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String firstDay, + String secondDay, + ) { + return '$frequency$firstMonth和$secondMonth的$firstDay和$secondDay'; + } + + @override + String repeatYearlyInTwoMonthsOnMonthDaysSummary( + String frequency, + String firstMonth, + String secondMonth, + String days, + ) { + return '$frequency$firstMonth和$secondMonth的$days'; + } + @override String get appTitle => 'BusyMax'; @@ -4959,7 +5288,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String moreItems(int count) { - return '還有 $count 項'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '還有 $countString 項'; } @override @@ -5332,10 +5665,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String reminderMinutesBefore(int minutes) { + final intl.NumberFormat minutesNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String minutesString = minutesNumberFormat.format(minutes); + String _temp0 = intl.Intl.pluralLogic( minutes, locale: localeName, - other: '$minutes 分鐘前', + other: '$minutesString 分鐘前', one: '1 分鐘前', ); return '$_temp0'; @@ -5346,10 +5683,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String reminderHoursBefore(int hours) { + final intl.NumberFormat hoursNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String hoursString = hoursNumberFormat.format(hours); + String _temp0 = intl.Intl.pluralLogic( hours, locale: localeName, - other: '$hours 小時前', + other: '$hoursString 小時前', one: '1 小時前', ); return '$_temp0'; @@ -5357,10 +5698,15 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String reminderDaysBefore(int days) { + final intl.NumberFormat daysNumberFormat = intl.NumberFormat.decimalPattern( + localeName, + ); + final String daysString = daysNumberFormat.format(days); + String _temp0 = intl.Intl.pluralLogic( days, locale: localeName, - other: '$days 天前', + other: '$daysString 天前', one: '1 天前', ); return '$_temp0'; @@ -5853,7 +6199,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String completionPercent(int percent) { - return '已完成 $percent%'; + final intl.NumberFormat percentNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String percentString = percentNumberFormat.format(percent); + + return '已完成 $percentString%'; } @override @@ -5867,17 +6217,29 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String priorityHighValue(int priority) { - return '優先級 $priority · 高'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先級 $priorityString · 高'; } @override String priorityMediumValue(int priority) { - return '優先級 $priority · 中'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先級 $priorityString · 中'; } @override String priorityLowValue(int priority) { - return '優先級 $priority · 低'; + final intl.NumberFormat priorityNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String priorityString = priorityNumberFormat.format(priority); + + return '優先級 $priorityString · 低'; } @override @@ -6005,7 +6367,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String relatedRemindersDescription(int count) { - return '此日期有 $count 個相關提醒。要保留它們當前的日期和時間嗎?'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '此日期有 $countString 個相關提醒。要保留它們當前的日期和時間嗎?'; } @override @@ -6130,22 +6496,38 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String repeatEveryDays(int count) { - return '每 $count 天'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 天'; } @override String repeatEveryWeeks(int count) { - return '每 $count 週'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 週'; } @override String repeatEveryMonths(int count) { - return '每 $count 個月'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 個月'; } @override String repeatEveryYears(int count) { - return '每 $count 年'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '每 $countString 年'; } @override @@ -6180,10 +6562,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String repeatTimesSummary(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '重複 $count 次', + other: '重複 $countString 次', ); return '$_temp0'; } @@ -6487,7 +6873,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String pendingOpAttempts(int count) { - return '嘗試次數=$count'; + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + + return '嘗試次數=$countString'; } @override @@ -6539,10 +6929,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String dueTodayNotificationBody(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '今天有 $count 項待辦事項到期。', + other: '今天有 $countString 項待辦事項到期。', one: '今天有 1 項待辦事項到期。', ); return '$_temp0'; @@ -6592,7 +6986,11 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String weekNumberTooltip(int number) { - return '第 $number 週'; + final intl.NumberFormat numberNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String numberString = numberNumberFormat.format(number); + + return '第 $numberString 週'; } @override @@ -6600,10 +6998,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String scheduleItemCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count 項', + other: '$countString 項', one: '1 項', ); return '$_temp0'; diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 6e766c2e..f58300a2 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -218,7 +218,7 @@ class _BusyMaxAppState extends ConsumerState { ...GlobalUbuntuLocalizations.delegates, ], localeListResolutionCallback: resolveBusyMaxLocales, - supportedLocales: AppLocalizations.supportedLocales, + supportedLocales: busyMaxSupportedLocales, builder: (context, child) { final l10n = AppLocalizations.of(context); final material = MaterialLocalizations.of(context); diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index a0c27dfc..7a75c7e0 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -372,7 +372,7 @@ class NotificationStrings { factory NotificationStrings.forLocales(List? locales) { final supportedLocale = resolveBusyMaxLocales( locales, - AppLocalizations.supportedLocales, + busyMaxSupportedLocales, ); return NotificationStrings.fromLocalizations( lookupAppLocalizations(supportedLocale), diff --git a/lib/src/features/recurrence/domain/event_recurrence_codec.dart b/lib/src/features/recurrence/domain/event_recurrence_codec.dart index ac7bdf2a..f5b3387f 100644 --- a/lib/src/features/recurrence/domain/event_recurrence_codec.dart +++ b/lib/src/features/recurrence/domain/event_recurrence_codec.dart @@ -60,6 +60,7 @@ final class RecurrenceRuleLimits { case RecurrenceFrequency.yearly: return rule.byMonth.isNotEmpty && (allowMultipleMonths || rule.byMonth.length == 1) && + (rule.bySetPosition == null || rule.byMonth.length == 1) && _supportsMonthlyShape(rule); } } diff --git a/lib/src/features/recurrence/domain/recurrence_rule.dart b/lib/src/features/recurrence/domain/recurrence_rule.dart index cd66edc1..79f189a4 100644 --- a/lib/src/features/recurrence/domain/recurrence_rule.dart +++ b/lib/src/features/recurrence/domain/recurrence_rule.dart @@ -490,6 +490,13 @@ _NormalizedEditorRule? _normalizeMonthlyOrYearly({ ); } + final hasOrdinalByDay = byDay.any( + (value) => RegExp(r'^-?[1-5](?:MO|TU|WE|TH|FR|SA|SU)$').hasMatch(value), + ); + if (byMonth.length > 1 && (bySetPositions.isNotEmpty || hasOrdinalByDay)) { + return null; + } + if (byDay.length == 1 && bySetPositions.isEmpty) { final ordinal = RegExp( r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$', diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index 1d0d51ac..024cf16f 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -50,6 +50,11 @@ String recurrenceRuleSummary( }) { 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, @@ -77,7 +82,7 @@ String recurrenceRuleSummary( if (recurrence.frequency == RecurrenceFrequency.weekly && recurrence.byDay.isNotEmpty) { final dayValues = recurrence.byDay - .map((day) => _localizedInlineWeekday(context, day)) + .map((day) => _localizedWeeklyDaySummary(context, day)) .toList(growable: false); final days = _localizedRecurrenceList( dayValues, @@ -112,18 +117,43 @@ String recurrenceRuleSummary( summary = switch ((monthValues.length, monthDayValues.length)) { (1, 1) => l10n.repeatYearlyOnMonthDaySummary( summary, - months, - monthDays, + monthValues.single, + monthDayValues.single, + ), + (1, 2) => l10n.repeatYearlyOnTwoMonthDaysSummary( + summary, + monthValues.single, + monthDayValues.first, + monthDayValues.last, ), (1, _) => l10n.repeatYearlyOnMonthDaysSummary( summary, - months, + 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, - monthDays, + monthDayValues.single, ), _ => l10n.repeatYearlyInMonthsOnMonthDaysSummary( summary, @@ -138,36 +168,27 @@ String recurrenceRuleSummary( pair: l10n.repeatMonthDayListPair, start: l10n.repeatMonthDayListStart, ); - final monthDaysSummary = recurrence.byMonthDay.length == 1 - ? l10n.repeatOnMonthDaysSummary(monthDays) - : l10n.repeatOnMonthDaysSummaryMultiple(monthDays); + 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 days = _localizedOrdinalDaySummary(context, recurrence.byDay); if (recurrence.frequency == RecurrenceFrequency.yearly && recurrence.byMonth.isNotEmpty) { - final monthValues = recurrence.byMonth - .map((month) => _localizedYearlyMonth(context, month)) - .toList(growable: false); - final months = _localizedRecurrenceList( - monthValues, - pair: l10n.repeatYearlyMonthListPair, - start: l10n.repeatYearlyMonthListStart, + final month = _localizedYearlyMonth(context, recurrence.byMonth.single); + summary = l10n.repeatYearlyOnOrdinalSummary( + summary, + month, + _ordinalPositionKey(position), + days, ); - summary = monthValues.length == 1 - ? l10n.repeatYearlyOnOrdinalSummary( - summary, - months, - _ordinalPositionKey(position), - days, - ) - : l10n.repeatYearlyInMonthsOnOrdinalSummary( - summary, - months, - _ordinalPositionKey(position), - days, - ); yearlyMonthPlacedInSummary = true; } else { final ordinalSummary = l10n.repeatOnOrdinalSummary( @@ -582,7 +603,7 @@ class _RecurrenceEditorDialogState extends State { void _toggleMonth(int month, bool selected) { var values = [..._value.byMonth]; - if (!widget.limits.allowMultipleMonths) { + if (!widget.limits.allowMultipleMonths || _value.bySetPosition != null) { values = [month]; } else if (selected) { if (!values.contains(month)) values.add(month); @@ -610,6 +631,11 @@ class _RecurrenceEditorDialogState extends State { ? [_weekdayCode(widget.baseDate.weekday)] : _value.byDay, byMonthDay: const [], + byMonth: + _value.frequency == RecurrenceFrequency.yearly && + _value.byMonth.length > 1 + ? [_value.byMonth.first] + : _value.byMonth, ); } }); @@ -847,6 +873,13 @@ String _localizedOrdinalDaySummary(BuildContext context, List days) { return context.l10n.repeatOrdinalDaySummary(dayKey, 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/l10n/app_locale.dart b/lib/src/l10n/app_locale.dart index 75747819..abebef92 100644 --- a/lib/src/l10n/app_locale.dart +++ b/lib/src/l10n/app_locale.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.dart'; +import '../../l10n/generated/app_localizations.dart'; + /// A locale that can be selected explicitly in BusyMax. /// /// Language names are endonyms on purpose: users should be able to find their @@ -26,7 +28,7 @@ const busyMaxLocaleOptions = [ BusyMaxLocaleOption(locale: Locale('it'), endonym: 'Italiano'), BusyMaxLocaleOption(locale: Locale('ja'), endonym: '日本語'), BusyMaxLocaleOption(locale: Locale('ko'), endonym: '한국어'), - BusyMaxLocaleOption(locale: Locale('pt'), endonym: 'Português'), + BusyMaxLocaleOption(locale: Locale('pt', 'PT'), endonym: 'Português'), BusyMaxLocaleOption(locale: Locale('ru'), endonym: 'Русский'), BusyMaxLocaleOption(locale: Locale('vi'), endonym: 'Tiếng Việt'), BusyMaxLocaleOption( @@ -39,6 +41,18 @@ const busyMaxLocaleOptions = [ ), ]; +/// Locales exposed by BusyMax after removing generator-only fallbacks. +/// +/// Flutter's localization generator requires a base `pt` catalog before it +/// will generate `pt_PT`. BusyMax deliberately exposes only European +/// Portuguese so generic Portuguese and Brazilian Portuguese are not silently +/// resolved to the pt-PT catalog. +final busyMaxSupportedLocales = AppLocalizations.supportedLocales + .where( + (locale) => locale.languageCode != 'pt' || locale.countryCode == 'PT', + ) + .toList(growable: false); + const _traditionalChineseRegions = {'HK', 'MO', 'TW'}; const _simplifiedChineseRegions = {'CN', 'MY', 'SG'}; @@ -119,6 +133,9 @@ Locale resolveBusyMaxLocales( ]; final normalizedRequested = requestedLocales ?.map(_normalizeChineseLocale) + .where( + (locale) => locale.languageCode != 'pt' || locale.countryCode == 'PT', + ) .toList(growable: false); return basicLocaleListResolution(normalizedRequested, orderedSupported); } diff --git a/lib/src/l10n/locale_resolution.dart b/lib/src/l10n/locale_resolution.dart index 8daf0c79..710dc55f 100644 --- a/lib/src/l10n/locale_resolution.dart +++ b/lib/src/l10n/locale_resolution.dart @@ -1 +1,2 @@ -export 'app_locale.dart' show resolveBusyMaxLocale, resolveBusyMaxLocales; +export 'app_locale.dart' + show busyMaxSupportedLocales, resolveBusyMaxLocale, resolveBusyMaxLocales; diff --git a/linux/io.busystack.busymax.desktop b/linux/io.busystack.busymax.desktop index d8a0a2c2..86e55ed8 100644 --- a/linux/io.busystack.busymax.desktop +++ b/linux/io.busystack.busymax.desktop @@ -24,8 +24,8 @@ Name[ja]=BusyMax Comment[ja]=カレンダー・タスク管理アプリ Name[ko]=BusyMax Comment[ko]=캘린더와 할 일 관리 -Name[pt]=BusyMax -Comment[pt]=Gestor de calendário e tarefas +Name[pt_PT]=BusyMax +Comment[pt_PT]=Gestor de calendário e tarefas Name[ru]=BusyMax Comment[ru]=Календарь и планировщик задач Name[vi]=BusyMax diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 8d11899a..24f8244f 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -15,7 +15,7 @@ BusyMax BusyMax BusyMax - BusyMax + BusyMax BusyMax BusyMax BusyMax @@ -33,7 +33,7 @@ Gestore di calendari e attività カレンダー・タスク管理アプリ 캘린더와 할 일 관리 - Gestor de calendário e tarefas + Gestor de calendário e tarefas Календарь и планировщик задач Trình quản lý lịch và công việc 日历与任务管理工具 @@ -52,7 +52,7 @@

BusyMax è un gestore di calendari e attività per desktop Linux che riunisce eventi, attività, promemoria e programmi giornalieri in un unico spazio di lavoro.

BusyMax は、予定、タスク、リマインダー、毎日のスケジュールを一つのワークスペースで管理できる Linux デスクトップ向けのカレンダー・タスク管理アプリです。

BusyMax는 일정, 할 일, 미리 알림, 일일 계획을 하나의 작업 공간에서 관리할 수 있는 Linux 데스크톱용 캘린더 및 할 일 관리 앱입니다.

-

BusyMax é um gestor de calendário e tarefas para o ambiente de trabalho Linux que reúne eventos, tarefas, lembretes e agendas diárias num único espaço de trabalho.

+

BusyMax é um gestor de calendário e tarefas para o ambiente de trabalho Linux que reúne eventos, tarefas, lembretes e agendas diárias num único espaço de trabalho.

BusyMax — календарь и планировщик задач для Linux. В одном приложении можно управлять событиями, задачами, напоминаниями и распорядком дня.

BusyMax là trình quản lý lịch và công việc cho máy tính Linux, giúp lập kế hoạch sự kiện, công việc, lời nhắc và lịch trình hằng ngày trong một không gian làm việc.

BusyMax 是一款面向 Linux 桌面的日历与任务管理工具,可在一个工作区中规划日程、任务、提醒和每日安排。

diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index e0b62d99..bac516cd 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -107,6 +107,8 @@ void main() { 'repeatYearlyMonthListStart', 'repeatYearlyMonthValue', 'settingsSystem', + 'sensitivityNormal', + 'sensitivityPersonal', }; final failures = []; @@ -124,7 +126,7 @@ void main() { }); test('discard-changes action is distinct from cancel in every locale', () { - for (final locale in AppLocalizations.supportedLocales) { + for (final locale in busyMaxSupportedLocales) { final localizations = lookupAppLocalizations(locale); expect( localizations.discardChangesAction, @@ -207,7 +209,7 @@ void main() { 'it': 'Gestore di calendari e attività', 'ja': 'カレンダー・タスク管理アプリ', 'ko': '캘린더와 할 일 관리', - 'pt': 'Gestor de calendário e tarefas', + 'pt_PT': 'Gestor de calendário e tarefas', 'ru': 'Календарь и планировщик задач', 'vi': 'Trình quản lý lịch và công việc', 'zh': '日历与任务管理工具', @@ -236,14 +238,29 @@ void main() { ); }); - test('Portuguese is generated and exposed as a supported locale', () { - const locale = Locale('pt'); - final localizations = lookupAppLocalizations(locale); + test( + 'European Portuguese is generated and exposed without a generic alias', + () { + const locale = Locale('pt', 'PT'); + final localizations = lookupAppLocalizations(locale); - expect(AppLocalizations.supportedLocales, contains(locale)); - expect(localizations.settings, 'Definições'); - expect(localizations.today, 'Hoje'); - }); + expect(AppLocalizations.supportedLocales, contains(locale)); + expect(busyMaxSupportedLocales, contains(locale)); + expect(busyMaxSupportedLocales, isNot(contains(const Locale('pt')))); + expect(busyMaxLocaleFromTag('pt-PT'), locale); + expect(busyMaxLocaleFromTag('pt'), isNull); + expect(busyMaxLocaleFromTag('pt-BR'), isNull); + expect(_messages(_decodeArb(File('lib/l10n/app_pt.arb'))), isEmpty); + expect( + resolveBusyMaxLocales(const [ + Locale('pt', 'BR'), + ], busyMaxSupportedLocales), + const Locale('en'), + ); + expect(localizations.settings, 'Definições'); + expect(localizations.today, 'Hoje'); + }, + ); test( 'reviewed Arabic conflict wording and Portuguese variant stay stable', @@ -254,13 +271,13 @@ void main() { contains('تم التغيير على الخادم في:'), ); - final portuguese = lookupAppLocalizations(const Locale('pt')); + final portuguese = lookupAppLocalizations(const Locale('pt', 'PT')); expect(portuguese.settings, 'Definições'); expect(portuguese.deleteTask, 'Eliminar tarefa'); expect(portuguese.copyAndDelete, 'Copiar e eliminar'); final portugueseArb = File( - 'lib/l10n/app_pt.arb', + 'lib/l10n/app_pt_PT.arb', ).readAsStringSync().toLowerCase(); for (final brazilianForm in [ 'excluído', @@ -278,6 +295,48 @@ void main() { }, ); + test('reviewed terminology and punctuation stay consistent', () { + final de = lookupAppLocalizations(const Locale('de')); + final es = lookupAppLocalizations(const Locale('es')); + final pt = lookupAppLocalizations(const Locale('pt', 'PT')); + expect(de.sensitivityNormal, 'Normal'); + expect(es.sensitivityNormal, 'Normal'); + expect(es.sensitivityPersonal, 'Personal'); + expect(pt.sensitivityNormal, 'Normal'); + + final estonian = File('lib/l10n/app_et.arb').readAsStringSync(); + expect(estonian, isNot(contains("Google'"))); + expect(estonian, contains('Lisage kõik kontod, mida soovite kasutada.')); + expect(estonian, contains('Ühendage Google’i')); + + expect( + File('lib/l10n/app_hi.arb').readAsStringSync(), + isNot(contains('ईवेंट')), + ); + for (final path in ['lib/l10n/app_zh.arb', 'lib/l10n/app_zh_Hans.arb']) { + final catalog = File(path).readAsStringSync(); + expect(catalog, isNot(contains('帐户')), reason: path); + expect(catalog, isNot(contains('活动')), reason: path); + } + expect( + File('lib/l10n/app_ar.arb').readAsStringSync(), + isNot(contains('ضيوف')), + ); + expect( + File('lib/l10n/app_fa.arb').readAsStringSync(), + isNot(contains('مهمانی')), + ); + expect( + File('lib/l10n/app_ko.arb').readAsStringSync(), + isNot(contains('게스트')), + ); + + final french = File('lib/l10n/app_fr.arb').readAsStringSync(); + expect(RegExp(r' [;:?!]').hasMatch(french), isFalse); + expect(french.contains('« '), isFalse); + expect(french.contains(' »'), isFalse); + }); + test('Hindi is generated and exposed as a supported locale', () { const locale = Locale('hi'); final localizations = lookupAppLocalizations(locale); @@ -433,6 +492,42 @@ void main() { }, ); + test('RTL catalogs isolate non-recurrence String placeholders', () { + const fsi = '\u2068'; + const pdi = '\u2069'; + final template = _decodeArb(File('lib/l10n/app_en.arb')); + + for (final locale in ['ar', 'fa']) { + final messages = _decodeArb(File('lib/l10n/app_$locale.arb')); + final failures = []; + for (final entry in template.entries) { + if (!entry.key.startsWith('@') || entry.key.startsWith('@@')) { + continue; + } + final messageKey = entry.key.substring(1); + if (messageKey.startsWith('repeat') || entry.value is! Map) { + continue; + } + final metadata = Map.from(entry.value as Map); + final placeholders = metadata['placeholders']; + if (placeholders is! Map) continue; + final translation = messages[messageKey] as String? ?? ''; + for (final placeholderEntry in placeholders.entries) { + final placeholderMetadata = placeholderEntry.value; + if (placeholderMetadata is! Map || + placeholderMetadata['type'] != 'String') { + continue; + } + final placeholder = placeholderEntry.key.toString(); + if (!translation.contains('$fsi{$placeholder}$pdi')) { + failures.add('$locale:$messageKey:$placeholder'); + } + } + } + expect(failures, isEmpty, reason: failures.join('\n')); + } + }); + testWidgets('recurrence summaries use sentence-form localized grammar', ( tester, ) async { @@ -612,19 +707,17 @@ void main() { const expectedYearlySummaries = >{ 'ar': [ 'سنويًا في يوم 15 من سبتمبر', - 'سنويًا في الأيام 1 و15 من سبتمبر', - 'سنويًا في يوم 15 من أشهر سبتمبر وأكتوبر', - 'سنويًا في الأيام 1 و15 من أشهر سبتمبر وأكتوبر', + 'سنويًا في يومي 1 و15 من سبتمبر', + 'سنويًا في اليوم 15 من شهري سبتمبر وأكتوبر', + 'سنويًا في يومي 1 و15 من شهري سبتمبر وأكتوبر', 'سنويًا في أول اثنين من سبتمبر', - 'سنويًا في أول اثنين من أشهر سبتمبر وأكتوبر', ], 'de': [ 'Jährlich am 15. Sept.', 'Jährlich an den Tagen 1 und 15 im Sept.', - 'Jährlich jeweils am 15. in Sept. und Okt.', - 'Jährlich jeweils an den Tagen 1 und 15 in Sept. und Okt.', + 'Jährlich jeweils am 15. im Sept. und Okt.', + 'Jährlich jeweils an den Tagen 1 und 15 im Sept. und Okt.', 'Jährlich am ersten Montag im Sept.', - 'Jährlich jeweils am ersten Montag in Sept. und Okt.', ], 'en': [ 'Yearly on Sep 15', @@ -632,7 +725,6 @@ void main() { 'Yearly on day 15 of Sep and Oct', 'Yearly on days 1 and 15 of Sep and Oct', 'Yearly on the first Monday of Sep', - 'Yearly on the first Monday of Sep and Oct', ], 'es': [ 'Anual el día 15 de sept', @@ -640,7 +732,6 @@ void main() { 'Anual el día 15 de sept y oct', 'Anual los días 1 y 15 de sept y oct', 'Anual el primer lunes de sept', - 'Anual el primer lunes de sept y oct', ], 'et': [ 'Iga aasta septembris 15. päeval', @@ -648,7 +739,6 @@ void main() { 'Iga aasta septembris ja oktoobris 15. päeval', 'Iga aasta septembris ja oktoobris 1. ja 15. päeval', 'Iga aasta septembris: esimene esmaspäev', - 'Iga aasta septembris ja oktoobris: esimene esmaspäev', ], 'fa': [ 'سالانه در روز ۱۵ ماه سپتامبر', @@ -656,15 +746,13 @@ void main() { 'سالانه در روز ۱۵ ماه‌های سپتامبر و اکتبر', 'سالانه در روزهای ۱ و ۱۵ ماه‌های سپتامبر و اکتبر', 'سالانه در اولین دوشنبه ماه سپتامبر', - 'سالانه در اولین دوشنبه ماه‌های سپتامبر و اکتبر', ], 'fi': [ 'Vuosittain syyskuun 15. päivänä', - 'Vuosittain syyskuun päivinä 1 ja 15', + 'Vuosittain syyskuun 1. ja 15. päivänä', 'Vuosittain syyskuun ja lokakuun 15. päivänä', - 'Vuosittain syyskuun ja lokakuun päivinä 1 ja 15', + 'Vuosittain syyskuun ja lokakuun 1. ja 15. päivänä', 'Vuosittain syyskuun ensimmäisenä maanantaina', - 'Vuosittain syyskuun ja lokakuun ensimmäisenä maanantaina', ], 'fr': [ 'Annuel le 15 sept.', @@ -672,7 +760,6 @@ void main() { 'Annuel le 15 de sept. et oct.', 'Annuel les jours 1 et 15 de sept. et oct.', 'Annuel le premier lundi de sept.', - 'Annuel le premier lundi de sept. et oct.', ], 'hi': [ 'हर वर्ष सित॰ की 15 तारीख को', @@ -680,7 +767,6 @@ void main() { 'हर वर्ष सित॰ और अक्तू॰ की 15 तारीख को', 'हर वर्ष सित॰ और अक्तू॰ की 1 और 15 तारीखों को', 'हर वर्ष सित॰ के पहले सोमवार को', - 'हर वर्ष सित॰ और अक्तू॰ के पहले सोमवार को', ], 'it': [ 'Ogni anno il giorno 15 di set', @@ -688,7 +774,6 @@ void main() { 'Ogni anno il giorno 15 di set e ott', 'Ogni anno nei giorni 1 e 15 di set e ott', 'Ogni anno il primo lunedì di set', - 'Ogni anno il primo lunedì di set e ott', ], 'ja': [ '毎年9月15日', @@ -696,7 +781,6 @@ void main() { '毎年9月と10月の15日', '毎年9月と10月の1日と15日', '毎年9月の第1月曜日', - '毎年9月と10月の第1月曜日', ], 'ko': [ '매년 9월 15일', @@ -704,15 +788,13 @@ void main() { '매년 9월과 10월의 15일', '매년 9월과 10월의 1일과 15일', '매년 9월의 첫 번째 월요일', - '매년 9월과 10월의 첫 번째 월요일', ], - 'pt': [ + 'pt-PT': [ 'Anualmente no dia 15 de set.', 'Anualmente nos dias 1 e 15 de set.', 'Anualmente no dia 15 de set. e out.', 'Anualmente nos dias 1 e 15 de set. e out.', 'Anualmente na primeira ocorrência de segunda-feira de set.', - 'Anualmente na primeira ocorrência de segunda-feira de set. e out.', ], 'ru': [ 'Ежегодно: сент., 15-го числа', @@ -720,7 +802,6 @@ void main() { 'Ежегодно: сент. и окт., 15-го числа', 'Ежегодно: сент. и окт., 1-го и 15-го числа', 'Ежегодно: сент., в первый понедельник', - 'Ежегодно: сент. и окт., в первый понедельник', ], 'vi': [ 'Hằng năm vào ngày 15 thg 9', @@ -728,7 +809,6 @@ void main() { 'Hằng năm vào ngày 15 của thg 9 và thg 10', 'Hằng năm vào các ngày 1 và 15 của thg 9 và thg 10', 'Hằng năm vào Thứ Hai đầu tiên của thg 9', - 'Hằng năm vào Thứ Hai đầu tiên của thg 9 và thg 10', ], 'zh': [ '每年9月15日', @@ -736,7 +816,6 @@ void main() { '每年9月和10月的15日', '每年9月和10月的1日和15日', '每年9月的第一个星期一', - '每年9月和10月的第一个星期一', ], 'zh-Hans': [ '每年9月15日', @@ -744,7 +823,6 @@ void main() { '每年9月和10月的15日', '每年9月和10月的1日和15日', '每年9月的第一个星期一', - '每年9月和10月的第一个星期一', ], 'zh-Hant': [ '每年9月15日', @@ -752,18 +830,16 @@ void main() { '每年9月和10月的15日', '每年9月和10月的1日和15日', '每年9月的第一個星期一', - '每年9月和10月的第一個星期一', ], }; - for (final locale in AppLocalizations.supportedLocales) { + for (final locale in busyMaxSupportedLocales) { final values = [ await summary(locale, yearlyMonthDayRule), await summary(locale, yearlyMonthDaysRule), await summary(locale, yearlyMonthsMonthDayRule), await summary(locale, yearlyMonthsMonthDaysRule), await summary(locale, yearlyOrdinalRule), - await summary(locale, yearlyMonthsOrdinalRule), ]; expect( values, @@ -771,6 +847,10 @@ void main() { reason: locale.toLanguageTag(), ); } + expect( + await summary(const Locale('en'), yearlyMonthsOrdinalRule), + lookupAppLocalizations(const Locale('en')).unsupportedRecurrencePreserved, + ); expect( await summary(const Locale('en'), yearlyThreeMonthsAndDaysRule), 'Yearly on days 1, 15 and 20 of Aug, Sep and Oct', @@ -807,7 +887,7 @@ void main() { ); expect( await summary(const Locale('ru'), monthDaysRule), - 'Ежемесячно в дни месяца 1-го и 15-го', + 'Ежемесячно 1-го и 15-го числа каждого месяца', ); expect( await summary(const Locale('et'), singleMonthDayRule), @@ -819,7 +899,15 @@ void main() { ); expect( await summary(const Locale('hi'), singleMonthDayRule), - 'हर महीने के 15वें दिन', + 'हर महीने की 15 तारीख को', + ); + expect( + await summary(const Locale('hi'), monthDaysRule), + 'हर महीने की 1 और 15 तारीखों को', + ); + expect( + await summary(const Locale('ar'), monthDaysRule), + 'شهريًا في يومي 1 و15 من الشهر', ); expect( await summary(const Locale('hi'), ordinalRule), @@ -906,13 +994,13 @@ void main() { 'et': 'Iga päev · 1 kord', 'fi': 'Päivittäin · 1 kerran', 'it': 'Ogni giorno · 1 volta', - 'pt': 'Diariamente · 1 vez', + 'pt-PT': 'Diariamente · 1 vez', 'ru': 'Ежедневно · 1 раз', 'ar': 'يوميًا · مرة واحدة', }; for (final entry in singularCountSummaries.entries) { expect( - await summary(Locale(entry.key), rule(count: 1)), + await summary(busyMaxLocaleFromTag(entry.key)!, rule(count: 1)), entry.value, reason: entry.key, ); @@ -1015,17 +1103,69 @@ void main() { await summary(const Locale('ar'), weekly), 'أسبوعيًا في الاثنين والأربعاء', ); + expect( + await summary(const Locale('de'), weekly), + 'Wöchentlich montags und mittwochs', + ); + expect( + await summary(const Locale('et'), weekly), + 'Iga nädal esmaspäeviti ja kolmapäeviti', + ); + expect( + await summary(const Locale('fi'), weekly), + 'Viikoittain maanantaisin ja keskiviikkoisin', + ); + expect( + await summary(const Locale('fr'), weekly), + 'Hebdomadaire le lundi et le mercredi', + ); + expect( + await summary(const Locale('it'), weekly), + 'Ogni settimana il lunedì e il mercoledì', + ); + expect( + await summary(const Locale('pt', 'PT'), weekly), + 'Semanalmente à segunda-feira e à quarta-feira', + ); + expect( + await summary(const Locale('ru'), weekly), + 'Еженедельно по понедельникам и средам', + ); final monthly = rule( frequency: RecurrenceFrequency.monthly, byMonthDay: const [1, 15], ); + final monthlyThreeDates = rule( + frequency: RecurrenceFrequency.monthly, + byMonthDay: const [1, 15, 20], + ); expect( await summary(const Locale('en'), monthly), 'Monthly on days 1 and 15', ); expect(await summary(const Locale('ja'), monthly), '毎月1日、15日'); expect(await summary(const Locale('zh'), monthly), '每月1日、15日'); + expect( + await summary(const Locale('hi'), monthly), + 'हर महीने की 1 और 15 तारीखों को', + ); + expect( + await summary(const Locale('ar'), monthly), + 'شهريًا في يومي 1 و15 من الشهر', + ); + expect( + await summary(const Locale('en'), monthlyThreeDates), + 'Monthly on days 1, 15 and 20', + ); + expect( + await summary(const Locale('hi'), monthlyThreeDates), + 'हर महीने की 1, 15 और 20 तारीखों को', + ); + expect( + await summary(const Locale('ar'), monthlyThreeDates), + 'شهريًا في الأيام 1، 15 و20 من الشهر', + ); }, ); @@ -1038,6 +1178,17 @@ void main() { expect(source, isNot(contains('repeatMonthDayListSeparator'))); expect(source, contains('repeatWeekdayListPair')); expect(source, contains('repeatMonthDayListPair')); + expect(source, contains('repeatWeeklyDaySummary')); + expect( + source, + isNot(contains('l10n.repeatYearlyInMonthsOnOrdinalSummary')), + ); + expect( + source, + contains( + '!widget.limits.allowMultipleMonths || _value.bySetPosition != null', + ), + ); }); test('Persian dynamic numbers use Persian digits', () { @@ -1053,6 +1204,78 @@ void main() { expect(AppLocalizationsAr().moreItems(12), contains('${fsi}12$pdi')); }); + test('all human-facing Persian numeric placeholders use Persian digits', () { + final fa = AppLocalizationsFa(); + final valuesWithTwelve = [ + fa.calendarColorOption(12), + fa.moreItems(12), + fa.trayTasksDueToday(12), + fa.trayLastSyncedMinutesAgo(12), + fa.trayLastSyncedHoursAgo(12), + fa.trayLastSyncedDaysAgo(12), + fa.reminderMinutesBefore(12), + fa.reminderHoursBefore(12), + fa.reminderDaysBefore(12), + fa.completionPercent(12), + fa.relatedRemindersDescription(12), + fa.repeatEveryDays(12), + fa.repeatEveryWeeks(12), + fa.repeatEveryMonths(12), + fa.repeatEveryYears(12), + fa.repeatTimesSummary(12), + fa.pendingOpAttempts(12), + fa.dueTodayNotificationBody(12), + fa.weekNumberTooltip(12), + fa.scheduleItemCount(12), + fa.importEventsFound(12), + fa.importInvalidEvents(12), + fa.importQueued(12), + fa.importDuplicatesSkipped(12), + fa.importUnsupportedSets(12), + ]; + for (final value in valuesWithTwelve) { + expect(value, contains('۱۲'), reason: value); + expect(value, isNot(contains('12')), reason: value); + } + for (final value in [ + fa.priorityHighValue(4), + fa.priorityMediumValue(4), + fa.priorityLowValue(4), + ]) { + expect(value, contains('۴'), reason: value); + expect(value, isNot(contains('4')), reason: value); + } + }); + + test( + 'Finnish and Arabic quantities select singular and Arabic dual forms', + () { + final fi = lookupAppLocalizations(const Locale('fi')); + final ar = AppLocalizationsAr(); + const fsi = '\u2068'; + const pdi = '\u2069'; + + expect(fi.moreItems(1), '+1 muu'); + expect(fi.moreItems(2), '+2 muuta'); + expect(ar.moreItems(1), '+عنصر واحد آخر'); + expect(ar.moreItems(2), '+عنصران آخران'); + expect(ar.moreItems(3), '+${fsi}3$pdi عناصر أخرى'); + expect(ar.moreItems(11), '+${fsi}11$pdi عنصرًا آخر'); + + expect(ar.trayLastSyncedMinutesAgo(2), 'تمت المزامنة قبل دقيقتين'); + expect(ar.trayLastSyncedHoursAgo(2), 'تمت المزامنة قبل ساعتين'); + expect(ar.trayLastSyncedDaysAgo(2), 'تمت المزامنة قبل يومين'); + expect( + ar.trayLastSyncedMinutesAgo(3), + 'تمت المزامنة قبل ${fsi}3$pdi دقائق', + ); + expect( + ar.trayLastSyncedMinutesAgo(11), + 'تمت المزامنة قبل ${fsi}11$pdi دقيقة', + ); + }, + ); + test('yearly recurrence month forms cover every month', () { const monthKeys = [ 'jan', @@ -1180,7 +1403,7 @@ void main() { }); test('every selectable locale has a generated catalog', () { - final generated = AppLocalizations.supportedLocales.toSet() + final generated = busyMaxSupportedLocales.toSet() ..remove(const Locale('zh')); final selectable = busyMaxLocaleOptions .map((option) => option.locale) @@ -1210,7 +1433,8 @@ Iterable _translatedArbFiles() sync* { for (final entity in Directory('lib/l10n').listSync()) { if (entity is File && entity.path.endsWith('.arb') && - !entity.path.endsWith('app_en.arb')) { + !entity.path.endsWith('app_en.arb') && + !entity.path.endsWith('app_pt.arb')) { yield entity; } } diff --git a/test/app/localized_formatters_test.dart b/test/app/localized_formatters_test.dart index 12f55cd6..6b08d857 100644 --- a/test/app/localized_formatters_test.dart +++ b/test/app/localized_formatters_test.dart @@ -20,7 +20,7 @@ void main() { 'it': 'Luglio', 'ja': '7月', 'ko': '7월', - 'pt': 'julho', + 'pt_PT': 'julho', 'ru': 'Июль', 'vi': 'Tháng 7', 'zh': '七月', @@ -52,7 +52,7 @@ void main() { 'it': 'Lunedì 7 settembre 2026', 'ja': '2026年9月7日月曜日', 'ko': '2026년 9월 7일 월요일', - 'pt': 'Segunda-feira, 7 de setembro de 2026', + 'pt_PT': 'Segunda-feira, 7 de setembro de 2026', 'ru': 'Понедельник, 7 сентября 2026 г.', 'vi': 'Thứ Hai, 7 tháng 9, 2026', 'zh': '2026年9月7日星期一', @@ -85,7 +85,7 @@ void main() { 'it': 'Settembre 2026', 'ja': '2026年9月', 'ko': '2026년 9월', - 'pt': 'setembro de 2026', + 'pt_PT': 'setembro de 2026', 'ru': 'Сентябрь 2026 г.', 'vi': 'tháng 9 năm 2026', 'zh': '2026年9月', @@ -119,7 +119,7 @@ void main() { 'it': ('Lunedì', 'Lun'), 'ja': ('月曜日', '月'), 'ko': ('월요일', '월'), - 'pt': ('Segunda-feira', 'Seg.'), + 'pt_PT': ('Segunda-feira', 'Segunda'), 'ru': ('Понедельник', 'Пн'), 'vi': ('Thứ Hai', 'Th 2'), 'zh': ('星期一', '周一'), @@ -139,7 +139,7 @@ void main() { 'it': ('Settembre', 'Set'), 'ja': ('9月', '9月'), 'ko': ('9월', '9월'), - 'pt': ('setembro', 'set.'), + 'pt_PT': ('setembro', 'set.'), 'ru': ('Сентябрь', 'Сент.'), 'vi': ('Tháng 9', 'Tháng 9'), 'zh': ('九月', '9月'), diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 22ea06ab..7c3213c1 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -183,7 +183,7 @@ void main() { final service = DesktopNotificationService( backend: backend, settings: AppSettings.defaults().copyWith(notifyDueToday: true), - locale: const Locale('pt'), + locale: const Locale('pt', 'PT'), ); await service.notifyDueToday(2); diff --git a/test/features/recurrence/domain/event_recurrence_codec_test.dart b/test/features/recurrence/domain/event_recurrence_codec_test.dart index 056db973..d87b2ee9 100644 --- a/test/features/recurrence/domain/event_recurrence_codec_test.dart +++ b/test/features/recurrence/domain/event_recurrence_codec_test.dart @@ -70,6 +70,38 @@ void main() { expect(rule.rawRules, ['FREQ=WEEKLY;BYDAY=MO']); expect(rule.exceptionDates, ['EXDATE:20260615T160000Z']); }); + + test('rejects one ordinal set position across multiple yearly months', () { + final decoded = EventRecurrenceCodec.decode(BusyProvider.google, const [ + 'RRULE:FREQ=YEARLY;BYMONTH=9,10;BYDAY=MO;BYSETPOS=1', + ], baseDate: DateTime(2026, 9, 7)); + + expect(decoded.isSupported, isFalse); + expect( + EventRecurrenceCodec.canEncode(BusyProvider.google, decoded), + isFalse, + ); + + const editableShape = RecurrenceRule( + frequency: RecurrenceFrequency.yearly, + interval: 1, + byDay: ['MO'], + byMonth: [9, 10], + byMonthDay: [], + bySetPosition: 1, + count: null, + untilRaw: null, + recurrenceDates: [], + exceptionDates: [], + rawRules: [], + isSupported: true, + ); + expect(RecurrenceRuleLimits.rfc5545.supports(editableShape), isFalse); + expect( + EventRecurrenceCodec.canEncode(BusyProvider.google, editableShape), + isFalse, + ); + }); }); group('Microsoft Graph recurrence', () { From 65a760d0b184f810dde887691bb0ec7fe1dffb72 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 12:47:35 -0700 Subject: [PATCH 23/69] add windows port --- .github/workflows/flutter-linux.yml | 10 +- .github/workflows/flutter-windows.yml | 66 + .gitignore | 5 + .metadata | 3 + README.md | 41 +- assets/windows/busymax_tray.ico | Bin 0 -> 97283 bytes assets/windows/busymax_tray_offline.ico | Bin 0 -> 72422 bytes config/windows_store.example.json | 17 + docs/privacy_data_map.md | 36 + docs/windows_architecture.md | 119 ++ docs/windows_development.md | 107 ++ docs/windows_packaging.md | 120 ++ docs/windows_release_checklist.md | 99 ++ docs/windows_ui.md | 68 + lib/l10n/app_ar.arb | 13 +- lib/l10n/app_de.arb | 13 +- lib/l10n/app_en.arb | 13 +- lib/l10n/app_es.arb | 13 +- lib/l10n/app_et.arb | 13 +- lib/l10n/app_fa.arb | 13 +- lib/l10n/app_fi.arb | 13 +- lib/l10n/app_fr.arb | 13 +- lib/l10n/app_hi.arb | 13 +- lib/l10n/app_it.arb | 13 +- lib/l10n/app_ja.arb | 13 +- lib/l10n/app_ko.arb | 13 +- lib/l10n/app_pt_PT.arb | 13 +- lib/l10n/app_ru.arb | 13 +- lib/l10n/app_vi.arb | 13 +- lib/l10n/app_zh.arb | 13 +- lib/l10n/app_zh_Hans.arb | 13 +- lib/l10n/app_zh_Hant.arb | 13 +- lib/l10n/generated/app_localizations.dart | 68 +- lib/l10n/generated/app_localizations_ar.dart | 38 +- lib/l10n/generated/app_localizations_de.dart | 39 +- lib/l10n/generated/app_localizations_en.dart | 38 +- lib/l10n/generated/app_localizations_es.dart | 39 +- lib/l10n/generated/app_localizations_et.dart | 39 +- lib/l10n/generated/app_localizations_fa.dart | 39 +- lib/l10n/generated/app_localizations_fi.dart | 39 +- lib/l10n/generated/app_localizations_fr.dart | 39 +- lib/l10n/generated/app_localizations_hi.dart | 39 +- lib/l10n/generated/app_localizations_it.dart | 39 +- lib/l10n/generated/app_localizations_ja.dart | 37 +- lib/l10n/generated/app_localizations_ko.dart | 38 +- lib/l10n/generated/app_localizations_pt.dart | 77 +- lib/l10n/generated/app_localizations_ru.dart | 38 +- lib/l10n/generated/app_localizations_vi.dart | 39 +- lib/l10n/generated/app_localizations_zh.dart | 108 +- lib/main.dart | 107 +- lib/main_linux.dart | 145 ++ lib/main_windows.dart | 137 ++ lib/src/app/app_bootstrap.dart | 84 +- lib/src/app/busymax_app.dart | 89 +- lib/src/app/common/busymax_design_values.dart | 72 + lib/src/app/windows/windows_busymax_app.dart | 260 +++ lib/src/config/build_config.dart | 33 +- .../time/linux_gweather_location_source.dart | 22 +- lib/src/core/time/local_time_zone.dart | 12 + .../core/time/system_time_zone_location.dart | 21 + lib/src/core/time/time_zone_catalog.dart | 21 +- lib/src/demo/demo_profile.dart | 9 +- .../auth/presentation/sign_in_screen.dart | 1 + .../presentation/ical_import_flow.dart | 1 + .../feedback/data/feedback_submission.dart | 4 +- .../presentation/feedback_dialog.dart | 1 + .../desktop_notification_backend.dart | 73 + .../desktop_notification_service.dart | 121 +- .../notifications/notification_scheduler.dart | 43 + .../recurrence/domain/recurrence_rule.dart | 14 +- .../presentation/schedule_sidebar.dart | 1 + .../presentation/schedule_workspace.dart | 1 + .../presentation/settings_screen.dart | 1 + .../tasks/presentation/new_task_dialog.dart | 1 + .../tasks/presentation/task_details_pane.dart | 1 + lib/src/platform/common/desktop_services.dart | 296 +++ .../external_calendar_open_service.dart | 139 +- .../linux/linux_notification_backend.dart | 64 + lib/src/platform/linux_autostart_service.dart | 13 +- lib/src/platform/linux_window_service.dart | 9 +- .../windows/windows_activation_service.dart | 92 + .../windows/windows_autostart_service.dart | 40 + .../windows/windows_notification_backend.dart | 187 ++ .../windows_system_appearance_source.dart | 37 + .../windows/windows_time_zone_source.dart | 101 ++ .../windows/windows_tray_service.dart | 180 ++ .../windows/windows_window_service.dart | 30 + lib/src/schedule/schedule_repository.dart | 35 + lib/src/ui/common/busymax_glyph.dart | 29 + lib/src/ui/linux/linux_busymax_glyphs.dart | 52 + .../windows_account_removal_dialog.dart | 62 + .../ui/windows/windows_busymax_glyphs.dart | 34 + .../windows_calendar_activation_flows.dart | 269 +++ .../windows/windows_dav_account_dialogs.dart | 154 ++ .../ui/windows/windows_desktop_runtime.dart | 212 +++ .../windows/windows_diagnostics_dialog.dart | 230 +++ .../windows/windows_event_editor_dialog.dart | 1338 ++++++++++++++ .../ui/windows/windows_event_reminders.dart | 66 + .../ui/windows/windows_feedback_dialog.dart | 285 +++ .../windows/windows_guest_update_dialog.dart | 74 + .../windows_keyboard_shortcuts_dialog.dart | 112 ++ .../ui/windows/windows_recurrence_dialog.dart | 279 +++ lib/src/ui/windows/windows_schedule_page.dart | 1538 ++++++++++++++++ .../windows/windows_schedule_source_pane.dart | 746 ++++++++ lib/src/ui/windows/windows_settings_page.dart | 966 ++++++++++ lib/src/ui/windows/windows_sign_in_page.dart | 217 +++ .../windows/windows_task_create_fields.dart | 125 ++ .../windows/windows_task_details_dialog.dart | 1583 +++++++++++++++++ .../windows/windows_task_editor_dialog.dart | 885 +++++++++ lib/src/ui/windows/windows_tasks_page.dart | 211 +++ .../ui/windows/windows_time_zone_dialog.dart | 86 + .../ui/windows/windows_workspace_shell.dart | 74 + linux/flutter/generated_plugin_registrant.cc | 8 - linux/flutter/generated_plugins.cmake | 2 - pubspec.lock | 201 ++- pubspec.yaml | 15 + test/app/native_ui_audit_test.dart | 15 +- test/architecture/platform_boundary_test.dart | 18 + test/config/build_config_test.dart | 5 +- test/demo/demo_profile_test.dart | 1 + .../auth/presentation/auth_routing_test.dart | 3 +- .../data/feedback_api_client_test.dart | 1 + .../data/feedback_submission_test.dart | 2 + .../desktop_notification_service_test.dart | 44 +- .../notification_scheduler_test.dart | 40 +- .../schedule_workspace_states_test.dart | 1 + ...chedule_workspace_task_mutations_test.dart | 1 + .../schedule/schedule_search_test.dart | 39 + .../presentation/settings_screen_test.dart | 1 + .../common/desktop_activation_test.dart | 138 ++ .../windows_activation_service_test.dart | 72 + .../platform/windows/windows_assets_test.dart | 52 + .../windows/windows_event_reminders_test.dart | 55 + .../windows_guest_update_dialog_test.dart | 79 + .../windows_packaging_contract_test.dart | 99 ++ .../windows_schedule_source_pane_test.dart | 85 + .../windows_task_create_fields_test.dart | 79 + .../windows_task_details_dialog_test.dart | 254 +++ .../windows_time_zone_source_test.dart | 69 + third_party/flutter_timezone/LICENSE | 201 +++ .../flutter_timezone/README.busymax.md | 6 + .../lib/flutter_timezone.dart | 53 + .../flutter_timezone/lib/timezone_info.dart | 35 + third_party/flutter_timezone/pubspec.yaml | 24 + .../flutter_timezone/windows/CMakeLists.txt | 102 ++ .../windows/flutter_timezone_plugin.cpp | 146 ++ .../windows/flutter_timezone_plugin.h | 37 + .../windows/flutter_timezone_plugin_c_api.cpp | 12 + .../flutter_timezone_plugin_c_api.h | 23 + third_party/tray_manager/LICENSE | 21 + third_party/tray_manager/README.busymax.md | 7 + .../tray_manager/lib/src/helpers/sandbox.dart | 9 + .../tray_manager/lib/src/tray_listener.dart | 15 + .../tray_manager/lib/src/tray_manager.dart | 225 +++ .../tray_manager/lib/tray_manager.dart | 4 + third_party/tray_manager/pubspec.yaml | 25 + .../tray_manager/windows/CMakeLists.txt | 25 + .../tray_manager/tray_manager_plugin.h | 23 + .../windows/tray_manager_plugin.cpp | 426 +++++ third_party/yaru_window/LICENSE | 373 ++++ third_party/yaru_window/README.busymax.md | 7 + third_party/yaru_window/lib/src/window.dart | 223 +++ third_party/yaru_window/lib/yaru_window.dart | 7 + third_party/yaru_window/pubspec.yaml | 21 + tool/check_platform_boundaries.dart | 169 ++ tool/generate_windows_assets.dart | 158 ++ tools/windows/AppxManifest.xml.template | 95 + tools/windows/build_release.ps1 | 52 + tools/windows/check_prerequisites.ps1 | 48 + tools/windows/common.ps1 | 185 ++ tools/windows/create_test_certificate.ps1 | 26 + tools/windows/install_test_package.ps1 | 26 + tools/windows/package_store.ps1 | 65 + tools/windows/render_manifest.ps1 | 25 + tools/windows/run_wack.ps1 | 16 + tools/windows/validate_manifest.ps1 | 97 + tools/windows/validate_package_contents.ps1 | 41 + tools/windows/validate_release_config.ps1 | 15 + windows/.gitignore | 17 + windows/CMakeLists.txt | 113 ++ windows/flutter/CMakeLists.txt | 109 ++ .../flutter/generated_plugin_registrant.cc | 32 + windows/flutter/generated_plugin_registrant.h | 15 + windows/flutter/generated_plugins.cmake | 32 + windows/runner/CMakeLists.txt | 43 + windows/runner/Runner.rc | 121 ++ windows/runner/flutter_window.cpp | 272 +++ windows/runner/flutter_window.h | 52 + windows/runner/main.cpp | 83 + windows/runner/resource.h | 16 + windows/runner/resources/app_icon.ico | Bin 0 -> 97283 bytes .../runner/resources/msix/FileAssociation.png | Bin 0 -> 2294 bytes .../msix/FileAssociation.scale-100.png | Bin 0 -> 2294 bytes .../msix/FileAssociation.scale-125.png | Bin 0 -> 3163 bytes .../msix/FileAssociation.scale-150.png | Bin 0 -> 4255 bytes .../msix/FileAssociation.scale-200.png | Bin 0 -> 6619 bytes .../msix/FileAssociation.scale-400.png | Bin 0 -> 19362 bytes .../msix/Square150x150Logo.scale-100.png | Bin 0 -> 15349 bytes .../msix/Square150x150Logo.scale-125.png | Bin 0 -> 22115 bytes .../msix/Square150x150Logo.scale-150.png | Bin 0 -> 30862 bytes .../msix/Square150x150Logo.scale-200.png | Bin 0 -> 51677 bytes .../msix/Square150x150Logo.scale-400.png | Bin 0 -> 163667 bytes .../msix/Square44x44Logo.scale-100.png | Bin 0 -> 2294 bytes .../msix/Square44x44Logo.scale-125.png | Bin 0 -> 3163 bytes .../msix/Square44x44Logo.scale-150.png | Bin 0 -> 4255 bytes .../msix/Square44x44Logo.scale-200.png | Bin 0 -> 6619 bytes .../msix/Square44x44Logo.scale-400.png | Bin 0 -> 19362 bytes .../resources/msix/StoreLogo.scale-100.png | Bin 0 -> 2746 bytes .../resources/msix/StoreLogo.scale-125.png | Bin 0 -> 3859 bytes .../resources/msix/StoreLogo.scale-150.png | Bin 0 -> 5133 bytes .../resources/msix/StoreLogo.scale-200.png | Bin 0 -> 8045 bytes .../resources/msix/StoreLogo.scale-400.png | Bin 0 -> 24942 bytes windows/runner/runner.exe.manifest | 23 + windows/runner/single_instance.cpp | 419 +++++ windows/runner/single_instance.h | 41 + windows/runner/utils.cpp | 69 + windows/runner/utils.h | 19 + windows/runner/win32_window.cpp | 288 +++ windows/runner/win32_window.h | 102 ++ windows/runner/windows_startup_task.cpp | 118 ++ windows/runner/windows_startup_task.h | 16 + 221 files changed, 19799 insertions(+), 491 deletions(-) create mode 100644 .github/workflows/flutter-windows.yml create mode 100644 assets/windows/busymax_tray.ico create mode 100644 assets/windows/busymax_tray_offline.ico create mode 100644 config/windows_store.example.json create mode 100644 docs/privacy_data_map.md create mode 100644 docs/windows_architecture.md create mode 100644 docs/windows_development.md create mode 100644 docs/windows_packaging.md create mode 100644 docs/windows_release_checklist.md create mode 100644 docs/windows_ui.md create mode 100644 lib/main_linux.dart create mode 100644 lib/main_windows.dart create mode 100644 lib/src/app/common/busymax_design_values.dart create mode 100644 lib/src/app/windows/windows_busymax_app.dart create mode 100644 lib/src/core/time/system_time_zone_location.dart create mode 100644 lib/src/features/notifications/desktop_notification_backend.dart create mode 100644 lib/src/platform/common/desktop_services.dart create mode 100644 lib/src/platform/linux/linux_notification_backend.dart create mode 100644 lib/src/platform/windows/windows_activation_service.dart create mode 100644 lib/src/platform/windows/windows_autostart_service.dart create mode 100644 lib/src/platform/windows/windows_notification_backend.dart create mode 100644 lib/src/platform/windows/windows_system_appearance_source.dart create mode 100644 lib/src/platform/windows/windows_time_zone_source.dart create mode 100644 lib/src/platform/windows/windows_tray_service.dart create mode 100644 lib/src/platform/windows/windows_window_service.dart create mode 100644 lib/src/ui/common/busymax_glyph.dart create mode 100644 lib/src/ui/linux/linux_busymax_glyphs.dart create mode 100644 lib/src/ui/windows/windows_account_removal_dialog.dart create mode 100644 lib/src/ui/windows/windows_busymax_glyphs.dart create mode 100644 lib/src/ui/windows/windows_calendar_activation_flows.dart create mode 100644 lib/src/ui/windows/windows_dav_account_dialogs.dart create mode 100644 lib/src/ui/windows/windows_desktop_runtime.dart create mode 100644 lib/src/ui/windows/windows_diagnostics_dialog.dart create mode 100644 lib/src/ui/windows/windows_event_editor_dialog.dart create mode 100644 lib/src/ui/windows/windows_event_reminders.dart create mode 100644 lib/src/ui/windows/windows_feedback_dialog.dart create mode 100644 lib/src/ui/windows/windows_guest_update_dialog.dart create mode 100644 lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart create mode 100644 lib/src/ui/windows/windows_recurrence_dialog.dart create mode 100644 lib/src/ui/windows/windows_schedule_page.dart create mode 100644 lib/src/ui/windows/windows_schedule_source_pane.dart create mode 100644 lib/src/ui/windows/windows_settings_page.dart create mode 100644 lib/src/ui/windows/windows_sign_in_page.dart create mode 100644 lib/src/ui/windows/windows_task_create_fields.dart create mode 100644 lib/src/ui/windows/windows_task_details_dialog.dart create mode 100644 lib/src/ui/windows/windows_task_editor_dialog.dart create mode 100644 lib/src/ui/windows/windows_tasks_page.dart create mode 100644 lib/src/ui/windows/windows_time_zone_dialog.dart create mode 100644 lib/src/ui/windows/windows_workspace_shell.dart create mode 100644 test/architecture/platform_boundary_test.dart create mode 100644 test/platform/common/desktop_activation_test.dart create mode 100644 test/platform/windows/windows_activation_service_test.dart create mode 100644 test/platform/windows/windows_assets_test.dart create mode 100644 test/platform/windows/windows_event_reminders_test.dart create mode 100644 test/platform/windows/windows_guest_update_dialog_test.dart create mode 100644 test/platform/windows/windows_packaging_contract_test.dart create mode 100644 test/platform/windows/windows_schedule_source_pane_test.dart create mode 100644 test/platform/windows/windows_task_create_fields_test.dart create mode 100644 test/platform/windows/windows_task_details_dialog_test.dart create mode 100644 test/platform/windows/windows_time_zone_source_test.dart create mode 100644 third_party/flutter_timezone/LICENSE create mode 100644 third_party/flutter_timezone/README.busymax.md create mode 100644 third_party/flutter_timezone/lib/flutter_timezone.dart create mode 100644 third_party/flutter_timezone/lib/timezone_info.dart create mode 100644 third_party/flutter_timezone/pubspec.yaml create mode 100644 third_party/flutter_timezone/windows/CMakeLists.txt create mode 100644 third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp create mode 100644 third_party/flutter_timezone/windows/flutter_timezone_plugin.h create mode 100644 third_party/flutter_timezone/windows/flutter_timezone_plugin_c_api.cpp create mode 100644 third_party/flutter_timezone/windows/include/flutter_timezone/flutter_timezone_plugin_c_api.h create mode 100644 third_party/tray_manager/LICENSE create mode 100644 third_party/tray_manager/README.busymax.md create mode 100644 third_party/tray_manager/lib/src/helpers/sandbox.dart create mode 100644 third_party/tray_manager/lib/src/tray_listener.dart create mode 100644 third_party/tray_manager/lib/src/tray_manager.dart create mode 100644 third_party/tray_manager/lib/tray_manager.dart create mode 100644 third_party/tray_manager/pubspec.yaml create mode 100644 third_party/tray_manager/windows/CMakeLists.txt create mode 100644 third_party/tray_manager/windows/include/tray_manager/tray_manager_plugin.h create mode 100644 third_party/tray_manager/windows/tray_manager_plugin.cpp create mode 100644 third_party/yaru_window/LICENSE create mode 100644 third_party/yaru_window/README.busymax.md create mode 100644 third_party/yaru_window/lib/src/window.dart create mode 100644 third_party/yaru_window/lib/yaru_window.dart create mode 100644 third_party/yaru_window/pubspec.yaml create mode 100644 tool/check_platform_boundaries.dart create mode 100644 tool/generate_windows_assets.dart create mode 100644 tools/windows/AppxManifest.xml.template create mode 100644 tools/windows/build_release.ps1 create mode 100644 tools/windows/check_prerequisites.ps1 create mode 100644 tools/windows/common.ps1 create mode 100644 tools/windows/create_test_certificate.ps1 create mode 100644 tools/windows/install_test_package.ps1 create mode 100644 tools/windows/package_store.ps1 create mode 100644 tools/windows/render_manifest.ps1 create mode 100644 tools/windows/run_wack.ps1 create mode 100644 tools/windows/validate_manifest.ps1 create mode 100644 tools/windows/validate_package_contents.ps1 create mode 100644 tools/windows/validate_release_config.ps1 create mode 100644 windows/.gitignore create mode 100644 windows/CMakeLists.txt create mode 100644 windows/flutter/CMakeLists.txt create mode 100644 windows/flutter/generated_plugin_registrant.cc create mode 100644 windows/flutter/generated_plugin_registrant.h create mode 100644 windows/flutter/generated_plugins.cmake create mode 100644 windows/runner/CMakeLists.txt create mode 100644 windows/runner/Runner.rc create mode 100644 windows/runner/flutter_window.cpp create mode 100644 windows/runner/flutter_window.h create mode 100644 windows/runner/main.cpp create mode 100644 windows/runner/resource.h create mode 100644 windows/runner/resources/app_icon.ico create mode 100644 windows/runner/resources/msix/FileAssociation.png create mode 100644 windows/runner/resources/msix/FileAssociation.scale-100.png create mode 100644 windows/runner/resources/msix/FileAssociation.scale-125.png create mode 100644 windows/runner/resources/msix/FileAssociation.scale-150.png create mode 100644 windows/runner/resources/msix/FileAssociation.scale-200.png create mode 100644 windows/runner/resources/msix/FileAssociation.scale-400.png create mode 100644 windows/runner/resources/msix/Square150x150Logo.scale-100.png create mode 100644 windows/runner/resources/msix/Square150x150Logo.scale-125.png create mode 100644 windows/runner/resources/msix/Square150x150Logo.scale-150.png create mode 100644 windows/runner/resources/msix/Square150x150Logo.scale-200.png create mode 100644 windows/runner/resources/msix/Square150x150Logo.scale-400.png create mode 100644 windows/runner/resources/msix/Square44x44Logo.scale-100.png create mode 100644 windows/runner/resources/msix/Square44x44Logo.scale-125.png create mode 100644 windows/runner/resources/msix/Square44x44Logo.scale-150.png create mode 100644 windows/runner/resources/msix/Square44x44Logo.scale-200.png create mode 100644 windows/runner/resources/msix/Square44x44Logo.scale-400.png create mode 100644 windows/runner/resources/msix/StoreLogo.scale-100.png create mode 100644 windows/runner/resources/msix/StoreLogo.scale-125.png create mode 100644 windows/runner/resources/msix/StoreLogo.scale-150.png create mode 100644 windows/runner/resources/msix/StoreLogo.scale-200.png create mode 100644 windows/runner/resources/msix/StoreLogo.scale-400.png create mode 100644 windows/runner/runner.exe.manifest create mode 100644 windows/runner/single_instance.cpp create mode 100644 windows/runner/single_instance.h create mode 100644 windows/runner/utils.cpp create mode 100644 windows/runner/utils.h create mode 100644 windows/runner/win32_window.cpp create mode 100644 windows/runner/win32_window.h create mode 100644 windows/runner/windows_startup_task.cpp create mode 100644 windows/runner/windows_startup_task.h diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 63d9a906..b5ea9f1b 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -42,6 +42,9 @@ jobs: - name: Resolve dependencies run: flutter pub get + - name: Generate localizations + run: flutter gen-l10n + - name: Generate Drift code run: dart run build_runner build --delete-conflicting-outputs --force-jit @@ -49,11 +52,14 @@ jobs: run: git diff --exit-code - name: Check formatting - run: dart format --set-exit-if-changed . + run: dart format --output=none --set-exit-if-changed . - name: Analyze run: flutter analyze + - name: Check platform boundaries + run: dart run tool/check_platform_boundaries.dart + - name: Test run: flutter test @@ -73,7 +79,7 @@ jobs: if [ -n "$MICROSOFT_OAUTH_CLIENT_ID" ]; then args+=("--dart-define=MICROSOFT_OAUTH_CLIENT_ID=$MICROSOFT_OAUTH_CLIENT_ID") fi - flutter build linux "${args[@]}" + flutter build linux -t lib/main_linux.dart "${args[@]}" - name: Build strict Snap from the release bundle id: snapcraft diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml new file mode 100644 index 00000000..11487931 --- /dev/null +++ b/.github/workflows/flutter-windows.yml @@ -0,0 +1,66 @@ +name: Flutter Windows + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: '3.44.4' + cache: true + + - name: Enable Windows desktop + shell: pwsh + run: flutter config --enable-windows-desktop + + - name: Create explicitly non-production CI identity + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path build/windows | Out-Null + @{ + production = $false + identityName = 'BusyStack.BusyMax.CI' + publisher = 'CN=BusyMax CI Package' + publisherDisplayName = 'BusyMax CI' + msixVersion = '1.0.0.0' + previousMsixVersion = '' + privacyPolicyUrl = 'https://busystack.org/privacy' + supportUrl = 'https://busystack.org/support' + homepageUrl = 'https://busystack.org' + googleOAuthClientId = 'busymax-ci-google-client-id' + googleOAuthClientSecret = '' + microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' + microsoftOAuthAuthorityTenant = 'common' + fakeData = $false + developmentBackend = $false + } | ConvertTo-Json | Set-Content ` + -LiteralPath build/windows/store-ci.json -Encoding utf8NoBOM + + - name: Validate, compile, and package Windows x64 + shell: pwsh + run: >- + ./tools/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci + + - name: Upload unsigned CI MSIX and test reports + uses: actions/upload-artifact@v7 + with: + name: busymax-windows-x64-ci + path: | + build/windows/store/*.msix + build/windows/store/staging/AppxManifest.xml + build/windows/test-results/** + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 3228ca41..0b4f83d0 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,8 @@ app.*.map.json # Local Snap packages contain compiled build-time configuration. *.snap **/*.snap + +# Local Windows Store identity, certificates, and signing material. +config/windows_store.local.json +*.pfx +*.cer diff --git a/.metadata b/.metadata index a30de0e2..da084209 100644 --- a/.metadata +++ b/.metadata @@ -18,6 +18,9 @@ migration: - platform: linux create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + - platform: windows + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 # User provided section diff --git a/README.md b/README.md index 14ef73af..c9f6bb39 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # BusyMax -BusyMax is a Linux desktop calendar and task manager built with Flutter. +BusyMax is a Linux and Windows desktop calendar and task manager built with +Flutter. -It brings calendar events and tasks into a native-feeling Linux desktop -interface. BusyMax connects directly to Google, Microsoft, Apple iCloud -Calendar, and Nextcloud. Apple Reminders is not supported. +It brings calendar events and tasks into a native-feeling desktop interface: +Yaru with the existing GTK integrations on Linux, and Fluent UI with the native +title bar on Windows 11. BusyMax connects directly to Google, Microsoft, Apple +iCloud Calendar, and Nextcloud. Apple Reminders is not supported. [![busymax](https://snapcraft.io/busymax/badge.svg)](https://snapcraft.io/busymax) @@ -20,7 +22,7 @@ Calendar, and Nextcloud. Apple Reminders is not supported. ## Highlights -- Linux desktop app built with Flutter. +- First-class Linux Yaru and Windows 11 Fluent desktop applications. - Calendar views for day, week, month, year, and agenda planning. - Task creation with lists, start/due dates, reminders, repeat options, subtasks, status, progress, priority, categories, location, and URL. @@ -88,6 +90,7 @@ Calendar, and Nextcloud. Apple Reminders is not supported. ## Prerequisites - [Flutter SDK](https://docs.flutter.dev/install) +- Flutter is pinned to 3.44.4 for both platforms. - GTK 3 and libhandy development packages (`libgtk-3-dev` and `libhandy-1-dev` on Ubuntu/Debian) - `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`; see @@ -101,7 +104,12 @@ Apple and Nextcloud do not require compile-time client credentials: - [Nextcloud setup](docs/nextcloud_setup.md) requires an HTTPS server and completes authorization in the default browser. -## Run locally +Windows targets Windows 11 24H2 or newer on x64 only. See +[Windows development](docs/windows_development.md) for Visual Studio and SDK +requirements, exact development commands, and the source-side validation +workflow. + +## Run locally on Linux Register the development launcher once so GNOME can associate BusyMax's native Wayland windows with its desktop icon. The helper is idempotent and defaults to @@ -114,7 +122,7 @@ tools/install_linux_dev_desktop.sh Then run BusyMax normally: ```bash -flutter run -d linux \ +flutter run -d linux -t lib/main_linux.dart \ --dart-define=GOOGLE_OAUTH_CLIENT_ID= \ --dart-define=GOOGLE_OAUTH_CLIENT_SECRET= \ --dart-define=MICROSOFT_OAUTH_CLIENT_ID= @@ -129,7 +137,7 @@ does not take precedence; packaged Snaps register their own launcher. The native **Send feedback** form in the About dialog sends JSON to `POST https://busystack.org/api/feedback`. Every submission contains a new submission UUID, the application identifier `busymax`, the application version -and build number, the `linux` platform identifier, category, subject, message, +and build number, the runtime platform identifier, category, subject, message, and optional reply email. A successful response has this form: ```json @@ -137,9 +145,9 @@ and optional reply email. A successful response has this form: ``` The optional technical-details checkbox is off by default. When the user -explicitly enables it, BusyMax adds only the Linux operating-system version and +explicitly enables it, BusyMax adds only the operating-system version and application locale. BusyMax does not attach logs, account or calendar data, -file names, screenshots, environment variables, or other diagnostics. +file names, screenshots, environment variables, tokens, or other diagnostics. For local website development, override the endpoint through the existing compile-time configuration mechanism: @@ -166,3 +174,16 @@ and iCloud integration tests. See [Snap Build and Beta Release](docs/beta_snap_release.md) for OAuth build configuration, canonical Snapcraft packaging, local installation, artifact verification, Store review, and beta release instructions. + +## Windows architecture and packaging + +Windows uses the separate `lib/main_windows.dart` entrypoint and a Fluent UI +composition. Linux continues to use `lib/main_linux.dart`, Yaru, GTK, the XDG +tray, and the existing Snap package. The Windows Store MSIX workflow is local +and CI-only; it never deploys an artifact. + +- [Windows architecture](docs/windows_architecture.md) +- [Windows UI](docs/windows_ui.md) +- [Windows packaging](docs/windows_packaging.md) +- [Windows release checklist](docs/windows_release_checklist.md) +- [Privacy and data map](docs/privacy_data_map.md) diff --git a/assets/windows/busymax_tray.ico b/assets/windows/busymax_tray.ico new file mode 100644 index 0000000000000000000000000000000000000000..0dc7487a71414d99f48c243f253c4c30ec469132 GIT binary patch literal 97283 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 EB$0SKkM=sT-b=_Za+iqVP zYn3En#-T?ZB2 z&u7usN}r?95_L+9z$0TdE6aHWU5at8U@LD$bZNcu6xd)>s};3sJ)XSf-{A2&d0RXxv;$xyhzQjEah-b zuk5HGGFRqnWFn3-^Yt4>LQQ@cskJ6l?#Zr+%MD4`V2dj=a(4m4ZK&&-+u;d*_IGXV zj-w066>j8wk~l)g>G(#SaE#cgME!XpuE-AG-yqfOIax*{Nt|Y58qLLiU7xRQyllZj zNF+^iO?6;IvQOrQZnxpHNN@XSYggf5MoMwm0>LE45SQIYf3v#3d7CR9>3R&It(cF} zYEB!Q>?q%y)$l4gaE!r^9>K;me^bk>D758@-r&6DsU6;9qf!y`_2>5F&M(>_MaD%5 z)zWQlPO&CP3p4&bqv+TzbxRuE5N&EGFl{&vXtZ6vFgW~U!7+Rp`Ac~q?To_?S3Bf; zPqFW={Q<+$qj@le6eP5eUrRLg%k^r-rTREo{?1DSFmRwA0$VF z$^^emC?WqffQdu=>EAayi6PBqGW^O32a&+8rda54UCO^=7FT)q$AjPo-E{8`U%lv> ze*Tnyo`nfuPfq9RzC){5?jPJfK5xK%9=#T5meJhwQ)y~Zf1|SzND}u}<||*(#H)R7 zh~aHt>6l((JQ@=*Q5H}~5VZ#6r&5f7msfp^Rf0rt@5bFW*EKpm*7Tf>=G3}(J9%lJQL;R+z$8@WYV{yWra|zV9TAeoa&rfEh4m% zLM?Man#*Sgq(a+)y%j&kj#`g-WO~zzx)k)P?NwFE)r&DWsN!WjsUt%mV26FqS`6Az zxU{v4iIrKH-R0ix05`FpbGkS7yH%7&obi?LGJ4V{9D$rRV%{n~R=weg+2}G#hU65o zn!WB`^iW0@5W(^iErv<)Fys2CAw6J&ctE)w!976+*~|Ne?QO#H;(RFY!pYXM_pi=pjIXBHNs+TKlL!Q>AXSshmAd&1z*!ZEITy`U_uTp$612cnYR|F z4R75?F+#(|1HLLJZZ$Ce40q7}wk#p$fkVP-y%Z;~CfJNMtl{r)GO-8AemkLY0f zAB06rZz`P(sP<=QsDO(jV|*EPE6z*(Y@@L;STCMIJ|IKrhVd*jzpbd`QNAcqVT_5$ zNooKt^UfM6r}H^@1U>>*Z1B+J%@P6qD(^;>vv{047R;4XRGc`JCrt`cLb9=mFVjEU zu211WcjB5=DE`V}35*KpNSR*`cLCBw_(!EU+rEgS^VYD#>hJEmZP^%wt&J&8`co6) z@0WOVnyf5g0(NY3Bf$x6VRi2k&2;KZB$WdA!l1uxVC^F9&(>S|k6)dRa>fqf#?R=a zkpr{*lyQ4TjoXm~F*Q`#1-!lQInP=W)yM3KuFZ(Uf@$BfpJj_(a(dqzUmhO0A}BIc z-=ZtA2t^^jcsKIMXnF})$*}2(keC9y#tN6P&AY|lS-!GS^$K⩔4Yg(;(j_Atjfo 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 CdEik1 literal 0 HcmV?d00001 diff --git a/assets/windows/busymax_tray_offline.ico b/assets/windows/busymax_tray_offline.ico new file mode 100644 index 0000000000000000000000000000000000000000..a35e35c8abd8d380d59fdbf7c3704a6be75b52f4 GIT binary patch literal 72422 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 a?EkeQ?%1Dq?9cyQ_NUISHtqlQHv3-+Y}2*? literal 0 HcmV?d00001 diff --git a/config/windows_store.example.json b/config/windows_store.example.json new file mode 100644 index 00000000..a83ee243 --- /dev/null +++ b/config/windows_store.example.json @@ -0,0 +1,17 @@ +{ + "production": true, + "identityName": "REPLACE_WITH_PARTNER_CENTER_IDENTITY_NAME", + "publisher": "CN=REPLACE_WITH_PARTNER_CENTER_PUBLISHER", + "publisherDisplayName": "REPLACE_WITH_PUBLISHER_DISPLAY_NAME", + "msixVersion": "REPLACE_WITH_FOUR_PART_VERSION", + "previousMsixVersion": "", + "privacyPolicyUrl": "https://REPLACE_WITH_PRIVACY_POLICY_URL", + "supportUrl": "https://REPLACE_WITH_SUPPORT_URL", + "homepageUrl": "https://busystack.org", + "googleOAuthClientId": "REPLACE_WITH_GOOGLE_DESKTOP_CLIENT_ID", + "googleOAuthClientSecret": "REPLACE_WITH_GOOGLE_DESKTOP_CLIENT_SECRET", + "microsoftOAuthClientId": "REPLACE_WITH_MICROSOFT_PUBLIC_CLIENT_ID", + "microsoftOAuthAuthorityTenant": "common", + "fakeData": false, + "developmentBackend": false +} diff --git a/docs/privacy_data_map.md b/docs/privacy_data_map.md new file mode 100644 index 00000000..4a515d6d --- /dev/null +++ b/docs/privacy_data_map.md @@ -0,0 +1,36 @@ +# Privacy and data map + +BusyMax processes calendar, event, task, account, reminder, and synchronization +data to provide its user-requested desktop functions. It does not add telemetry +or analytics. + +| Data | Purpose | Local handling | Network destination | +|---|---|---|---| +| OAuth access/refresh tokens and DAV app passwords | Authenticate selected accounts | Windows secure-storage backend or Linux portal/keyring backend; removed on sign-out/account removal as applicable | Google, Microsoft, Apple iCloud, or the user's Nextcloud server over HTTPS | +| OAuth authorization code, PKCE verifier, and state | Complete browser/loopback sign-in | Short-lived in memory; callback binds only to loopback; never logged | Selected provider token endpoint | +| Calendar/event/task/source identifiers and content | Display, edit, synchronize, recur, import/export, and work offline | Drift database in the user-writable application-support directory | Only the account provider selected by the user | +| Reminder schedule rows | Deliver in-process reminders and make actions idempotent | Drift; stopped by explicit Quit | No new scheduling service; Windows toast receives stable opaque IDs only | +| `.ics` files and export destinations | User-requested import review/export | Read or written only through explicit activation/file selection; external activation never silently imports | None unless the user later synchronizes confirmed content | +| WebCal URI | User-requested calendar subscription | Validated and confirmed before subscription | The confirmed HTTPS/WebCal calendar endpoint | +| Local settings | Theme, locale, notification, tray, and startup preferences | User-writable settings storage | None | +| Feedback category, subject, message, optional reply email, random submission ID, app/version/build/platform | Submit feedback requested by the user | Prepared only for an explicit submission | Configured BusyStack feedback HTTPS endpoint | +| Optional feedback technical details | Diagnose explicitly reported issues | OS version and application locale only; checkbox is off by default | Same feedback endpoint when the user opts in | + +Feedback never silently attaches logs, account/provider content, calendar/task +content, filenames, screenshots, environment variables, stable device IDs, +tokens, secrets, or activation payloads. + +Windows package declarations are limited to internet client and full-trust +desktop execution needed by Flutter Win32. BusyMax does not request location, +microphone, webcam, contacts, broad-filesystem, or private-network capability. +File import/export uses the selected file or manifest-declared `.ics` +association. Startup uses the user-controlled package StartupTask. + +The Windows privacy-policy and support URLs are build inputs. Production +validation rejects missing, placeholder, or non-HTTPS values. App disclosures +and the external privacy page must be reviewed whenever provider calls, +feedback fields, storage behavior, or package capabilities change. + +Uninstall and package-update retention behavior must be verified with the +installed MSIX because Windows owns package data lifecycle. BusyMax never writes +the database or credentials into the immutable MSIX installation directory. diff --git a/docs/windows_architecture.md b/docs/windows_architecture.md new file mode 100644 index 00000000..96e29876 --- /dev/null +++ b/docs/windows_architecture.md @@ -0,0 +1,119 @@ +# Windows architecture + +BusyMax has one domain and data model with two desktop compositions. + +```text +domain / providers / repositories / Drift / sync / OAuth / recurrence + | + common desktop interfaces + / \ + Linux composition Windows composition + MaterialApp.router FluentApp.router + Yaru + GTK/DBus/XDG Fluent + Win32/MSIX +``` + +`lib/main.dart` delegates to `lib/main_linux.dart` to preserve the existing +Linux default. Windows build commands explicitly select `lib/main_windows.dart`. +`LinuxBusyMaxApp` retains Material/Yaru, Ubuntu localization delegates, and the +GTK header-bar integration. `WindowsBusyMaxApp` owns the Fluent root and does +not import the Linux composition. + +## Dependency boundaries + +Common application, domain, repository, synchronization, OAuth, recurrence, +database, and scheduling code can depend on Flutter primitives and BusyMax +types. It cannot import Yaru, Fluent UI, Ubuntu/GTK/DBus/XDG implementations, or +Windows runner adapters. Platform code translates native/package types into +BusyMax-owned models at the boundary. + +The injected desktop interfaces are: + +- `DesktopWindowService` +- `DesktopTrayService` +- `DesktopAutostartService` +- `DesktopNotificationBackend` +- `DesktopActivationService` +- `SystemAppearanceSource` +- `LocalTimeZoneSource` + +The automated policy is implemented by `tool/check_platform_boundaries.dart` +and runs in Linux and Windows CI. + +`tray_manager` 0.5.3 and `flutter_timezone` 5.1.0 are pinned through committed +Windows-only packaging of their upstream plugins. This prevents Flutter's +generated Linux plugin graph from registering a second AppIndicator tray or +replacing the existing Linux timezone path. The boundary check requires both +plugins only in the Windows graph. Their Dart APIs, Windows sources, versions, +and upstream licenses are preserved. + +## Window and single instance + +The Win32 runner uses the standard Windows title bar, caption buttons, system +menu, maximize/restore behavior, and per-monitor-v2 DPI awareness. Flutter +content begins below the non-client title bar. Minimum tracking size is +900x600 logical pixels and the initial content size is 1280x800. + +One process per Windows user owns a named mutex containing that user's SID. +Later processes validate a bounded activation, connect to a local named pipe +whose DACL grants only the current SID, forward a length-prefixed UTF-8 message, +and wait for a one-byte acceptance acknowledgment before exiting. Connection +retries cover the bounded interval between the primary acquiring its mutex and +creating its first pipe instance. The secondary allows the primary process to +take foreground focus and never starts Dart or Drift. The primary acknowledges +only after it validates and queues the activation until the method channel is +ready. No second package owns window lifecycle. + +The native single-instance IPC accepts only exact runner-generated normal, +`--start-minimized`, `.ics`, `webcal:`, and bounded notification JSON shapes. It +rejects unsupported kinds, actions, schemes, files, fields, malformed +UTF-8/JSON, NULs, and oversized data; Dart independently validates the common +activation model again. + +Toast callbacks normally arrive through the notification plugin's registered +COM activator in the primary process. If Windows starts an activation-server +process while the primary process already owns the mutex, the runner starts a +private, hidden forwarding mode instead of the application composition. That +mode initializes only the notification callback, starts no providers or Drift +database, forwards the validated stable-ID activation through the same +current-user pipe, and exits. This preserves notification actions without ever +letting a second process open BusyMax data. + +## Lifecycle and tray + +The Windows tray adapter uses `tray_manager`; Linux retains its DBus/XDG +adapter. Shared presentation state supplies Open BusyMax, new event, new task, +today/agenda, synchronize, Settings, and Quit. Close hides only when the setting +is enabled and the tray is available. A failed tray causes the sole window to +remain visible. The icon and menu are periodically reapplied so Explorer's +notification-area restart can recover. + +Explicit Quit stops reminder and synchronization schedulers, destroys the tray, +disposes the Windows notification backend, closes Drift, and finally requests +native termination. Closing without background mode exits through the normal +runner lifecycle. + +## Activation routes + +Manifest `.ics` and `webcal` activations enter the same single-instance bridge. +An `.ics` file always opens the existing review and confirmation flow; it is +never imported silently. A `webcal:` URI opens the existing subscription +confirmation. Cold and warm delivery use the same common activation model. + +Windows toast activation uses `flutter_local_notifications` with stable AUMID +and committed CLSID `7B854A6D-8B2A-45A5-B998-1F51EC5A81D7`. Toast arguments +contain only stable internal IDs. Body, Open, Snooze, and Dismiss callbacks are +routed to the idempotent scheduler action handler; event and task text is not +placed in activation arguments. + +## Startup and timezone + +The MSIX declares one disabled-by-default `BusyMaxStartupTask`. The runner uses +the packaged StartupTask API and preserves enabled, disabled, user-disabled, +policy-disabled, and unpackaged states. There is no registry or Startup-folder +fallback. + +`flutter_timezone` supplies the Windows system IANA zone. Recognized IANA values +are used directly; a deterministic Windows-ID mapping covers defensive fallback +cases. An unrecognized or unavailable result records a non-sensitive diagnostic +and falls back to `Etc/UTC`, never an assumed local zone. Timezone search uses +BusyMax's generic IANA catalog, not GWeather. diff --git a/docs/windows_development.md b/docs/windows_development.md new file mode 100644 index 00000000..960b7c2a --- /dev/null +++ b/docs/windows_development.md @@ -0,0 +1,107 @@ +# Windows development + +BusyMax supports Windows 11 24H2 (`10.0.26100.0`) or newer on x64. Windows 10, +x86, ARM64, MSI, and EXE installers are intentionally outside the target. +Flutter and Dart must come from the repository's pinned Flutter 3.44.4 SDK. + +## Prerequisites + +- 64-bit Windows 11 24H2 or newer. +- Flutter 3.44.4 with Windows desktop enabled. +- Visual Studio 2022 with **Desktop development with C++**, including the x64 + MSVC toolchain and CMake tools. +- Windows SDK `10.0.26100.0` or newer. +- Developer Mode for unpackaged development, or a local test certificate for + installed MSIX testing. + +Check the toolchain from a PowerShell prompt at the repository root: + +```powershell +flutter config --enable-windows-desktop +.\tools\windows\check_prerequisites.ps1 +``` + +## Entry points + +Entrypoints are always explicit. The Windows application must never be built +from the Linux default entrypoint. + +```powershell +flutter pub get +flutter gen-l10n +dart run build_runner build --delete-conflicting-outputs --force-jit +flutter run -d windows -t lib/main_windows.dart ` + --dart-define=BUSYMAX_WINDOWS_AUMID=BusyStack.BusyMax.Development ` + --dart-define=GOOGLE_OAUTH_CLIENT_ID= ` + --dart-define=MICROSOFT_OAUTH_CLIENT_ID= +``` + +An unpackaged build accurately reports Windows StartupTask as unavailable. It +does not create a registry or Startup-folder fallback. Notification retrieval +and cancellation also require installed package identity; display and live +callbacks can still be exercised unpackaged. + +Linux continues to use: + +```bash +flutter run -d linux -t lib/main_linux.dart +flutter build linux --release -t lib/main_linux.dart +``` + +## Source validation + +Run these before requesting Windows CI: + +```powershell +flutter gen-l10n +dart run build_runner build --delete-conflicting-outputs --force-jit +dart format --set-exit-if-changed . +flutter analyze +flutter test +dart run tool/check_platform_boundaries.dart +``` + +The boundary checker fails if Windows/common code reaches Yaru, Ubuntu +localizations, DBus, freedesktop notifications, XDG tray, or GTK services, or +if business code reaches Fluent UI. + +## Baseline recorded before the port + +The original Linux tree was checked with Flutter 3.44.4 using `flutter pub +get`, `flutter gen-l10n`, `dart run build_runner build +--delete-conflicting-outputs --force-jit`, the formatting check, `flutter +analyze`, `flutter test`, and `flutter build linux --release`. Dependency +resolution, generation, formatting, analysis, and the Linux release build +succeeded. The test run recorded 1,508 passing tests, 10 skipped tests, and five +pre-existing failures: one Nextcloud recurrence parse/serialize case and four +account-add routing widget cases. This is a baseline record, not a waiver for +CI; the release workflows require a fully passing current test suite. + +The Drift schema version was 13 and remains 13. Existing migrations, the Linux +Snap configuration, and the Linux workflow remain in place. + +## Current source-side validation + +On 2026-08-31, the post-port working tree was validated on Linux with Flutter +3.44.4 and Dart 3.12.2: + +| Command | Result | +| --- | --- | +| `flutter gen-l10n` | Passed; every supported catalog generated. | +| `dart run build_runner build --delete-conflicting-outputs` | Passed; generated Drift content remained consistent and the schema version remained 13. The pinned build runner reported that the legacy delete-conflicting option is ignored. | +| `dart format --output=none --set-exit-if-changed .` | Passed; 452 files checked, zero changes required. | +| `flutter analyze` | Passed; no issues found. | +| `dart run tool/check_platform_boundaries.dart` | Passed. | +| `flutter test --reporter compact` | Passed; 1,540 tests passed, 10 skipped, zero failed. | +| `flutter build linux --release -t lib/main_linux.dart` | Passed; produced `build/linux/x64/release/bundle/busymax`. | + +These results establish Linux and platform-neutral source health only. They do +not replace the Windows gates below, a Windows CI result, or installed-package +testing. + +## Windows-only verification + +Compilation, native bridge validation, installed-package activation, package +identity notifications, StartupTask, screenshots, and WACK cannot be certified +from Linux. Run the checklist in [windows_release_checklist.md](windows_release_checklist.md) +on a clean Windows 11 host and retain the resulting artifacts. diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md new file mode 100644 index 00000000..1cb19754 --- /dev/null +++ b/docs/windows_packaging.md @@ -0,0 +1,120 @@ +# Windows MSIX packaging + +BusyMax produces an x64 Microsoft Store-mode MSIX. The scripts never upload or +deploy it. The production artifact is unsigned locally because Microsoft Store +signing is separate from local test signing. + +## Identity configuration + +Copy `config/windows_store.example.json` to the ignored path +`config/windows_store.local.json` and replace every placeholder with values the +owner obtained for this product: + +- package identity name; +- Publisher CN; +- publisher display name; +- four-part Store package version; +- previous package version when monotonic comparison is wanted; +- privacy-policy, support, and homepage HTTPS URLs; +- Google desktop OAuth client ID/configuration; +- Microsoft public client ID and authority tenant; + +Do not commit the local file. Identity values are not secrets, but they must +match the final product registration exactly. OAuth tokens, authorization +codes, certificates, PFX files, and passwords must never enter configuration or +the package. + +The product version remains in `pubspec.yaml`. `msixVersion` is independent and +must have four numeric components, a first component of at least 1, a fourth +component of 0, and components no greater than 65535. If a previous version is +provided, the new value must be greater. CI deliberately uses non-production +`1.0.0.0`; it is not an owner production version. + +## Build + +From a 64-bit Windows 11 PowerShell prompt: + +```powershell +.\tools\windows\build_release.ps1 ` + -ConfigPath config\windows_store.local.json +``` + +The script checks Windows, Flutter 3.44.4, Visual Studio x64 C++ tools, and a +Windows 11 SDK; resolves dependencies; generates localization and Drift code; +checks committed generation, formatting, analysis, tests, architecture +boundaries, and release inputs; builds `lib/main_windows.dart`; stages release +runtime files; renders and validates the manifest; packs the MSIX; and prints +its path, metadata, and SHA-256. It performs no network deployment. + +`MaxVersionTested` is taken from the Windows SDK selected by the prerequisite +check, so the manifest cannot claim a newer SDK than the release builder used. + +The manifest is rendered reproducibly from +`tools/windows/AppxManifest.xml.template`. It declares x64, minimum OS +`10.0.26100.0`, only internet client and full-trust desktop capabilities, `.ics`, +`webcal`, StartupTask, toast activation, resource languages derived from ARB +catalogs, and BusyMax visual assets. It contains no location, microphone, +webcam, contacts, broad-filesystem, or private-network capability. + +`msix` remains pinned as the repository's Store packaging tool dependency, but +its generated configuration does not express BusyMax's complete combination of +StartupTask parameters, full-trust toast COM activation, and resource-derived +languages. The release script therefore follows the documented deterministic +fallback: stage the Flutter release, render the committed manifest template, +validate it, and invoke the selected Windows SDK's `MakeAppx`. No generated +output is hand-edited. + +Runner, tray, file-association, and package logo variants are derived from the +existing `assets/branding/busymax-logo.png`; no separate product mark is used. +After changing that source branding asset, regenerate and review all committed +Windows assets with: + +```powershell +dart run tool/generate_windows_assets.dart +``` + +The startup extension passes `--start-minimized` through its schema-valid +`uap10:Parameters` attribute. The nested `desktop:StartupTask` remains disabled +by default; that element does not define an arguments attribute. + +## Local test signing and installation + +For installed-package testing only: + +```powershell +.\tools\windows\create_test_certificate.ps1 ` + -ConfigPath config\windows_store.local.json +.\tools\windows\install_test_package.ps1 ` + -ConfigPath config\windows_store.local.json ` + -PackagePath build\windows\store\BusyMax--x64.msix ` + -PfxPath build\windows\test-signing\busymax-test-only.pfx +``` + +The password is prompted, never stored. PFX/CER files and the signed copy stay +under ignored build output. The installer prints a package-specific uninstall +command for cleanup. The unsigned Store-mode artifact must not depend on this +development certificate. + +## Validation + +The staging step includes only the whitelisted, version-matched x64 Visual C++ +redistributable DLLs from the selected Visual Studio installation, so a clean +machine does not need developer tools or a preinstalled VC runtime. + +`validate_manifest.ps1` enforces architecture, OS target, extensions, +stable application/executable identity, StartupTask defaults, the exact `.ics` +and `webcal` registration set, capabilities, and placeholder removal. +`validate_package_contents.ps1` rejects source, PFX/CER, logs, databases, test +data, fake data, debug symbols, Linux/Snap content, extra executables, and +template branding while checking required Flutter assets and DLLs. + +After installing a release-equivalent locally signed package, run: + +```powershell +.\tools\windows\run_wack.ps1 ` + -PackagePath build\windows\store\BusyMax-test-signed.msix +``` + +Retain the XML report. Any native runner, dependency, manifest, or packaging +change invalidates an earlier WACK result and requires a repeat. See the +[release checklist](windows_release_checklist.md) for behavioral coverage. diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md new file mode 100644 index 00000000..d38a9e86 --- /dev/null +++ b/docs/windows_release_checklist.md @@ -0,0 +1,99 @@ +# Windows release checklist + +This checklist is evidence-driven. Source review or a Linux build cannot mark +the Windows package ready. Record host OS/build, display setup, package version, +MSIX filename/SHA-256, commands, results, screenshots, and WACK report beside +the release candidate. + +## Automated gates + +- [ ] Clean checkout uses Flutter 3.44.4. +- [ ] Localization and build-runner generation are committed and reproducible. +- [ ] Formatting, analyzer, unit tests, widget tests, and platform-boundary + checks pass on Linux and `windows-latest`. +- [ ] Linux release builds explicitly from `lib/main_linux.dart`; Snap checks + remain green and Yaru/GTK behavior is unchanged. +- [ ] Windows x64 release builds explicitly from `lib/main_windows.dart`. +- [ ] CI creates only the explicitly non-production unsigned test MSIX and + uploads it and test reports as CI artifacts; no deployment job exists. +- [ ] Manifest and package-content validators pass. + +## Installed package lifecycle + +- [ ] Ordinary start, offline start, and `--start-minimized`. +- [ ] Close-to-tray enabled/disabled, explicit Quit, reopen and restore. +- [ ] Second launch cannot open Drift and restores the primary instance. +- [ ] Explorer restart recovers the tray; tray failure leaves a visible window. +- [ ] Sign-out/restart and launch-at-login behavior. +- [ ] StartupTask disabled by default, user enable/disable, start minimized, + user-disabled, policy-disabled, and unpackaged states. + +## Activation and notifications + +- [ ] Cold, warm, hidden-window, and initialization-time `.ics` activation opens + review/confirmation without silent import. +- [ ] Cold/warm/hidden `webcal:` enters subscription confirmation. +- [ ] Malformed URI/JSON/UTF-8, unsupported file/scheme, oversized input, and + concurrent activations are safely rejected or serialized. +- [ ] Notification display, body click, Open, Snooze, Dismiss, duplicate action, + cancellation, warm/cold activation, hidden window, and package-identity + behavior pass. +- [ ] Activation arguments contain stable internal IDs only and no title, body, + account email, token, code, or secret. + +## Accounts, storage, and files + +- [ ] Google and Microsoft browser/loopback OAuth pass unpackaged, installed + test-signed, and final Store-mode configurations, including cancellation, + timeout, PKCE/state validation, and restored focus. +- [ ] Secure credentials persist after restart and are removed on sign-out and + account removal; no plaintext preference/log/payload contains a token. +- [ ] Existing Drift schema/migrations, SQLite DLLs, restart, package update, + backup/recovery, Unicode paths, and long paths pass. +- [ ] `.ics` import/export, overwrite, cancellation, read-only destination, + Unicode/long filename, malformed iCalendar, and WebCal pass without broad + filesystem capability. +- [ ] Recurrence and timezone behavior covers UTC, standard/DST, skipped and + ambiguous local time, non-hour offsets, and a system-zone change followed + by restart. + +## UI and accessibility + +- [ ] Every existing screen has Fluent presentation; no Windows-visible Yaru, + Ubuntu, Material shell control, or Flutter template icon remains. +- [ ] Native title bar, caption controls, snap/maximize/restore/system menu. +- [ ] Light, dark, high contrast, reduced motion, system accent changes, text + scaling, keyboard-only order/focus/tooltips, and basic Narrator. +- [ ] 100%, 125%, 150%, and 200%; 1280x800 and 1920x1080; mixed-DPI monitors. +- [ ] Arabic and Persian RTL through navigation, dialogs, and editors; all + other supported locales open without English Windows placeholders. +- [ ] Real installed-MSIX light/dark screenshots captured at both target sizes, + including at least one 125% or 150% run. +- [ ] Windows 11 24H2 and 25H2 both exercised. + +## Final package and WACK + +- [ ] Production configuration has exact owner identity/OAuth values, valid + HTTPS privacy/support URLs, fake data off, and no development backend. +- [ ] Product version and Store package version are displayed distinctly. +- [ ] MSIX is x64, minimum `10.0.26100.0`, fourth version segment 0, expected + capabilities/extensions/languages/assets only, and newer than the prior + supplied version. +- [ ] Manual content review finds no certificate/PFX, secret/token, logs, + database, source, debug symbols, test data, Linux content, unreferenced + assets, or missing Flutter/plugin/SQLite runtime DLL. +- [ ] Clean Windows test environment without build tools installs and runs. +- [ ] WACK finishes with zero failures; every warning is investigated and its + report is retained. Repeat after package-affecting changes. + +## Release record + +- [ ] Architecture/dependency summary. +- [ ] Exact commands and results for Linux, Windows, CI, installed MSIX, and + WACK. +- [ ] CI run links/artifact identifiers. +- [ ] Windows screenshots. +- [ ] MSIX filename, version, architecture, minimum OS, and SHA-256. +- [ ] WACK report path/result. +- [ ] Exact remaining external owner values, if any. +- [ ] Confirmation that no package was uploaded or deployed. diff --git a/docs/windows_ui.md b/docs/windows_ui.md new file mode 100644 index 00000000..996be80f --- /dev/null +++ b/docs/windows_ui.md @@ -0,0 +1,68 @@ +# Windows UI + +Windows uses `FluentApp.router` and Fluent controls throughout the application +shell. It is a separate presentation target, not the Linux Yaru widget tree in +a Windows runner. + +## Shell and destinations + +`NavigationView` maps only the existing Schedule, Tasks, and Settings areas. +Settings is a footer destination. The source/calendar/task-list sidebar remains +a content-specific pane and collapses at BusyMax-owned breakpoints so narrow +windows do not show two permanently expanded sidebars. + +The Windows presentation covers sign-in and account addition; schedule day, +week, month, year, and agenda views; source visibility; task lists/details; +calendar and task-list creation/rename/removal and reminder/color actions; +event/task editors; recurrence; reminders; date/time and timezone inputs; +calendar import and WebCal subscription; settings; account removal; +diagnostics; feedback; About; third-party licenses; and keyboard shortcuts. The +view/controller and provider logic remains shared. + +Existing-task editing is capability-aware. Microsoft and DAV start, due, +reminder, recurrence, category/importance and iCalendar fields are patched +through `TaskDetailsDraft`; unchanged all-day values, unsupported recurrence, +and multi-alarm data are preserved instead of being normalized by merely +opening and saving the dialog. The Fluent task dialog also exposes supported +cross-list moves, hierarchy and checklist mutations, reparenting, DAV +duplication, and native iCalendar export. Event reminders retain multiple +Google/DAV values while Microsoft remains limited to its single provider +reminder. New tasks expose explicit all-day/timed scheduling and retain every +DAV alarm. Event and task editors confirm before discarding unsaved edits, +including Escape/back and hierarchy navigation. + +BusyMax presentation contracts use semantic glyphs such as calendar, task, +add, delete, synchronize, reminder, and diagnostics. The Windows mapper uses +Microsoft Fluent System Icons. Feature code does not request `YaruIcons` or +Material `Icons` for Windows chrome. + +## Theme and accessibility + +- System, light, and dark modes follow the persisted BusyMax preference. +- Accent color follows the Windows system accent. +- High contrast uses opaque black/white Fluent surfaces. +- Reduced-motion preference zeroes Fluent animation durations. +- Segoe UI Variable is requested and Windows supplies its own fallback; no + Microsoft font is bundled. +- Native text scaling, keyboard traversal, focus indicators, tooltips, + screen-reader semantics, and RTL direction flow through Flutter/Fluent. +- The supported Windows validation matrix is 100%, 125%, 150%, and 200% display + scaling, mixed-DPI monitors, 1280x800 and 1920x1080, and Arabic/Persian RTL. + +The native title bar remains untouched. There are no Flutter-drawn minimize, +maximize, restore, or close controls and Mica is not a release dependency. + +## Compatibility subtrees + +Material controls are not used as Windows application chrome. If a custom or +third-party calendar subtree requires Material internals, compatibility must be +narrowly scoped to that subtree and inherit the Fluent brightness, color, +typography, locale, and direction. Material surfaces must not escape into the +shell. Ubuntu localization delegates remain Linux-only. + +## Visual verification + +Widget tests are not substitutes for native rendering. The release checklist +requires real installed-MSIX screenshots of major screens in light and dark at +1280x800 and 1920x1080, including a 125% or 150% run. Capture RTL, high-contrast, +focus, and mixed-DPI observations in the same Windows test record. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 88b88ab9..4bbc073c 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,5 +1,16 @@ { "@@locale": "ar", + "windowsSupport": "الدعم", + "windowsThirdPartyLicenses": "تراخيص الجهات الخارجية", + "windowsSearch": "بحث", + "windowsStartupDisabledByUser": "عطّل المستخدم هذه الميزة في إعدادات Windows.", + "windowsStartupDisabledByPolicy": "معطّلة بواسطة نهج Windows.", + "windowsStartupUnavailable": "تتوفر بعد تثبيت BusyMax من حزمة MSIX.", + "windowsReminderExitNotice": "تتوقف التذكيرات عند إنهاء BusyMax بالكامل. أبقِه قيد التشغيل في الخلفية لتلقيها.", + "windowsProductVersionLabel": "إصدار المنتج", + "windowsPackageVersionLabel": "إصدار حزمة Windows", + "windowsUnpackaged": "غير معبأ", + "windowsAgendaLoadMore": "تحميل المزيد من عناصر جدول الأعمال", "repeatWeeklyDaySummary": "{dayKey, select, MO{الاثنين} TU{الثلاثاء} WE{الأربعاء} TH{الخميس} FR{الجمعة} SA{السبت} SU{الأحد} other{{day}}}", "repeatOnTwoMonthDaysSummary": "في يومي {first} و{second} من الشهر", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} في يومي {firstDay} و{secondDay} من {month}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "رسالة مفصّلة", "feedbackReplyEmail": "البريد الإلكتروني للرد (اختياري)", "feedbackIncludeTechnicalDetails": "تضمين التفاصيل التقنية", - "feedbackTechnicalDetailsDisclosure": "يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.", + "feedbackTechnicalDetailsDisclosure": "يضيف فقط اسم نظام التشغيل وإصداره ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.", "feedbackCategoryRequired": "اختر فئة.", "feedbackSubjectLengthError": "يجب أن يتراوح الموضوع بين 3 و120 حرفًا.", "feedbackMessageLengthError": "يجب أن تتراوح الرسالة بين 10 و5,000 حرف.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9dd965b2..286fd00c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +1,16 @@ { "@@locale": "de", + "windowsSupport": "Hilfe", + "windowsThirdPartyLicenses": "Lizenzen von Drittanbietern", + "windowsSearch": "Suchen", + "windowsStartupDisabledByUser": "Vom Benutzer in den Windows-Einstellungen deaktiviert.", + "windowsStartupDisabledByPolicy": "Durch eine Windows-Richtlinie deaktiviert.", + "windowsStartupUnavailable": "Verfügbar, nachdem BusyMax aus einem MSIX-Paket installiert wurde.", + "windowsReminderExitNotice": "Erinnerungen enden, wenn BusyMax vollständig beendet wird. Lassen Sie es im Hintergrund laufen, um sie zu erhalten.", + "windowsProductVersionLabel": "Produktversion", + "windowsPackageVersionLabel": "Windows-Paketversion", + "windowsUnpackaged": "Nicht paketiert", + "windowsAgendaLoadMore": "Weitere Agendaeinträge laden", "repeatWeeklyDaySummary": "{dayKey, select, MO{montags} TU{dienstags} WE{mittwochs} TH{donnerstags} FR{freitags} SA{samstags} SU{sonntags} other{{day}}}", "repeatOnTwoMonthDaysSummary": "an den Tagen {first} und {second} des Monats", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} an den Tagen {firstDay} und {secondDay} im {month}", @@ -267,7 +278,7 @@ "feedbackDetailedMessage": "Ausführliche Nachricht", "feedbackReplyEmail": "E-Mail-Adresse für Antworten (optional)", "feedbackIncludeTechnicalDetails": "Technische Details hinzufügen", - "feedbackTechnicalDetailsDisclosure": "Fügt nur die Version Ihres Linux-Betriebssystems und die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.", + "feedbackTechnicalDetailsDisclosure": "Fügt nur Name und Version Ihres Betriebssystems sowie die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.", "feedbackCategoryRequired": "Wählen Sie eine Kategorie aus.", "feedbackSubjectLengthError": "Der Betreff muss zwischen 3 und 120 Zeichen lang sein.", "feedbackMessageLengthError": "Die Nachricht muss zwischen 10 und 5.000 Zeichen lang sein.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 79f50bb8..f343c286 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +1,16 @@ { "@@locale": "en", + "windowsSupport": "Support", + "windowsThirdPartyLicenses": "Third-party licenses", + "windowsSearch": "Search", + "windowsStartupDisabledByUser": "Disabled by the user in Windows Settings.", + "windowsStartupDisabledByPolicy": "Disabled by Windows policy.", + "windowsStartupUnavailable": "Available after BusyMax is installed from an MSIX package.", + "windowsReminderExitNotice": "Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.", + "windowsProductVersionLabel": "Product version", + "windowsPackageVersionLabel": "Windows package version", + "windowsUnpackaged": "Unpackaged", + "windowsAgendaLoadMore": "Load more agenda items", "repeatWeeklyDaySummary": "{dayKey, select, MO{Monday} TU{Tuesday} WE{Wednesday} TH{Thursday} FR{Friday} SA{Saturday} SU{Sunday} other{{day}}}", "@repeatWeeklyDaySummary": { "description": "Sentence-form weekday used inside a weekly recurrence summary. {dayKey} is MO through SU; {day} is the locale-formatted fallback.", @@ -365,7 +376,7 @@ "feedbackDetailedMessage": "Detailed message", "feedbackReplyEmail": "Reply email (optional)", "feedbackIncludeTechnicalDetails": "Include technical details", - "feedbackTechnicalDetailsDisclosure": "Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.", + "feedbackTechnicalDetailsDisclosure": "Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.", "feedbackCategoryRequired": "Select a category.", "feedbackSubjectLengthError": "Subject must be between 3 and 120 characters.", "feedbackMessageLengthError": "Message must be between 10 and 5,000 characters.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index de2b10a3..9655d5f4 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,5 +1,16 @@ { "@@locale": "es", + "windowsSupport": "Soporte", + "windowsThirdPartyLicenses": "Licencias de terceros", + "windowsSearch": "Buscar", + "windowsStartupDisabledByUser": "Desactivado por el usuario en Configuración de Windows.", + "windowsStartupDisabledByPolicy": "Desactivado por una directiva de Windows.", + "windowsStartupUnavailable": "Disponible después de instalar BusyMax desde un paquete MSIX.", + "windowsReminderExitNotice": "Los recordatorios se detienen al cerrar BusyMax por completo. Déjalo ejecutándose en segundo plano para recibirlos.", + "windowsProductVersionLabel": "Versión del producto", + "windowsPackageVersionLabel": "Versión del paquete de Windows", + "windowsUnpackaged": "Sin empaquetar", + "windowsAgendaLoadMore": "Cargar más elementos de la agenda", "repeatWeeklyDaySummary": "{dayKey, select, MO{lunes} TU{martes} WE{miércoles} TH{jueves} FR{viernes} SA{sábado} SU{domingo} other{{day}}}", "repeatOnTwoMonthDaysSummary": "los días {first} y {second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} los días {firstDay} y {secondDay} de {month}", @@ -267,7 +278,7 @@ "feedbackDetailedMessage": "Mensaje detallado", "feedbackReplyEmail": "Correo electrónico para recibir una respuesta (opcional)", "feedbackIncludeTechnicalDetails": "Incluir detalles técnicos", - "feedbackTechnicalDetailsDisclosure": "Incluye únicamente la versión del sistema operativo Linux y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.", + "feedbackTechnicalDetailsDisclosure": "Incluye únicamente el nombre y la versión del sistema operativo y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.", "feedbackCategoryRequired": "Selecciona una categoría.", "feedbackSubjectLengthError": "El asunto debe tener entre 3 y 120 caracteres.", "feedbackMessageLengthError": "El mensaje debe tener entre 10 y 5.000 caracteres.", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 8f572879..c1369c9b 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,5 +1,16 @@ { "@@locale": "et", + "windowsSupport": "Tugi", + "windowsThirdPartyLicenses": "Kolmandate osapoolte litsentsid", + "windowsSearch": "Otsi", + "windowsStartupDisabledByUser": "Kasutaja keelas selle Windowsi sätetes.", + "windowsStartupDisabledByPolicy": "Windowsi poliitika on selle keelanud.", + "windowsStartupUnavailable": "Saadaval pärast BusyMaxi installimist MSIX-paketist.", + "windowsReminderExitNotice": "BusyMaxi täielikul sulgemisel meeldetuletused peatuvad. Nende saamiseks jätke rakendus taustal tööle.", + "windowsProductVersionLabel": "Toote versioon", + "windowsPackageVersionLabel": "Windowsi paketi versioon", + "windowsUnpackaged": "Pakkimata", + "windowsAgendaLoadMore": "Laadi rohkem päevakava üksusi", "repeatWeeklyDaySummary": "{dayKey, select, MO{esmaspäeviti} TU{teisipäeviti} WE{kolmapäeviti} TH{neljapäeviti} FR{reedeti} SA{laupäeviti} SU{pühapäeviti} other{{day}}}", "repeatOnTwoMonthDaysSummary": "{first} ja {second} päeval", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} {firstDay} ja {secondDay} päeval", @@ -281,7 +292,7 @@ "feedbackDetailedMessage": "Üksikasjalik sõnum", "feedbackReplyEmail": "Vastamise e-posti aadress (valikuline)", "feedbackIncludeTechnicalDetails": "Lisa tehnilised üksikasjad", - "feedbackTechnicalDetailsDisclosure": "Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.", + "feedbackTechnicalDetailsDisclosure": "Lisatakse ainult teie operatsioonisüsteemi nimi ja versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.", "feedbackCategoryRequired": "Valige kategooria.", "feedbackSubjectLengthError": "Teema peab olema 3–120 tähemärki pikk.", "feedbackMessageLengthError": "Sõnum peab olema 10–5000 tähemärki pikk.", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3e1093ef..30d8dc6d 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,5 +1,16 @@ { "@@locale": "fa", + "windowsSupport": "پشتیبانی", + "windowsThirdPartyLicenses": "مجوزهای شخص ثالث", + "windowsSearch": "جستجو", + "windowsStartupDisabledByUser": "کاربر این گزینه را در تنظیمات Windows غیرفعال کرده است.", + "windowsStartupDisabledByPolicy": "با خط‌مشی Windows غیرفعال شده است.", + "windowsStartupUnavailable": "پس از نصب BusyMax از بستهٔ MSIX در دسترس است.", + "windowsReminderExitNotice": "با خروج کامل از BusyMax یادآورها متوقف می‌شوند. برای دریافت آن‌ها برنامه را در پس‌زمینه فعال نگه دارید.", + "windowsProductVersionLabel": "نسخهٔ محصول", + "windowsPackageVersionLabel": "نسخهٔ بستهٔ Windows", + "windowsUnpackaged": "بدون بسته", + "windowsAgendaLoadMore": "بارگیری موارد بیشتر دستور کار", "repeatWeeklyDaySummary": "{dayKey, select, MO{دوشنبه} TU{سه‌شنبه} WE{چهارشنبه} TH{پنجشنبه} FR{جمعه} SA{شنبه} SU{یکشنبه} other{{day}}}", "repeatOnTwoMonthDaysSummary": "در روزهای {first} و {second} ماه", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} در روزهای {firstDay} و {secondDay} ماه {month}", @@ -251,7 +262,7 @@ "feedbackDetailedMessage": "پیام با جزئیات", "feedbackReplyEmail": "نشانی ایمیل برای پاسخ (اختیاری)", "feedbackIncludeTechnicalDetails": "افزودن جزئیات فنی", - "feedbackTechnicalDetailsDisclosure": "فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.", + "feedbackTechnicalDetailsDisclosure": "فقط نام و نسخهٔ سیستم‌عامل و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.", "feedbackCategoryRequired": "یک دسته‌بندی انتخاب کنید.", "feedbackSubjectLengthError": "موضوع باید بین ۳ تا ۱۲۰ نویسه باشد.", "feedbackMessageLengthError": "پیام باید بین ۱۰ تا ۵٬۰۰۰ نویسه باشد.", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 18da5b5c..22deabf8 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,5 +1,16 @@ { "@@locale": "fi", + "windowsSupport": "Tuki", + "windowsThirdPartyLicenses": "Kolmannen osapuolen lisenssit", + "windowsSearch": "Haku", + "windowsStartupDisabledByUser": "Käyttäjä on poistanut toiminnon käytöstä Windowsin asetuksissa.", + "windowsStartupDisabledByPolicy": "Windows-käytäntö on poistanut toiminnon käytöstä.", + "windowsStartupUnavailable": "Käytettävissä, kun BusyMax on asennettu MSIX-paketista.", + "windowsReminderExitNotice": "Muistutukset lakkaavat, kun BusyMax suljetaan kokonaan. Jätä se toimimaan taustalle saadaksesi muistutukset.", + "windowsProductVersionLabel": "Tuoteversio", + "windowsPackageVersionLabel": "Windows-paketin versio", + "windowsUnpackaged": "Pakkaamaton", + "windowsAgendaLoadMore": "Lataa lisää esityslistan kohteita", "repeatWeeklyDaySummary": "{dayKey, select, MO{maanantaisin} TU{tiistaisin} WE{keskiviikkoisin} TH{torstaisin} FR{perjantaisin} SA{lauantaisin} SU{sunnuntaisin} other{{day}}}", "repeatOnTwoMonthDaysSummary": "kuukauden {first} ja {second} päivänä", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} {firstDay} ja {secondDay} päivänä", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "Yksityiskohtainen viesti", "feedbackReplyEmail": "Sähköpostiosoite vastausta varten (valinnainen)", "feedbackIncludeTechnicalDetails": "Sisällytä tekniset tiedot", - "feedbackTechnicalDetailsDisclosure": "Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.", + "feedbackTechnicalDetailsDisclosure": "Lisää vain käyttöjärjestelmäsi nimen ja version sekä sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.", "feedbackCategoryRequired": "Valitse luokka.", "feedbackSubjectLengthError": "Aiheen pituuden on oltava 3–120 merkkiä.", "feedbackMessageLengthError": "Viestin pituuden on oltava 10–5 000 merkkiä.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 368f68fd..0750730f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,5 +1,16 @@ { "@@locale": "fr", + "windowsSupport": "Assistance", + "windowsThirdPartyLicenses": "Licences tierces", + "windowsSearch": "Rechercher", + "windowsStartupDisabledByUser": "Désactivé par l’utilisateur dans les paramètres Windows.", + "windowsStartupDisabledByPolicy": "Désactivé par une stratégie Windows.", + "windowsStartupUnavailable": "Disponible après l’installation de BusyMax à partir d’un package MSIX.", + "windowsReminderExitNotice": "Les rappels s’arrêtent lorsque BusyMax est complètement fermé. Laissez-le s’exécuter en arrière-plan pour les recevoir.", + "windowsProductVersionLabel": "Version du produit", + "windowsPackageVersionLabel": "Version du package Windows", + "windowsUnpackaged": "Non empaqueté", + "windowsAgendaLoadMore": "Charger plus d’éléments de l’agenda", "repeatWeeklyDaySummary": "{dayKey, select, MO{le lundi} TU{le mardi} WE{le mercredi} TH{le jeudi} FR{le vendredi} SA{le samedi} SU{le dimanche} other{{day}}}", "repeatOnTwoMonthDaysSummary": "les jours {first} et {second} du mois", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} les jours {firstDay} et {secondDay} de {month}", @@ -267,7 +278,7 @@ "feedbackDetailedMessage": "Message détaillé", "feedbackReplyEmail": "Adresse e-mail de réponse (facultatif)", "feedbackIncludeTechnicalDetails": "Inclure les détails techniques", - "feedbackTechnicalDetailsDisclosure": "Ajoute uniquement la version de votre système d’exploitation Linux et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.", + "feedbackTechnicalDetailsDisclosure": "Ajoute uniquement le nom et la version de votre système d’exploitation et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.", "feedbackCategoryRequired": "Sélectionnez une catégorie.", "feedbackSubjectLengthError": "L’objet doit comporter entre 3 et 120 caractères.", "feedbackMessageLengthError": "Le message doit comporter entre 10 et 5 000 caractères.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7d703668..1d07a771 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,5 +1,16 @@ { "@@locale": "hi", + "windowsSupport": "सहायता", + "windowsThirdPartyLicenses": "तृतीय-पक्ष लाइसेंस", + "windowsSearch": "खोजें", + "windowsStartupDisabledByUser": "उपयोगकर्ता ने Windows सेटिंग में इसे बंद किया है।", + "windowsStartupDisabledByPolicy": "Windows नीति द्वारा बंद किया गया है।", + "windowsStartupUnavailable": "BusyMax को MSIX पैकेज से इंस्टॉल करने के बाद उपलब्ध होगा।", + "windowsReminderExitNotice": "BusyMax को पूरी तरह बंद करने पर रिमाइंडर रुक जाते हैं। उन्हें पाने के लिए इसे पृष्ठभूमि में चलने दें।", + "windowsProductVersionLabel": "उत्पाद संस्करण", + "windowsPackageVersionLabel": "Windows पैकेज संस्करण", + "windowsUnpackaged": "पैकेज रहित", + "windowsAgendaLoadMore": "एजेंडा के और आइटम लोड करें", "repeatWeeklyDaySummary": "{dayKey, select, MO{सोमवार} TU{मंगलवार} WE{बुधवार} TH{गुरुवार} FR{शुक्रवार} SA{शनिवार} SU{रविवार} other{{day}}}", "repeatOnTwoMonthDaysSummary": "की {first} और {second} तारीखों को", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month} की {firstDay} और {secondDay} तारीखों को", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "विस्तृत संदेश", "feedbackReplyEmail": "जवाब के लिए ईमेल (वैकल्पिक)", "feedbackIncludeTechnicalDetails": "तकनीकी विवरण शामिल करें", - "feedbackTechnicalDetailsDisclosure": "केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।", + "feedbackTechnicalDetailsDisclosure": "केवल आपके ऑपरेटिंग सिस्टम का नाम और संस्करण तथा ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।", "feedbackCategoryRequired": "श्रेणी चुनें।", "feedbackSubjectLengthError": "विषय 3 से 120 वर्णों के बीच होना चाहिए।", "feedbackMessageLengthError": "संदेश 10 से 5,000 वर्णों के बीच होना चाहिए।", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 33f79071..fbb56c76 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,5 +1,16 @@ { "@@locale": "it", + "windowsSupport": "Supporto", + "windowsThirdPartyLicenses": "Licenze di terze parti", + "windowsSearch": "Cerca", + "windowsStartupDisabledByUser": "Disabilitato dall’utente nelle impostazioni di Windows.", + "windowsStartupDisabledByPolicy": "Disabilitato da un criterio di Windows.", + "windowsStartupUnavailable": "Disponibile dopo l’installazione di BusyMax da un pacchetto MSIX.", + "windowsReminderExitNotice": "I promemoria si interrompono quando BusyMax viene chiuso completamente. Lascialo in esecuzione in background per riceverli.", + "windowsProductVersionLabel": "Versione del prodotto", + "windowsPackageVersionLabel": "Versione del pacchetto Windows", + "windowsUnpackaged": "Non pacchettizzato", + "windowsAgendaLoadMore": "Carica altri elementi dell’agenda", "repeatWeeklyDaySummary": "{dayKey, select, MO{il lunedì} TU{il martedì} WE{il mercoledì} TH{il giovedì} FR{il venerdì} SA{il sabato} SU{la domenica} other{{day}}}", "repeatOnTwoMonthDaysSummary": "nei giorni {first} e {second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} nei giorni {firstDay} e {secondDay} di {month}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "Messaggio dettagliato", "feedbackReplyEmail": "Indirizzo email per la risposta (facoltativo)", "feedbackIncludeTechnicalDetails": "Includi dettagli tecnici", - "feedbackTechnicalDetailsDisclosure": "Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.", + "feedbackTechnicalDetailsDisclosure": "Aggiunge soltanto il nome e la versione del sistema operativo e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.", "feedbackCategoryRequired": "Seleziona una categoria.", "feedbackSubjectLengthError": "L’oggetto deve contenere da 3 a 120 caratteri.", "feedbackMessageLengthError": "Il messaggio deve contenere da 10 a 5.000 caratteri.", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index e5cada59..bbcb7185 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,5 +1,16 @@ { "@@locale": "ja", + "windowsSupport": "サポート", + "windowsThirdPartyLicenses": "サードパーティ ライセンス", + "windowsSearch": "検索", + "windowsStartupDisabledByUser": "Windows の設定でユーザーにより無効にされています。", + "windowsStartupDisabledByPolicy": "Windows ポリシーにより無効にされています。", + "windowsStartupUnavailable": "BusyMax を MSIX パッケージからインストールすると利用できます。", + "windowsReminderExitNotice": "BusyMax を完全に終了するとリマインダーも停止します。受け取るにはバックグラウンドで実行したままにしてください。", + "windowsProductVersionLabel": "製品バージョン", + "windowsPackageVersionLabel": "Windows パッケージ バージョン", + "windowsUnpackaged": "パッケージ化されていません", + "windowsAgendaLoadMore": "予定をさらに読み込む", "repeatWeeklyDaySummary": "{dayKey, select, MO{月曜日} TU{火曜日} WE{水曜日} TH{木曜日} FR{金曜日} SA{土曜日} SU{日曜日} other{{day}}}", "repeatOnTwoMonthDaysSummary": "{first}、{second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}の{firstDay}と{secondDay}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "詳しい内容", "feedbackReplyEmail": "返信先メールアドレス(任意)", "feedbackIncludeTechnicalDetails": "技術情報を含める", - "feedbackTechnicalDetailsDisclosure": "Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。", + "feedbackTechnicalDetailsDisclosure": "オペレーティングシステムの名前とバージョン、およびアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。", "feedbackCategoryRequired": "カテゴリーを選択してください。", "feedbackSubjectLengthError": "件名は3文字以上120文字以下にしてください。", "feedbackMessageLengthError": "メッセージは10文字以上5,000文字以下にしてください。", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3a99f899..67746959 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,5 +1,16 @@ { "@@locale": "ko", + "windowsSupport": "지원", + "windowsThirdPartyLicenses": "타사 라이선스", + "windowsSearch": "검색", + "windowsStartupDisabledByUser": "Windows 설정에서 사용자가 사용하지 않도록 설정했습니다.", + "windowsStartupDisabledByPolicy": "Windows 정책에 의해 사용하지 않도록 설정되었습니다.", + "windowsStartupUnavailable": "BusyMax를 MSIX 패키지로 설치하면 사용할 수 있습니다.", + "windowsReminderExitNotice": "BusyMax를 완전히 종료하면 미리 알림이 중지됩니다. 받으려면 백그라운드에서 계속 실행하세요.", + "windowsProductVersionLabel": "제품 버전", + "windowsPackageVersionLabel": "Windows 패키지 버전", + "windowsUnpackaged": "패키지되지 않음", + "windowsAgendaLoadMore": "일정 항목 더 불러오기", "repeatWeeklyDaySummary": "{dayKey, select, MO{월요일} TU{화요일} WE{수요일} TH{목요일} FR{금요일} SA{토요일} SU{일요일} other{{day}}}", "repeatOnTwoMonthDaysSummary": "{first}과 {second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} {month}의 {firstDay}과 {secondDay}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "자세한 내용", "feedbackReplyEmail": "답변 받을 이메일 주소(선택 사항)", "feedbackIncludeTechnicalDetails": "기술 세부 정보 포함", - "feedbackTechnicalDetailsDisclosure": "Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.", + "feedbackTechnicalDetailsDisclosure": "운영 체제 이름과 버전 및 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.", "feedbackCategoryRequired": "범주를 선택하세요.", "feedbackSubjectLengthError": "제목은 3~120자여야 합니다.", "feedbackMessageLengthError": "메시지는 10~5,000자여야 합니다.", diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index 2d618301..e66dd5b2 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1,5 +1,16 @@ { "@@locale": "pt_PT", + "windowsSupport": "Suporte", + "windowsThirdPartyLicenses": "Licenças de terceiros", + "windowsSearch": "Pesquisar", + "windowsStartupDisabledByUser": "Desativado pelo utilizador nas Definições do Windows.", + "windowsStartupDisabledByPolicy": "Desativado por uma política do Windows.", + "windowsStartupUnavailable": "Disponível após a instalação do BusyMax a partir de um pacote MSIX.", + "windowsReminderExitNotice": "Os lembretes param quando o BusyMax é totalmente encerrado. Mantenha-o em execução em segundo plano para os receber.", + "windowsProductVersionLabel": "Versão do produto", + "windowsPackageVersionLabel": "Versão do pacote do Windows", + "windowsUnpackaged": "Sem pacote", + "windowsAgendaLoadMore": "Carregar mais itens da agenda", "repeatWeeklyDaySummary": "{dayKey, select, MO{à segunda-feira} TU{à terça-feira} WE{à quarta-feira} TH{à quinta-feira} FR{à sexta-feira} SA{ao sábado} SU{ao domingo} other{{day}}}", "repeatOnTwoMonthDaysSummary": "nos dias {first} e {second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} nos dias {firstDay} e {secondDay} de {month}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "Mensagem detalhada", "feedbackReplyEmail": "Endereço de e-mail para resposta (opcional)", "feedbackIncludeTechnicalDetails": "Incluir detalhes técnicos", - "feedbackTechnicalDetailsDisclosure": "Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", + "feedbackTechnicalDetailsDisclosure": "Adiciona apenas o nome e a versão do sistema operativo e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.", "feedbackCategoryRequired": "Selecione uma categoria.", "feedbackSubjectLengthError": "O assunto deve ter entre 3 e 120 carateres.", "feedbackMessageLengthError": "A mensagem deve ter entre 10 e 5 000 carateres.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f68b392b..732ee771 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,5 +1,16 @@ { "@@locale": "ru", + "windowsSupport": "Поддержка", + "windowsThirdPartyLicenses": "Лицензии сторонних компонентов", + "windowsSearch": "Поиск", + "windowsStartupDisabledByUser": "Отключено пользователем в параметрах Windows.", + "windowsStartupDisabledByPolicy": "Отключено политикой Windows.", + "windowsStartupUnavailable": "Доступно после установки BusyMax из пакета MSIX.", + "windowsReminderExitNotice": "Напоминания прекращаются после полного выхода из BusyMax. Оставьте приложение работать в фоновом режиме, чтобы получать их.", + "windowsProductVersionLabel": "Версия продукта", + "windowsPackageVersionLabel": "Версия пакета Windows", + "windowsUnpackaged": "Без пакета", + "windowsAgendaLoadMore": "Загрузить ещё элементы повестки", "repeatWeeklyDaySummary": "{dayKey, select, MO{понедельникам} TU{вторникам} WE{средам} TH{четвергам} FR{пятницам} SA{субботам} SU{воскресеньям} other{{day}}}", "repeatOnTwoMonthDaysSummary": "{first} и {second} числа каждого месяца", "repeatYearlyOnTwoMonthDaysSummary": "{frequency}: {month}, {firstDay} и {secondDay} числа", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "Подробное сообщение", "feedbackReplyEmail": "Адрес электронной почты для ответа (необязательно)", "feedbackIncludeTechnicalDetails": "Включить технические сведения", - "feedbackTechnicalDetailsDisclosure": "Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.", + "feedbackTechnicalDetailsDisclosure": "Будут добавлены только название и версия операционной системы и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.", "feedbackCategoryRequired": "Выберите категорию.", "feedbackSubjectLengthError": "Тема должна содержать от 3 до 120 символов.", "feedbackMessageLengthError": "Сообщение должно содержать от 10 до 5 000 символов.", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 02941eb0..a5808789 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,5 +1,16 @@ { "@@locale": "vi", + "windowsSupport": "Hỗ trợ", + "windowsThirdPartyLicenses": "Giấy phép bên thứ ba", + "windowsSearch": "Tìm kiếm", + "windowsStartupDisabledByUser": "Người dùng đã tắt trong Cài đặt Windows.", + "windowsStartupDisabledByPolicy": "Đã tắt theo chính sách Windows.", + "windowsStartupUnavailable": "Khả dụng sau khi cài đặt BusyMax từ gói MSIX.", + "windowsReminderExitNotice": "Lời nhắc sẽ dừng khi thoát hẳn BusyMax. Hãy để ứng dụng chạy trong nền để nhận lời nhắc.", + "windowsProductVersionLabel": "Phiên bản sản phẩm", + "windowsPackageVersionLabel": "Phiên bản gói Windows", + "windowsUnpackaged": "Chưa đóng gói", + "windowsAgendaLoadMore": "Tải thêm mục lịch biểu", "repeatWeeklyDaySummary": "{dayKey, select, MO{Thứ Hai} TU{Thứ Ba} WE{Thứ Tư} TH{Thứ Năm} FR{Thứ Sáu} SA{Thứ Bảy} SU{Chủ Nhật} other{{day}}}", "repeatOnTwoMonthDaysSummary": "vào các ngày {first} và {second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency} vào các ngày {firstDay} và {secondDay} của {month}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "Nội dung chi tiết", "feedbackReplyEmail": "Địa chỉ email để nhận phản hồi (không bắt buộc)", "feedbackIncludeTechnicalDetails": "Bao gồm chi tiết kỹ thuật", - "feedbackTechnicalDetailsDisclosure": "Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.", + "feedbackTechnicalDetailsDisclosure": "Chỉ thêm tên và phiên bản hệ điều hành cùng ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.", "feedbackCategoryRequired": "Hãy chọn một danh mục.", "feedbackSubjectLengthError": "Chủ đề phải có từ 3 đến 120 ký tự.", "feedbackMessageLengthError": "Nội dung phải có từ 10 đến 5.000 ký tự.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f39ef2cf..cb70669f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,5 +1,16 @@ { "@@locale": "zh", + "windowsSupport": "支持", + "windowsThirdPartyLicenses": "第三方许可证", + "windowsSearch": "搜索", + "windowsStartupDisabledByUser": "用户已在 Windows 设置中禁用。", + "windowsStartupDisabledByPolicy": "已由 Windows 策略禁用。", + "windowsStartupUnavailable": "从 MSIX 包安装 BusyMax 后可用。", + "windowsReminderExitNotice": "完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。", + "windowsProductVersionLabel": "产品版本", + "windowsPackageVersionLabel": "Windows 包版本", + "windowsUnpackaged": "未打包", + "windowsAgendaLoadMore": "載入更多議程項目", "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", "repeatOnTwoMonthDaysSummary": "{first}、{second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "详细信息", "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", "feedbackIncludeTechnicalDetails": "包含技术详情", - "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", + "feedbackTechnicalDetailsDisclosure": "仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", "feedbackCategoryRequired": "请选择类别。", "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 762df8a4..b7ff6065 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,5 +1,16 @@ { "@@locale": "zh_Hans", + "windowsSupport": "支持", + "windowsThirdPartyLicenses": "第三方许可证", + "windowsSearch": "搜索", + "windowsStartupDisabledByUser": "用户已在 Windows 设置中禁用。", + "windowsStartupDisabledByPolicy": "已由 Windows 策略禁用。", + "windowsStartupUnavailable": "从 MSIX 包安装 BusyMax 后可用。", + "windowsReminderExitNotice": "完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。", + "windowsProductVersionLabel": "产品版本", + "windowsPackageVersionLabel": "Windows 包版本", + "windowsUnpackaged": "未打包", + "windowsAgendaLoadMore": "加载更多议程项目", "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", "repeatOnTwoMonthDaysSummary": "{first}、{second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "详细信息", "feedbackReplyEmail": "用于接收回复的电子邮件地址(可选)", "feedbackIncludeTechnicalDetails": "包含技术详情", - "feedbackTechnicalDetailsDisclosure": "仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", + "feedbackTechnicalDetailsDisclosure": "仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。", "feedbackCategoryRequired": "请选择类别。", "feedbackSubjectLengthError": "主题必须为 3 至 120 个字符。", "feedbackMessageLengthError": "消息必须为 10 至 5,000 个字符。", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 751461e8..3c5a8c1a 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,5 +1,16 @@ { "@@locale": "zh_Hant", + "windowsSupport": "支援", + "windowsThirdPartyLicenses": "第三方授權", + "windowsSearch": "搜尋", + "windowsStartupDisabledByUser": "使用者已在 Windows 設定中停用。", + "windowsStartupDisabledByPolicy": "已由 Windows 原則停用。", + "windowsStartupUnavailable": "從 MSIX 套件安裝 BusyMax 後即可使用。", + "windowsReminderExitNotice": "完全結束 BusyMax 後,提醒會停止。請讓應用程式在背景執行以接收提醒。", + "windowsProductVersionLabel": "產品版本", + "windowsPackageVersionLabel": "Windows 套件版本", + "windowsUnpackaged": "未封裝", + "windowsAgendaLoadMore": "載入更多議程項目", "repeatWeeklyDaySummary": "{dayKey, select, MO{星期一} TU{星期二} WE{星期三} TH{星期四} FR{星期五} SA{星期六} SU{星期日} other{{day}}}", "repeatOnTwoMonthDaysSummary": "{first}、{second}", "repeatYearlyOnTwoMonthDaysSummary": "{frequency}{month}的{firstDay}和{secondDay}", @@ -246,7 +257,7 @@ "feedbackDetailedMessage": "詳細訊息", "feedbackReplyEmail": "回覆用電子郵件地址(選填)", "feedbackIncludeTechnicalDetails": "包含技術詳細資料", - "feedbackTechnicalDetailsDisclosure": "只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。", + "feedbackTechnicalDetailsDisclosure": "只會加入您的作業系統名稱與版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。", "feedbackCategoryRequired": "請選擇類別。", "feedbackSubjectLengthError": "主旨必須介於 3 到 120 個字元之間。", "feedbackMessageLengthError": "訊息必須介於 10 到 5,000 個字元之間。", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 4b7c16fa..b92d659c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -133,6 +133,72 @@ abstract class AppLocalizations { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; + /// No description provided for @windowsSupport. + /// + /// In en, this message translates to: + /// **'Support'** + String get windowsSupport; + + /// No description provided for @windowsThirdPartyLicenses. + /// + /// In en, this message translates to: + /// **'Third-party licenses'** + String get windowsThirdPartyLicenses; + + /// No description provided for @windowsSearch. + /// + /// In en, this message translates to: + /// **'Search'** + String get windowsSearch; + + /// No description provided for @windowsStartupDisabledByUser. + /// + /// In en, this message translates to: + /// **'Disabled by the user in Windows Settings.'** + String get windowsStartupDisabledByUser; + + /// No description provided for @windowsStartupDisabledByPolicy. + /// + /// In en, this message translates to: + /// **'Disabled by Windows policy.'** + String get windowsStartupDisabledByPolicy; + + /// No description provided for @windowsStartupUnavailable. + /// + /// In en, this message translates to: + /// **'Available after BusyMax is installed from an MSIX package.'** + String get windowsStartupUnavailable; + + /// No description provided for @windowsReminderExitNotice. + /// + /// In en, this message translates to: + /// **'Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.'** + String get windowsReminderExitNotice; + + /// No description provided for @windowsProductVersionLabel. + /// + /// In en, this message translates to: + /// **'Product version'** + String get windowsProductVersionLabel; + + /// No description provided for @windowsPackageVersionLabel. + /// + /// In en, this message translates to: + /// **'Windows package version'** + String get windowsPackageVersionLabel; + + /// No description provided for @windowsUnpackaged. + /// + /// In en, this message translates to: + /// **'Unpackaged'** + String get windowsUnpackaged; + + /// No description provided for @windowsAgendaLoadMore. + /// + /// In en, this message translates to: + /// **'Load more agenda items'** + String get windowsAgendaLoadMore; + /// Sentence-form weekday used inside a weekly recurrence summary. {dayKey} is MO through SU; {day} is the locale-formatted fallback. /// /// In en, this message translates to: @@ -1525,7 +1591,7 @@ abstract class AppLocalizations { /// No description provided for @feedbackTechnicalDetailsDisclosure. /// /// In en, this message translates to: - /// **'Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.'** + /// **'Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.'** String get feedbackTechnicalDetailsDisclosure; /// No description provided for @feedbackCategoryRequired. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index d2435ee0..dbe43ff9 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -12,6 +12,42 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get windowsSupport => 'الدعم'; + + @override + String get windowsThirdPartyLicenses => 'تراخيص الجهات الخارجية'; + + @override + String get windowsSearch => 'بحث'; + + @override + String get windowsStartupDisabledByUser => + 'عطّل المستخدم هذه الميزة في إعدادات Windows.'; + + @override + String get windowsStartupDisabledByPolicy => 'معطّلة بواسطة نهج Windows.'; + + @override + String get windowsStartupUnavailable => + 'تتوفر بعد تثبيت BusyMax من حزمة MSIX.'; + + @override + String get windowsReminderExitNotice => + 'تتوقف التذكيرات عند إنهاء BusyMax بالكامل. أبقِه قيد التشغيل في الخلفية لتلقيها.'; + + @override + String get windowsProductVersionLabel => 'إصدار المنتج'; + + @override + String get windowsPackageVersionLabel => 'إصدار حزمة Windows'; + + @override + String get windowsUnpackaged => 'غير معبأ'; + + @override + String get windowsAgendaLoadMore => 'تحميل المزيد من عناصر جدول الأعمال'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -886,7 +922,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'يضيف فقط إصدار نظام التشغيل Linux ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.'; + 'يضيف فقط اسم نظام التشغيل وإصداره ولغة التطبيق ومنطقته. لا يتم تضمين أي سجلات أو بيانات حسابات أو أسماء ملفات أو معلومات تشخيصية أخرى.'; @override String get feedbackCategoryRequired => 'اختر فئة.'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 03442437..6d8ecd9f 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get windowsSupport => 'Hilfe'; + + @override + String get windowsThirdPartyLicenses => 'Lizenzen von Drittanbietern'; + + @override + String get windowsSearch => 'Suchen'; + + @override + String get windowsStartupDisabledByUser => + 'Vom Benutzer in den Windows-Einstellungen deaktiviert.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Durch eine Windows-Richtlinie deaktiviert.'; + + @override + String get windowsStartupUnavailable => + 'Verfügbar, nachdem BusyMax aus einem MSIX-Paket installiert wurde.'; + + @override + String get windowsReminderExitNotice => + 'Erinnerungen enden, wenn BusyMax vollständig beendet wird. Lassen Sie es im Hintergrund laufen, um sie zu erhalten.'; + + @override + String get windowsProductVersionLabel => 'Produktversion'; + + @override + String get windowsPackageVersionLabel => 'Windows-Paketversion'; + + @override + String get windowsUnpackaged => 'Nicht paketiert'; + + @override + String get windowsAgendaLoadMore => 'Weitere Agendaeinträge laden'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -843,7 +880,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Fügt nur die Version Ihres Linux-Betriebssystems und die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.'; + 'Fügt nur Name und Version Ihres Betriebssystems sowie die Spracheinstellung der Anwendung hinzu. Es werden keine Protokolle, Kontodaten, Dateinamen oder anderen Diagnosedaten hinzugefügt.'; @override String get feedbackCategoryRequired => 'Wählen Sie eine Kategorie aus.'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 8ae20f90..f99be421 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -12,6 +12,42 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get windowsSupport => 'Support'; + + @override + String get windowsThirdPartyLicenses => 'Third-party licenses'; + + @override + String get windowsSearch => 'Search'; + + @override + String get windowsStartupDisabledByUser => + 'Disabled by the user in Windows Settings.'; + + @override + String get windowsStartupDisabledByPolicy => 'Disabled by Windows policy.'; + + @override + String get windowsStartupUnavailable => + 'Available after BusyMax is installed from an MSIX package.'; + + @override + String get windowsReminderExitNotice => + 'Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.'; + + @override + String get windowsProductVersionLabel => 'Product version'; + + @override + String get windowsPackageVersionLabel => 'Windows package version'; + + @override + String get windowsUnpackaged => 'Unpackaged'; + + @override + String get windowsAgendaLoadMore => 'Load more agenda items'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -872,7 +908,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.'; + 'Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.'; @override String get feedbackCategoryRequired => 'Select a category.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 9bd9bfa6..45f2069d 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get windowsSupport => 'Soporte'; + + @override + String get windowsThirdPartyLicenses => 'Licencias de terceros'; + + @override + String get windowsSearch => 'Buscar'; + + @override + String get windowsStartupDisabledByUser => + 'Desactivado por el usuario en Configuración de Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Desactivado por una directiva de Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponible después de instalar BusyMax desde un paquete MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Los recordatorios se detienen al cerrar BusyMax por completo. Déjalo ejecutándose en segundo plano para recibirlos.'; + + @override + String get windowsProductVersionLabel => 'Versión del producto'; + + @override + String get windowsPackageVersionLabel => 'Versión del paquete de Windows'; + + @override + String get windowsUnpackaged => 'Sin empaquetar'; + + @override + String get windowsAgendaLoadMore => 'Cargar más elementos de la agenda'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -844,7 +881,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Incluye únicamente la versión del sistema operativo Linux y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.'; + 'Incluye únicamente el nombre y la versión del sistema operativo y la configuración regional de la aplicación. No se incluyen registros, datos de cuenta, nombres de archivos ni otros diagnósticos.'; @override String get feedbackCategoryRequired => 'Selecciona una categoría.'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index b05ac08d..c25ddbcc 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String get windowsSupport => 'Tugi'; + + @override + String get windowsThirdPartyLicenses => 'Kolmandate osapoolte litsentsid'; + + @override + String get windowsSearch => 'Otsi'; + + @override + String get windowsStartupDisabledByUser => + 'Kasutaja keelas selle Windowsi sätetes.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windowsi poliitika on selle keelanud.'; + + @override + String get windowsStartupUnavailable => + 'Saadaval pärast BusyMaxi installimist MSIX-paketist.'; + + @override + String get windowsReminderExitNotice => + 'BusyMaxi täielikul sulgemisel meeldetuletused peatuvad. Nende saamiseks jätke rakendus taustal tööle.'; + + @override + String get windowsProductVersionLabel => 'Toote versioon'; + + @override + String get windowsPackageVersionLabel => 'Windowsi paketi versioon'; + + @override + String get windowsUnpackaged => 'Pakkimata'; + + @override + String get windowsAgendaLoadMore => 'Laadi rohkem päevakava üksusi'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -835,7 +872,7 @@ class AppLocalizationsEt extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Lisatakse ainult teie Linuxi operatsioonisüsteemi versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.'; + 'Lisatakse ainult teie operatsioonisüsteemi nimi ja versioon ning rakenduse keel ja piirkonnaseaded. Logisid, kontoandmeid, failinimesid ega muid diagnostikaandmeid ei lisata.'; @override String get feedbackCategoryRequired => 'Valige kategooria.'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 4a96e407..de896827 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get windowsSupport => 'پشتیبانی'; + + @override + String get windowsThirdPartyLicenses => 'مجوزهای شخص ثالث'; + + @override + String get windowsSearch => 'جستجو'; + + @override + String get windowsStartupDisabledByUser => + 'کاربر این گزینه را در تنظیمات Windows غیرفعال کرده است.'; + + @override + String get windowsStartupDisabledByPolicy => + 'با خط‌مشی Windows غیرفعال شده است.'; + + @override + String get windowsStartupUnavailable => + 'پس از نصب BusyMax از بستهٔ MSIX در دسترس است.'; + + @override + String get windowsReminderExitNotice => + 'با خروج کامل از BusyMax یادآورها متوقف می‌شوند. برای دریافت آن‌ها برنامه را در پس‌زمینه فعال نگه دارید.'; + + @override + String get windowsProductVersionLabel => 'نسخهٔ محصول'; + + @override + String get windowsPackageVersionLabel => 'نسخهٔ بستهٔ Windows'; + + @override + String get windowsUnpackaged => 'بدون بسته'; + + @override + String get windowsAgendaLoadMore => 'بارگیری موارد بیشتر دستور کار'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -875,7 +912,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'فقط نسخهٔ سیستم‌عامل Linux و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.'; + 'فقط نام و نسخهٔ سیستم‌عامل و تنظیمات منطقه‌ای برنامه افزوده می‌شود. هیچ گزارش، دادهٔ حساب، نام پرونده یا اطلاعات تشخیصی دیگری افزوده نمی‌شود.'; @override String get feedbackCategoryRequired => 'یک دسته‌بندی انتخاب کنید.'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index c949338b..d7698989 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsFi extends AppLocalizations { AppLocalizationsFi([String locale = 'fi']) : super(locale); + @override + String get windowsSupport => 'Tuki'; + + @override + String get windowsThirdPartyLicenses => 'Kolmannen osapuolen lisenssit'; + + @override + String get windowsSearch => 'Haku'; + + @override + String get windowsStartupDisabledByUser => + 'Käyttäjä on poistanut toiminnon käytöstä Windowsin asetuksissa.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windows-käytäntö on poistanut toiminnon käytöstä.'; + + @override + String get windowsStartupUnavailable => + 'Käytettävissä, kun BusyMax on asennettu MSIX-paketista.'; + + @override + String get windowsReminderExitNotice => + 'Muistutukset lakkaavat, kun BusyMax suljetaan kokonaan. Jätä se toimimaan taustalle saadaksesi muistutukset.'; + + @override + String get windowsProductVersionLabel => 'Tuoteversio'; + + @override + String get windowsPackageVersionLabel => 'Windows-paketin versio'; + + @override + String get windowsUnpackaged => 'Pakkaamaton'; + + @override + String get windowsAgendaLoadMore => 'Lataa lisää esityslistan kohteita'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -863,7 +900,7 @@ class AppLocalizationsFi extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Lisää vain Linux-käyttöjärjestelmäsi version ja sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.'; + 'Lisää vain käyttöjärjestelmäsi nimen ja version sekä sovelluksen alueasetuksen. Lokeja, tilitietoja, tiedostonimiä tai muita diagnostiikkatietoja ei lisätä.'; @override String get feedbackCategoryRequired => 'Valitse luokka.'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index b2ebbd31..333deacc 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get windowsSupport => 'Assistance'; + + @override + String get windowsThirdPartyLicenses => 'Licences tierces'; + + @override + String get windowsSearch => 'Rechercher'; + + @override + String get windowsStartupDisabledByUser => + 'Désactivé par l’utilisateur dans les paramètres Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Désactivé par une stratégie Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponible après l’installation de BusyMax à partir d’un package MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Les rappels s’arrêtent lorsque BusyMax est complètement fermé. Laissez-le s’exécuter en arrière-plan pour les recevoir.'; + + @override + String get windowsProductVersionLabel => 'Version du produit'; + + @override + String get windowsPackageVersionLabel => 'Version du package Windows'; + + @override + String get windowsUnpackaged => 'Non empaqueté'; + + @override + String get windowsAgendaLoadMore => 'Charger plus d’éléments de l’agenda'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -844,7 +881,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Ajoute uniquement la version de votre système d’exploitation Linux et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.'; + 'Ajoute uniquement le nom et la version de votre système d’exploitation et la langue de l’application. Aucun journal, aucune donnée de compte, aucun nom de fichier ni autre diagnostic n’est inclus.'; @override String get feedbackCategoryRequired => 'Sélectionnez une catégorie.'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index fa6d163c..d2ca1c51 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get windowsSupport => 'सहायता'; + + @override + String get windowsThirdPartyLicenses => 'तृतीय-पक्ष लाइसेंस'; + + @override + String get windowsSearch => 'खोजें'; + + @override + String get windowsStartupDisabledByUser => + 'उपयोगकर्ता ने Windows सेटिंग में इसे बंद किया है।'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windows नीति द्वारा बंद किया गया है।'; + + @override + String get windowsStartupUnavailable => + 'BusyMax को MSIX पैकेज से इंस्टॉल करने के बाद उपलब्ध होगा।'; + + @override + String get windowsReminderExitNotice => + 'BusyMax को पूरी तरह बंद करने पर रिमाइंडर रुक जाते हैं। उन्हें पाने के लिए इसे पृष्ठभूमि में चलने दें।'; + + @override + String get windowsProductVersionLabel => 'उत्पाद संस्करण'; + + @override + String get windowsPackageVersionLabel => 'Windows पैकेज संस्करण'; + + @override + String get windowsUnpackaged => 'पैकेज रहित'; + + @override + String get windowsAgendaLoadMore => 'एजेंडा के और आइटम लोड करें'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -856,7 +893,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'केवल आपके Linux ऑपरेटिंग सिस्टम का संस्करण और ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।'; + 'केवल आपके ऑपरेटिंग सिस्टम का नाम और संस्करण तथा ऐप की भाषा व क्षेत्रीय सेटिंग जोड़ी जाती है। लॉग, खाता डेटा, फ़ाइल नाम या अन्य निदान जानकारी शामिल नहीं की जाती।'; @override String get feedbackCategoryRequired => 'श्रेणी चुनें।'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 3ce43806..9e279463 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get windowsSupport => 'Supporto'; + + @override + String get windowsThirdPartyLicenses => 'Licenze di terze parti'; + + @override + String get windowsSearch => 'Cerca'; + + @override + String get windowsStartupDisabledByUser => + 'Disabilitato dall’utente nelle impostazioni di Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Disabilitato da un criterio di Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponibile dopo l’installazione di BusyMax da un pacchetto MSIX.'; + + @override + String get windowsReminderExitNotice => + 'I promemoria si interrompono quando BusyMax viene chiuso completamente. Lascialo in esecuzione in background per riceverli.'; + + @override + String get windowsProductVersionLabel => 'Versione del prodotto'; + + @override + String get windowsPackageVersionLabel => 'Versione del pacchetto Windows'; + + @override + String get windowsUnpackaged => 'Non pacchettizzato'; + + @override + String get windowsAgendaLoadMore => 'Carica altri elementi dell’agenda'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -858,7 +895,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Aggiunge soltanto la versione del sistema operativo Linux e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.'; + 'Aggiunge soltanto il nome e la versione del sistema operativo e le impostazioni locali dell’applicazione. Non vengono inclusi log, dati degli account, nomi di file o altre informazioni diagnostiche.'; @override String get feedbackCategoryRequired => 'Seleziona una categoria.'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 7a62fb2a..7aa5c499 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -12,6 +12,41 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get windowsSupport => 'サポート'; + + @override + String get windowsThirdPartyLicenses => 'サードパーティ ライセンス'; + + @override + String get windowsSearch => '検索'; + + @override + String get windowsStartupDisabledByUser => 'Windows の設定でユーザーにより無効にされています。'; + + @override + String get windowsStartupDisabledByPolicy => 'Windows ポリシーにより無効にされています。'; + + @override + String get windowsStartupUnavailable => + 'BusyMax を MSIX パッケージからインストールすると利用できます。'; + + @override + String get windowsReminderExitNotice => + 'BusyMax を完全に終了するとリマインダーも停止します。受け取るにはバックグラウンドで実行したままにしてください。'; + + @override + String get windowsProductVersionLabel => '製品バージョン'; + + @override + String get windowsPackageVersionLabel => 'Windows パッケージ バージョン'; + + @override + String get windowsUnpackaged => 'パッケージ化されていません'; + + @override + String get windowsAgendaLoadMore => '予定をさらに読み込む'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -841,7 +876,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Linux オペレーティングシステムのバージョンとアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。'; + 'オペレーティングシステムの名前とバージョン、およびアプリのロケールのみが追加されます。ログ、アカウントデータ、ファイル名、その他の診断情報は含まれません。'; @override String get feedbackCategoryRequired => 'カテゴリーを選択してください。'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 9fde4a36..b4201284 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -12,6 +12,42 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get windowsSupport => '지원'; + + @override + String get windowsThirdPartyLicenses => '타사 라이선스'; + + @override + String get windowsSearch => '검색'; + + @override + String get windowsStartupDisabledByUser => + 'Windows 설정에서 사용자가 사용하지 않도록 설정했습니다.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Windows 정책에 의해 사용하지 않도록 설정되었습니다.'; + + @override + String get windowsStartupUnavailable => 'BusyMax를 MSIX 패키지로 설치하면 사용할 수 있습니다.'; + + @override + String get windowsReminderExitNotice => + 'BusyMax를 완전히 종료하면 미리 알림이 중지됩니다. 받으려면 백그라운드에서 계속 실행하세요.'; + + @override + String get windowsProductVersionLabel => '제품 버전'; + + @override + String get windowsPackageVersionLabel => 'Windows 패키지 버전'; + + @override + String get windowsUnpackaged => '패키지되지 않음'; + + @override + String get windowsAgendaLoadMore => '일정 항목 더 불러오기'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -842,7 +878,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Linux 운영 체제 버전과 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.'; + '운영 체제 이름과 버전 및 앱 로캘만 추가됩니다. 로그, 계정 데이터, 파일 이름 또는 기타 진단 정보는 포함되지 않습니다.'; @override String get feedbackCategoryRequired => '범주를 선택하세요.'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 85e923b7..97312c16 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -12,6 +12,42 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get windowsSupport => 'Support'; + + @override + String get windowsThirdPartyLicenses => 'Third-party licenses'; + + @override + String get windowsSearch => 'Search'; + + @override + String get windowsStartupDisabledByUser => + 'Disabled by the user in Windows Settings.'; + + @override + String get windowsStartupDisabledByPolicy => 'Disabled by Windows policy.'; + + @override + String get windowsStartupUnavailable => + 'Available after BusyMax is installed from an MSIX package.'; + + @override + String get windowsReminderExitNotice => + 'Reminders stop when BusyMax is fully quit. Keep it running in the background to receive them.'; + + @override + String get windowsProductVersionLabel => 'Product version'; + + @override + String get windowsPackageVersionLabel => 'Windows package version'; + + @override + String get windowsUnpackaged => 'Unpackaged'; + + @override + String get windowsAgendaLoadMore => 'Load more agenda items'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -872,7 +908,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Adds only your Linux operating-system version and application locale. No logs, account data, file names, or other diagnostics are included.'; + 'Adds only your operating-system name and version and application locale. No logs, account data, file names, or other diagnostics are included.'; @override String get feedbackCategoryRequired => 'Select a category.'; @@ -2640,6 +2676,43 @@ class AppLocalizationsPt extends AppLocalizations { class AppLocalizationsPtPt extends AppLocalizationsPt { AppLocalizationsPtPt() : super('pt_PT'); + @override + String get windowsSupport => 'Suporte'; + + @override + String get windowsThirdPartyLicenses => 'Licenças de terceiros'; + + @override + String get windowsSearch => 'Pesquisar'; + + @override + String get windowsStartupDisabledByUser => + 'Desativado pelo utilizador nas Definições do Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Desativado por uma política do Windows.'; + + @override + String get windowsStartupUnavailable => + 'Disponível após a instalação do BusyMax a partir de um pacote MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Os lembretes param quando o BusyMax é totalmente encerrado. Mantenha-o em execução em segundo plano para os receber.'; + + @override + String get windowsProductVersionLabel => 'Versão do produto'; + + @override + String get windowsPackageVersionLabel => 'Versão do pacote do Windows'; + + @override + String get windowsUnpackaged => 'Sem pacote'; + + @override + String get windowsAgendaLoadMore => 'Carregar mais itens da agenda'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -3487,7 +3560,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get feedbackTechnicalDetailsDisclosure => - 'Adiciona apenas a versão do sistema operativo Linux e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.'; + 'Adiciona apenas o nome e a versão do sistema operativo e a configuração regional da aplicação. Não são incluídos registos, dados de contas, nomes de ficheiros nem outros diagnósticos.'; @override String get feedbackCategoryRequired => 'Selecione uma categoria.'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 3e4d744e..67601e98 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -12,6 +12,42 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get windowsSupport => 'Поддержка'; + + @override + String get windowsThirdPartyLicenses => 'Лицензии сторонних компонентов'; + + @override + String get windowsSearch => 'Поиск'; + + @override + String get windowsStartupDisabledByUser => + 'Отключено пользователем в параметрах Windows.'; + + @override + String get windowsStartupDisabledByPolicy => 'Отключено политикой Windows.'; + + @override + String get windowsStartupUnavailable => + 'Доступно после установки BusyMax из пакета MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Напоминания прекращаются после полного выхода из BusyMax. Оставьте приложение работать в фоновом режиме, чтобы получать их.'; + + @override + String get windowsProductVersionLabel => 'Версия продукта'; + + @override + String get windowsPackageVersionLabel => 'Версия пакета Windows'; + + @override + String get windowsUnpackaged => 'Без пакета'; + + @override + String get windowsAgendaLoadMore => 'Загрузить ещё элементы повестки'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -867,7 +903,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Будут добавлены только версия Linux и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.'; + 'Будут добавлены только название и версия операционной системы и выбранный язык приложения. Журналы, данные аккаунтов, имена файлов и другие диагностические сведения не добавляются.'; @override String get feedbackCategoryRequired => 'Выберите категорию.'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 99cbd0ac..48bfa36b 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -12,6 +12,43 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String get windowsSupport => 'Hỗ trợ'; + + @override + String get windowsThirdPartyLicenses => 'Giấy phép bên thứ ba'; + + @override + String get windowsSearch => 'Tìm kiếm'; + + @override + String get windowsStartupDisabledByUser => + 'Người dùng đã tắt trong Cài đặt Windows.'; + + @override + String get windowsStartupDisabledByPolicy => + 'Đã tắt theo chính sách Windows.'; + + @override + String get windowsStartupUnavailable => + 'Khả dụng sau khi cài đặt BusyMax từ gói MSIX.'; + + @override + String get windowsReminderExitNotice => + 'Lời nhắc sẽ dừng khi thoát hẳn BusyMax. Hãy để ứng dụng chạy trong nền để nhận lời nhắc.'; + + @override + String get windowsProductVersionLabel => 'Phiên bản sản phẩm'; + + @override + String get windowsPackageVersionLabel => 'Phiên bản gói Windows'; + + @override + String get windowsUnpackaged => 'Chưa đóng gói'; + + @override + String get windowsAgendaLoadMore => 'Tải thêm mục lịch biểu'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -854,7 +891,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - 'Chỉ thêm phiên bản hệ điều hành Linux và ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.'; + 'Chỉ thêm tên và phiên bản hệ điều hành cùng ngôn ngữ, khu vực của ứng dụng. Không bao gồm nhật ký, dữ liệu tài khoản, tên tệp hoặc thông tin chẩn đoán khác.'; @override String get feedbackCategoryRequired => 'Hãy chọn một danh mục.'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 6767327f..21ea0962 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -12,6 +12,40 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get windowsSupport => '支持'; + + @override + String get windowsThirdPartyLicenses => '第三方许可证'; + + @override + String get windowsSearch => '搜索'; + + @override + String get windowsStartupDisabledByUser => '用户已在 Windows 设置中禁用。'; + + @override + String get windowsStartupDisabledByPolicy => '已由 Windows 策略禁用。'; + + @override + String get windowsStartupUnavailable => '从 MSIX 包安装 BusyMax 后可用。'; + + @override + String get windowsReminderExitNotice => + '完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。'; + + @override + String get windowsProductVersionLabel => '产品版本'; + + @override + String get windowsPackageVersionLabel => 'Windows 包版本'; + + @override + String get windowsUnpackaged => '未打包'; + + @override + String get windowsAgendaLoadMore => '載入更多議程項目'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -835,7 +869,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get feedbackTechnicalDetailsDisclosure => - '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; + '仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; @override String get feedbackCategoryRequired => '请选择类别。'; @@ -2538,6 +2572,40 @@ class AppLocalizationsZh extends AppLocalizations { class AppLocalizationsZhHans extends AppLocalizationsZh { AppLocalizationsZhHans() : super('zh_Hans'); + @override + String get windowsSupport => '支持'; + + @override + String get windowsThirdPartyLicenses => '第三方许可证'; + + @override + String get windowsSearch => '搜索'; + + @override + String get windowsStartupDisabledByUser => '用户已在 Windows 设置中禁用。'; + + @override + String get windowsStartupDisabledByPolicy => '已由 Windows 策略禁用。'; + + @override + String get windowsStartupUnavailable => '从 MSIX 包安装 BusyMax 后可用。'; + + @override + String get windowsReminderExitNotice => + '完全退出 BusyMax 后,提醒将停止。请让应用在后台运行以接收提醒。'; + + @override + String get windowsProductVersionLabel => '产品版本'; + + @override + String get windowsPackageVersionLabel => 'Windows 包版本'; + + @override + String get windowsUnpackaged => '未打包'; + + @override + String get windowsAgendaLoadMore => '加载更多议程项目'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -3361,7 +3429,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get feedbackTechnicalDetailsDisclosure => - '仅添加您的 Linux 操作系统版本和应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; + '仅添加您的操作系统名称和版本以及应用区域设置。不包含日志、账户数据、文件名或其他诊断信息。'; @override String get feedbackCategoryRequired => '请选择类别。'; @@ -5064,6 +5132,40 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { class AppLocalizationsZhHant extends AppLocalizationsZh { AppLocalizationsZhHant() : super('zh_Hant'); + @override + String get windowsSupport => '支援'; + + @override + String get windowsThirdPartyLicenses => '第三方授權'; + + @override + String get windowsSearch => '搜尋'; + + @override + String get windowsStartupDisabledByUser => '使用者已在 Windows 設定中停用。'; + + @override + String get windowsStartupDisabledByPolicy => '已由 Windows 原則停用。'; + + @override + String get windowsStartupUnavailable => '從 MSIX 套件安裝 BusyMax 後即可使用。'; + + @override + String get windowsReminderExitNotice => + '完全結束 BusyMax 後,提醒會停止。請讓應用程式在背景執行以接收提醒。'; + + @override + String get windowsProductVersionLabel => '產品版本'; + + @override + String get windowsPackageVersionLabel => 'Windows 套件版本'; + + @override + String get windowsUnpackaged => '未封裝'; + + @override + String get windowsAgendaLoadMore => '載入更多議程項目'; + @override String repeatWeeklyDaySummary(String dayKey, String day) { String _temp0 = intl.Intl.selectLogic(dayKey, { @@ -5887,7 +5989,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get feedbackTechnicalDetailsDisclosure => - '只會加入您的 Linux 作業系統版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。'; + '只會加入您的作業系統名稱與版本和應用程式語系。不會包含記錄、帳戶資料、檔案名稱或其他診斷資訊。'; @override String get feedbackCategoryRequired => '請選擇類別。'; diff --git a/lib/main.dart b/lib/main.dart index 21456796..163a824f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,106 +1,3 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:system_theme/system_theme.dart'; +import 'main_linux.dart' as linux; -import 'src/app/app_bootstrap.dart'; -import 'src/app/app_theme.dart'; -import 'src/app/busymax_app.dart'; -import 'src/config/build_config.dart'; -import 'src/core/logging/redacting_logger.dart'; -import 'src/demo/demo_profile.dart'; -import 'src/platform/gtk_font_service.dart'; -import 'src/platform/linux_header_bar_service.dart'; - -Future main(List arguments) async { - final binding = WidgetsFlutterBinding.ensureInitialized(); - binding.deferFirstFrame(); - final buildConfig = BuildConfig.fromEnvironment(); - final LocalSettingsStore settingsStore; - if (buildConfig.useFakeProviderData) { - final settings = busyMaxDemoSettings(buildConfig.demoTheme); - settingsStore = InMemoryLocalSettingsStore(settings.toJson()); - } else { - settingsStore = const JsonFileLocalSettingsStore(); - } - - final systemAccentFuture = SystemTheme.accentColor.load(); - final initialGtkFontFuture = const GtkFontService().getGtkFont(); - final initialAppSettingsFuture = loadInitialAppSettings(settingsStore); - final initialAppSettings = await initialAppSettingsFuture; - final gtkThemeService = const GtkThemeService(); - await gtkThemeService.setPreferDark( - switch (initialAppSettings.themeModePreference) { - BusyMaxThemeModePreference.system => null, - BusyMaxThemeModePreference.light => false, - BusyMaxThemeModePreference.dark => true, - }, - ); - final desktopSettings = await Future.wait([ - systemAccentFuture, - initialGtkFontFuture, - gtkThemeService.getGtkThemeColors(), - ]); - configureLogging(); - - final initialGtkFont = desktopSettings[1] as GtkFontSettings?; - final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; - await _applyInitialNativeHeaderBarTheme( - settings: initialAppSettings, - gtkFont: initialGtkFont, - gtkThemeColors: initialGtkThemeColors, - ); - - final overrides = [ - buildConfigProvider.overrideWithValue(buildConfig), - localSettingsStoreProvider.overrideWithValue(settingsStore), - initialAppSettingsProvider.overrideWithValue(initialAppSettings), - initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), - initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), - ]; - final demoProfile = buildConfig.useFakeProviderData - ? await BusyMaxDemoProfile.create() - : null; - final applicationOverrides = [...overrides, ...?demoProfile?.overrides]; - - runApp( - ProviderScope( - overrides: applicationOverrides, - child: BusyMaxApp( - startMinimizedAtLaunch: arguments.contains('--start-minimized'), - ), - ), - ); - binding.allowFirstFrame(); -} - -Future _applyInitialNativeHeaderBarTheme({ - required AppSettings settings, - required GtkFontSettings? gtkFont, - required GtkThemeColors? gtkThemeColors, -}) async { - final platformDispatcher = WidgetsBinding.instance.platformDispatcher; - final brightness = switch (settings.themeModePreference) { - BusyMaxThemeModePreference.system => platformDispatcher.platformBrightness, - BusyMaxThemeModePreference.light => Brightness.light, - BusyMaxThemeModePreference.dark => Brightness.dark, - }; - final highContrast = platformDispatcher.accessibilityFeatures.highContrast; - final theme = buildBusyMaxTheme( - brightness: brightness, - accentColor: gtkThemeColors?.accent ?? SystemTheme.accentColor.accent, - family: settings.themeFamily, - gtkFontFamily: gtkFont?.family, - gtkFontSize: gtkFont?.size, - gtkThemeColors: gtkThemeColors, - highContrast: highContrast, - ); - final headerBarService = LinuxHeaderBarService(); - try { - await headerBarService.initialize(); - await headerBarService.setTheme( - busyMaxHeaderBarThemeFor(theme, highContrast: highContrast), - ); - } finally { - headerBarService.dispose(); - } -} +Future main(List arguments) => linux.main(arguments); diff --git a/lib/main_linux.dart b/lib/main_linux.dart new file mode 100644 index 00000000..82c68979 --- /dev/null +++ b/lib/main_linux.dart @@ -0,0 +1,145 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:system_theme/system_theme.dart'; + +import 'src/app/app_bootstrap.dart'; +import 'src/app/app_theme.dart'; +import 'src/app/busymax_app.dart'; +import 'src/config/build_config.dart'; +import 'src/core/logging/redacting_logger.dart'; +import 'src/core/secrets/portal_encrypted_secret_store.dart'; +import 'src/core/time/linux_gweather_location_source.dart'; +import 'src/core/time/local_time_zone.dart'; +import 'src/core/time/time_zone_catalog.dart'; +import 'src/demo/demo_profile.dart'; +import 'src/platform/common/desktop_services.dart'; +import 'src/platform/external_calendar_open_service.dart'; +import 'src/platform/gtk_font_service.dart'; +import 'src/platform/linux/linux_notification_backend.dart'; +import 'src/platform/linux_autostart_service.dart'; +import 'src/platform/linux_header_bar_service.dart'; +import 'src/platform/linux_window_service.dart'; + +Future main(List arguments) async { + final binding = WidgetsFlutterBinding.ensureInitialized(); + binding.deferFirstFrame(); + final buildConfig = BuildConfig.fromEnvironment(); + final LocalSettingsStore settingsStore; + if (buildConfig.useFakeProviderData) { + final settings = busyMaxDemoSettings(buildConfig.demoTheme); + settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + } else { + settingsStore = const JsonFileLocalSettingsStore(); + } + + BusyMaxTimeZoneCatalog.configureSystemLocationLoader( + loadLinuxSystemTimeZoneLocations, + ); + const timeZoneSource = LinuxLocalTimeZoneSource(); + final localTimeZone = await timeZoneSource.currentIanaTimeZone(); + final activationService = ExternalCalendarOpenService(); + await activationService.initialize(); + + final systemAccentFuture = SystemTheme.accentColor.load(); + final initialGtkFontFuture = const GtkFontService().getGtkFont(); + final initialAppSettingsFuture = loadInitialAppSettings(settingsStore); + final initialAppSettings = await initialAppSettingsFuture; + final gtkThemeService = const GtkThemeService(); + await gtkThemeService.setPreferDark( + switch (initialAppSettings.themeModePreference) { + BusyMaxThemeModePreference.system => null, + BusyMaxThemeModePreference.light => false, + BusyMaxThemeModePreference.dark => true, + }, + ); + final desktopSettings = await Future.wait([ + systemAccentFuture, + initialGtkFontFuture, + gtkThemeService.getGtkThemeColors(), + ]); + configureLogging(); + + final initialGtkFont = desktopSettings[1] as GtkFontSettings?; + final initialGtkThemeColors = desktopSettings[2] as GtkThemeColors?; + await _applyInitialNativeHeaderBarTheme( + settings: initialAppSettings, + gtkFont: initialGtkFont, + gtkThemeColors: initialGtkThemeColors, + ); + + final overrides = [ + buildConfigProvider.overrideWithValue(buildConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), + initialAppSettingsProvider.overrideWithValue(initialAppSettings), + initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), + initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), + desktopWindowServiceProvider.overrideWithValue(const LinuxWindowService()), + desktopAutostartServiceProvider.overrideWith( + (ref) => LinuxAutostartService(), + ), + desktopActivationServiceProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(activationService.dispose())); + return activationService; + }), + desktopNotificationBackendProvider.overrideWith((ref) { + final backend = FreedesktopNotificationBackend(); + ref.onDispose(() => unawaited(backend.close())); + return backend; + }), + localTimeZoneSourceProvider.overrideWithValue(timeZoneSource), + localTimeZoneProvider.overrideWithValue(localTimeZone), + if (Platform.environment['SNAP']?.isNotEmpty ?? false) + secretStoreProvider.overrideWith((ref) => PortalEncryptedSecretStore()), + ]; + final demoProfile = buildConfig.useFakeProviderData + ? await BusyMaxDemoProfile.create() + : null; + final applicationOverrides = [...overrides, ...?demoProfile?.overrides]; + + runApp( + ProviderScope( + overrides: applicationOverrides, + child: LinuxBusyMaxApp( + startMinimizedAtLaunch: arguments.contains( + busyMaxStartMinimizedArgument, + ), + ), + ), + ); + binding.allowFirstFrame(); +} + +Future _applyInitialNativeHeaderBarTheme({ + required AppSettings settings, + required GtkFontSettings? gtkFont, + required GtkThemeColors? gtkThemeColors, +}) async { + final platformDispatcher = WidgetsBinding.instance.platformDispatcher; + final brightness = switch (settings.themeModePreference) { + BusyMaxThemeModePreference.system => platformDispatcher.platformBrightness, + BusyMaxThemeModePreference.light => Brightness.light, + BusyMaxThemeModePreference.dark => Brightness.dark, + }; + final highContrast = platformDispatcher.accessibilityFeatures.highContrast; + final theme = buildBusyMaxTheme( + brightness: brightness, + accentColor: gtkThemeColors?.accent ?? SystemTheme.accentColor.accent, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + highContrast: highContrast, + ); + final headerBarService = LinuxHeaderBarService(); + try { + await headerBarService.initialize(); + await headerBarService.setTheme( + busyMaxHeaderBarThemeFor(theme, highContrast: highContrast), + ); + } finally { + headerBarService.dispose(); + } +} diff --git a/lib/main_windows.dart b/lib/main_windows.dart new file mode 100644 index 00000000..afebecb7 --- /dev/null +++ b/lib/main_windows.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:system_theme/system_theme.dart'; + +import 'src/app/app_bootstrap.dart'; +import 'src/app/windows/windows_busymax_app.dart'; +import 'src/config/build_config.dart'; +import 'src/core/logging/redacting_logger.dart'; +import 'src/demo/demo_profile.dart'; +import 'src/features/notifications/desktop_notification_backend.dart'; +import 'src/platform/common/desktop_services.dart'; +import 'src/platform/windows/windows_activation_service.dart'; +import 'src/platform/windows/windows_autostart_service.dart'; +import 'src/platform/windows/windows_notification_backend.dart'; +import 'src/platform/windows/windows_system_appearance_source.dart'; +import 'src/platform/windows/windows_time_zone_source.dart'; +import 'src/platform/windows/windows_window_service.dart'; + +Future main(List arguments) async { + final binding = WidgetsFlutterBinding.ensureInitialized(); + binding.deferFirstFrame(); + final buildConfig = BuildConfig.fromEnvironment(); + if (arguments.contains(busyMaxNotificationForwarderArgument)) { + await _runNotificationActivationForwarder(buildConfig); + return; + } + final LocalSettingsStore settingsStore; + if (buildConfig.useFakeProviderData) { + final settings = busyMaxDemoSettings(buildConfig.demoTheme); + settingsStore = InMemoryLocalSettingsStore(settings.toJson()); + } else { + settingsStore = const JsonFileLocalSettingsStore(); + } + + await SystemTheme.accentColor.load(); + final initialSettings = await loadInitialAppSettings(settingsStore); + final timeZoneSource = WindowsLocalTimeZoneSource(); + final localTimeZone = await timeZoneSource.currentIanaTimeZone(); + final activationService = WindowsActivationService(); + await activationService.initialize(); + final appearanceSource = WindowsSystemAppearanceSource(); + DesktopNotificationBackend notificationBackend = + const DisabledDesktopNotificationBackend(); + if (buildConfig.windowsAppUserModelId.trim().isNotEmpty) { + try { + notificationBackend = await WindowsNotificationBackend.create( + appUserModelId: buildConfig.windowsAppUserModelId, + onActivation: activationService.accept, + ); + } on Object { + notificationBackend = const DisabledDesktopNotificationBackend(); + } + } + configureLogging(); + + final overrides = [ + buildConfigProvider.overrideWithValue(buildConfig), + localSettingsStoreProvider.overrideWithValue(settingsStore), + initialAppSettingsProvider.overrideWithValue(initialSettings), + desktopWindowServiceProvider.overrideWithValue( + const WindowsWindowService(), + ), + desktopAutostartServiceProvider.overrideWithValue( + const WindowsAutostartService(), + ), + desktopActivationServiceProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(activationService.dispose())); + return activationService; + }), + desktopNotificationBackendProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(notificationBackend.close())); + return notificationBackend; + }), + systemAppearanceSourceProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(appearanceSource.dispose())); + return appearanceSource; + }), + localTimeZoneSourceProvider.overrideWithValue(timeZoneSource), + localTimeZoneProvider.overrideWithValue(localTimeZone), + ]; + final demoProfile = buildConfig.useFakeProviderData + ? await BusyMaxDemoProfile.create() + : null; + + runApp( + ProviderScope( + overrides: [...overrides, ...?demoProfile?.overrides], + child: WindowsBusyMaxApp( + startMinimizedAtLaunch: + arguments.contains(busyMaxStartMinimizedArgument) || + arguments.contains(busyMaxNotificationActivationServerArgument), + ), + ), + ); + binding.allowFirstFrame(); +} + +Future _runNotificationActivationForwarder( + BuildConfig buildConfig, +) async { + final completed = Completer(); + var finishing = false; + WindowsNotificationBackend? backend; + + Future finish([DesktopActivation? activation]) async { + if (finishing) return; + finishing = true; + try { + if (activation != null) { + await forwardWindowsActivationToPrimary(activation); + } + await backend?.close(); + } finally { + if (!completed.isCompleted) completed.complete(); + await const WindowsWindowService().quitApp(); + } + } + + if (buildConfig.windowsAppUserModelId.trim().isEmpty) { + await finish(); + return; + } + try { + backend = await WindowsNotificationBackend.create( + appUserModelId: buildConfig.windowsAppUserModelId, + onActivation: (activation) => unawaited(finish(activation)), + ); + } on Object { + await finish(); + return; + } + final timeout = Timer(const Duration(seconds: 15), () => unawaited(finish())); + await completed.future; + timeout.cancel(); +} diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 31e76987..57e31ec9 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:drift/drift.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -8,7 +7,6 @@ import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../config/build_config.dart'; -import '../core/time/local_time_zone.dart'; import '../db/app_database.dart'; import '../dav/auth/dav_account_onboarding_service.dart'; import '../dav/auth/nextcloud_app_password_revoker.dart'; @@ -48,26 +46,21 @@ import '../google_tasks/http/retrying_http_client.dart'; import '../google_tasks/oauth/oauth_loopback_flow.dart'; import '../google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; -import 'package:busymax/src/core/secrets/portal_encrypted_secret_store.dart'; import '../google_calendar/google_calendar_api_client.dart'; import '../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_task_remote_client.dart'; import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; -import '../platform/linux_window_service.dart'; -import '../platform/external_calendar_open_service.dart'; -import '../platform/linux_autostart_service.dart'; +import '../platform/common/desktop_services.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../schedule/schedule_commands.dart'; import '../schedule/schedule_repository.dart'; import '../webcal/webcal_http_client.dart'; import '../webcal/webcal_subscription_service.dart'; -import 'app_router.dart'; import 'app_settings.dart'; export '../app/app_settings.dart'; -export '../platform/linux_header_bar_provider.dart'; final buildConfigProvider = Provider( (ref) => BuildConfig.fromEnvironment(), @@ -195,11 +188,6 @@ final feedbackSubmissionServiceProvider = Provider(( }); final secretStoreProvider = Provider((ref) { - if (Platform.isLinux && (Platform.environment['SNAP']?.isNotEmpty ?? false)) { - // flutter_secure_storage_linux warms up direct libsecret first, so - // SECRET_BACKEND=file cannot avoid a locked keyring inside strict snaps. - return PortalEncryptedSecretStore(); - } return SecureSecretStore(ref.watch(secureStorageProvider)); }); @@ -240,13 +228,7 @@ final authenticatedHttpClientProvider = Provider((ref) { }); final desktopNotificationBackendProvider = Provider( - (ref) { - final backend = FreedesktopNotificationBackend(); - ref.onDispose(() { - unawaited(backend.close()); - }); - return backend; - }, + (ref) => const DisabledDesktopNotificationBackend(), ); final desktopNotificationServiceProvider = Provider( @@ -260,26 +242,46 @@ final desktopNotificationServiceProvider = Provider( }, ); -final linuxWindowServiceProvider = Provider( - (ref) => const LinuxWindowService(), +final desktopWindowServiceProvider = Provider( + (ref) => const NoOpDesktopWindowService(), ); -final externalCalendarOpenServiceProvider = - Provider((ref) { - final service = ExternalCalendarOpenService(); - unawaited(service.initialize()); - ref.onDispose(() => unawaited(service.dispose())); - return service; - }); +final linuxWindowServiceProvider = desktopWindowServiceProvider; + +final desktopActivationServiceProvider = Provider( + (ref) => const NoOpDesktopActivationService(), +); + +final externalCalendarOpenServiceProvider = desktopActivationServiceProvider; + +final desktopAutostartServiceProvider = Provider( + (ref) => const UnavailableDesktopAutostartService(), +); -final linuxAutostartServiceProvider = Provider( - (ref) => LinuxAutostartService(), +final linuxAutostartServiceProvider = desktopAutostartServiceProvider; + +final launchAtLoginStateProvider = FutureProvider( + (ref) => ref.watch(desktopAutostartServiceProvider).state(), ); final launchAtLoginEnabledProvider = FutureProvider( - (ref) => ref.watch(linuxAutostartServiceProvider).isEnabled(), + (ref) async => + await ref.watch(launchAtLoginStateProvider.future) == + DesktopAutostartState.enabled, ); +final desktopNavigationServiceProvider = Provider(( + ref, +) { + final service = QueuedDesktopNavigationService(); + ref.onDispose(() => unawaited(service.dispose())); + return service; +}); + +final systemAppearanceSourceProvider = Provider((ref) { + throw StateError('A platform SystemAppearanceSource was not configured.'); +}); + final authRepositoryProvider = Provider((ref) { return AuthRepository( oAuth: ref.watch(applicationOAuthGatewayProvider), @@ -374,7 +376,11 @@ final selectedAccountCapabilitiesProvider = : adapterDefaultTaskCapabilities(account.provider); }); -final localTimeZoneProvider = Provider((ref) => localIanaTimeZone()); +final localTimeZoneSourceProvider = Provider( + (ref) => const FixedLocalTimeZoneSource('Etc/UTC'), +); + +final localTimeZoneProvider = Provider((ref) => 'Etc/UTC'); final googleTasksApiClientForAccountProvider = Provider.family((ref, accountId) { @@ -1034,7 +1040,7 @@ Future _openNotificationSource( return switch (row.sourceType) { 'event' => _openEventNotification(ref, row), 'task' => _openTaskNotification(ref, row), - _ => ref.read(linuxWindowServiceProvider).showWindow(), + _ => ref.read(desktopWindowServiceProvider).showWindow(), }; } @@ -1053,7 +1059,7 @@ Future _openEventNotification( )) .getSingleOrNull(); if (event == null) { - await ref.read(linuxWindowServiceProvider).showWindow(); + await ref.read(desktopWindowServiceProvider).showWindow(); return; } @@ -1083,7 +1089,7 @@ Future _openTaskNotification( ..limit(1)) .getSingleOrNull(); if (task == null) { - await ref.read(linuxWindowServiceProvider).showWindow(); + await ref.read(desktopWindowServiceProvider).showWindow(); return; } @@ -1105,7 +1111,7 @@ Future _openScheduleItemFromNotification( required String sourceId, required String itemId, }) async { - await ref.read(linuxWindowServiceProvider).showWindow(); + await ref.read(desktopWindowServiceProvider).showWindow(); final sequenceController = ref.read( _notificationOpenSequenceProvider.notifier, ); @@ -1121,7 +1127,9 @@ Future _openScheduleItemFromNotification( sourceId: sourceId, itemId: itemId, ); - ref.read(appRouterProvider).go('/schedule'); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.schedule); } DateTime? _calendarEventCommandDate(CalendarEvent event) { diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index f58300a2..b99eb057 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -10,9 +10,9 @@ import '../features/tray/domain/tray_presentation.dart'; import '../features/tray/domain/tray_presentation_formatter.dart'; import '../platform/gtk_font_service.dart'; import '../platform/linux_header_bar_configuration_synchronizer.dart'; +import '../platform/linux_header_bar_provider.dart'; import '../platform/linux_header_bar_service.dart'; -import '../platform/linux_window_service.dart'; -import '../platform/external_calendar_open_service.dart'; +import '../platform/common/desktop_services.dart'; import '../l10n/locale_resolution.dart'; import '../schedule/schedule_commands.dart'; import 'app_bootstrap.dart'; @@ -61,8 +61,8 @@ BusyMaxHeaderBarTheme busyMaxHeaderBarThemeFor( ); } -class BusyMaxApp extends ConsumerStatefulWidget { - const BusyMaxApp({ +class LinuxBusyMaxApp extends ConsumerStatefulWidget { + const LinuxBusyMaxApp({ super.key, this.trayServiceFactory, this.startMinimizedAtLaunch = false, @@ -73,10 +73,18 @@ class BusyMaxApp extends ConsumerStatefulWidget { final bool startMinimizedAtLaunch; @override - ConsumerState createState() => _BusyMaxAppState(); + ConsumerState createState() => _BusyMaxAppState(); } -class _BusyMaxAppState extends ConsumerState { +class BusyMaxApp extends LinuxBusyMaxApp { + const BusyMaxApp({ + super.key, + super.trayServiceFactory, + super.startMinimizedAtLaunch, + }); +} + +class _BusyMaxAppState extends ConsumerState { BusyMaxTrayService? _trayService; bool? _lastHideOnClose; bool? _lastTrayEnabled; @@ -84,7 +92,8 @@ class _BusyMaxAppState extends ConsumerState { bool _settingsReady = false; var _scheduleCommandSequence = 0; BusyMaxTrayPresentationFormatter? _trayPresentationFormatter; - StreamSubscription? _externalOpenSubscription; + StreamSubscription? _externalOpenSubscription; + StreamSubscription? _navigationSubscription; Future _externalOpenTail = Future.value(); late final BusyMaxHeaderBarConfigurationSynchronizer _headerBarConfigurationSynchronizer; @@ -97,13 +106,17 @@ class _BusyMaxAppState extends ConsumerState { ref.read(linuxHeaderBarServiceProvider), ); _externalOpenSubscription = ref - .read(externalCalendarOpenServiceProvider) - .requests + .read(desktopActivationServiceProvider) + .activations .listen((request) { _externalOpenTail = _externalOpenTail .catchError((Object _) {}) .then((_) => _handleExternalCalendarOpen(request)); }); + _navigationSubscription = ref + .read(desktopNavigationServiceProvider) + .requests + .listen(_handleNavigationRequest); unawaited(_waitForSettings()); } @@ -111,6 +124,7 @@ class _BusyMaxAppState extends ConsumerState { void dispose() { _headerBarConfigurationSynchronizer.dispose(); unawaited(_externalOpenSubscription?.cancel()); + unawaited(_navigationSubscription?.cancel()); final tray = _trayService; if (tray != null) { unawaited(tray.stop()); @@ -118,34 +132,51 @@ class _BusyMaxAppState extends ConsumerState { super.dispose(); } - Future _handleExternalCalendarOpen( - ExternalCalendarOpenRequest request, - ) async { + Future _handleExternalCalendarOpen(DesktopActivation request) async { if (!mounted) return; final router = ref.read(appRouterProvider); switch (request.kind) { - case ExternalCalendarOpenKind.webCal: + case DesktopActivationKind.webCal: router.go('/settings?page=accounts'); - case ExternalCalendarOpenKind.icsFile: + case DesktopActivationKind.icsFile: final session = ref.read(authSessionControllerProvider); router.go(session.isSignedIn ? '/schedule' : '/settings?page=accounts'); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + return; } await WidgetsBinding.instance.endOfFrame; if (!mounted) return; final dialogContext = rootNavigatorKey.currentContext; if (dialogContext == null || !dialogContext.mounted) return; switch (request.kind) { - case ExternalCalendarOpenKind.webCal: + case DesktopActivationKind.webCal: await showAddCalendarSubscriptionFlow( dialogContext, ref, - initialUrl: request.value, + initialUrl: request.value!, ); - case ExternalCalendarOpenKind.icsFile: - await showIcsImportFlow(dialogContext, ref, filePath: request.value); + case DesktopActivationKind.icsFile: + await showIcsImportFlow(dialogContext, ref, filePath: request.value!); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + return; } } + void _handleNavigationRequest(DesktopNavigationRequest request) { + if (!mounted) return; + final router = ref.read(appRouterProvider); + router.go(switch (request.destination) { + DesktopNavigationDestination.schedule => '/schedule', + DesktopNavigationDestination.tasks => '/tasks', + DesktopNavigationDestination.settings => '/settings', + DesktopNavigationDestination.signIn => '/sign-in', + }); + } + Future _waitForSettings() async { await ref.read(appSettingsControllerProvider.notifier).ready; if (!mounted) { @@ -370,7 +401,7 @@ class _BusyMaxAppState extends ConsumerState { return; } - final windowService = ref.read(linuxWindowServiceProvider); + final windowService = ref.read(desktopWindowServiceProvider); final trayEnabled = settings.showTrayIcon || settings.runInBackgroundWhenClosed || @@ -411,7 +442,7 @@ class _BusyMaxAppState extends ConsumerState { } BusyMaxTrayService _createTrayService({ - required LinuxWindowService windowService, + required DesktopWindowService windowService, required BusyMaxTrayPresentationFormatter formatter, required AppSettings settings, }) { @@ -460,20 +491,20 @@ class _BusyMaxAppState extends ConsumerState { return formatter.format(presentation); } - Future _openTrayNewEvent(LinuxWindowService windowService) async { + Future _openTrayNewEvent(DesktopWindowService windowService) async { await windowService.showWindow(); ref.read(appRouterProvider).go('/schedule'); _issueScheduleCommand(ScheduleWorkspaceCommandKind.newEvent); } - Future _openTrayNewTask(LinuxWindowService windowService) async { + Future _openTrayNewTask(DesktopWindowService windowService) async { await windowService.showWindow(); ref.read(appRouterProvider).go('/tasks'); _issueScheduleCommand(ScheduleWorkspaceCommandKind.newTask); } Future _openTrayEvent( - LinuxWindowService windowService, + DesktopWindowService windowService, BusyMaxTrayEventEntry event, ) async { await windowService.showWindow(); @@ -487,7 +518,9 @@ class _BusyMaxAppState extends ConsumerState { ); } - Future _openTrayTasksDueToday(LinuxWindowService windowService) async { + Future _openTrayTasksDueToday( + DesktopWindowService windowService, + ) async { await windowService.showWindow(); ref.read(appRouterProvider).go('/tasks'); _issueScheduleCommand( @@ -496,12 +529,12 @@ class _BusyMaxAppState extends ConsumerState { ); } - Future _openTraySettings(LinuxWindowService windowService) async { + Future _openTraySettings(DesktopWindowService windowService) async { await windowService.showWindow(); ref.read(appRouterProvider).go('/settings'); } - Future _openMainAgenda(LinuxWindowService windowService) async { + Future _openMainAgenda(DesktopWindowService windowService) async { await windowService.showWindow(); ref.read(appRouterProvider).go('/schedule'); _issueScheduleCommand( @@ -529,7 +562,7 @@ class _BusyMaxAppState extends ConsumerState { ); } - void _setHideOnClose(LinuxWindowService windowService, bool enabled) { + void _setHideOnClose(DesktopWindowService windowService, bool enabled) { if (_lastHideOnClose == enabled) { return; } @@ -539,7 +572,7 @@ class _BusyMaxAppState extends ConsumerState { Future _startTray( BusyMaxTrayService tray, - LinuxWindowService windowService, { + DesktopWindowService windowService, { required bool startMinimizedToTray, }) async { await tray.start(); diff --git a/lib/src/app/common/busymax_design_values.dart b/lib/src/app/common/busymax_design_values.dart new file mode 100644 index 00000000..3281ff20 --- /dev/null +++ b/lib/src/app/common/busymax_design_values.dart @@ -0,0 +1,72 @@ +import 'package:flutter/widgets.dart'; + +abstract final class BusyMaxSpacing { + static const double xSmall = 4; + static const double small = 8; + static const double medium = 12; + static const double large = 16; + static const double xLarge = 24; + static const double xxLarge = 32; +} + +abstract final class BusyMaxDimensions { + static const double minimumWindowWidth = 900; + static const double minimumWindowHeight = 600; + static const double initialWindowWidth = 1280; + static const double initialWindowHeight = 800; + static const double navigationCompactWidth = 48; + static const double navigationOpenWidth = 240; + static const double sourcePaneWidth = 264; + static const double editorWidth = 440; + static const double contentMaximumWidth = 1120; +} + +abstract final class BusyMaxBreakpoints { + static const double compact = 720; + static const double sourcePane = 980; + static const double wide = 1280; + + static bool showsSourcePane(double width) => width >= sourcePane; +} + +abstract final class BusyMaxMotion { + static const Duration fast = Duration(milliseconds: 100); + static const Duration normal = Duration(milliseconds: 167); + static const Duration slow = Duration(milliseconds: 250); +} + +enum BusyMaxSemanticColorRole { + accent, + canvas, + surface, + foreground, + secondaryForeground, + border, + success, + warning, + error, + information, +} + +enum BusyMaxInteractionState { + normal, + hovered, + pressed, + focused, + selected, + disabled, +} + +@immutable +final class BusyMaxPaneState { + const BusyMaxPaneState({ + required this.availableWidth, + required this.sourcePaneRequested, + }); + + final double availableWidth; + final bool sourcePaneRequested; + + bool get showsSourcePane => + sourcePaneRequested && BusyMaxBreakpoints.showsSourcePane(availableWidth); +} diff --git a/lib/src/app/windows/windows_busymax_app.dart b/lib/src/app/windows/windows_busymax_app.dart new file mode 100644 index 00000000..01e83597 --- /dev/null +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -0,0 +1,260 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../features/auth/data/auth_repository.dart'; +import '../../l10n/locale_resolution.dart'; +import '../../platform/common/desktop_services.dart'; +import '../../ui/windows/windows_schedule_page.dart'; +import '../../ui/windows/windows_settings_page.dart'; +import '../../ui/windows/windows_sign_in_page.dart'; +import '../../ui/windows/windows_tasks_page.dart'; +import '../../ui/windows/windows_workspace_shell.dart'; +import '../../ui/windows/windows_desktop_runtime.dart'; +import '../../ui/windows/windows_calendar_activation_flows.dart'; +import '../app_bootstrap.dart'; + +final windowsRootNavigatorKey = GlobalKey(); + +final windowsAppRouter = GoRouter( + navigatorKey: windowsRootNavigatorKey, + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => const _WindowsHomeGate()), + GoRoute( + path: '/sign-in', + builder: (_, state) => WindowsSignInPage( + addingAccount: state.uri.queryParameters['add'] == 'true', + ), + ), + GoRoute( + path: '/schedule', + builder: (_, _) => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.schedule, + child: WindowsSchedulePage(), + ), + ), + GoRoute( + path: '/tasks', + builder: (_, _) => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.tasks, + child: WindowsTasksPage(), + ), + ), + GoRoute( + path: '/settings', + builder: (_, _) => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.settings, + child: WindowsSettingsPage(), + ), + ), + ], +); + +class WindowsBusyMaxApp extends ConsumerStatefulWidget { + const WindowsBusyMaxApp({super.key, this.startMinimizedAtLaunch = false}); + + final bool startMinimizedAtLaunch; + + @override + ConsumerState createState() => _WindowsBusyMaxAppState(); +} + +class _WindowsBusyMaxAppState extends ConsumerState + with WidgetsBindingObserver { + StreamSubscription? _activationSubscription; + StreamSubscription? _navigationSubscription; + StreamSubscription? _appearanceSubscription; + int _platformChangeSequence = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _activationSubscription = ref + .read(desktopActivationServiceProvider) + .activations + .listen(_handleActivation); + _navigationSubscription = ref + .read(desktopNavigationServiceProvider) + .requests + .listen(_handleNavigation); + _appearanceSubscription = ref + .read(systemAppearanceSourceProvider) + .changes + .listen((_) => _platformChanged()); + // Starts in-process reminder scheduling. Explicit Quit disposes it before + // asking the runner to terminate. + ref.read(notificationSchedulerProvider); + } + + @override + void didChangeAccessibilityFeatures() => _platformChanged(); + + @override + void didChangePlatformBrightness() => _platformChanged(); + + void _platformChanged() { + if (mounted) setState(() => _platformChangeSequence++); + } + + Future _handleActivation(DesktopActivation activation) async { + if (activation.requiresVisibleWindow) { + await ref.read(desktopWindowServiceProvider).showWindow(); + } + switch (activation.kind) { + case DesktopActivationKind.normalLaunch: + windowsAppRouter.go('/'); + case DesktopActivationKind.startMinimized: + return; + case DesktopActivationKind.icsFile: + windowsAppRouter.go('/schedule'); + await WidgetsBinding.instance.endOfFrame; + final context = windowsRootNavigatorKey.currentContext; + if (context != null && context.mounted) { + await showWindowsIcsImportFlow( + context, + ref, + filePath: activation.value, + ); + } + case DesktopActivationKind.webCal: + windowsAppRouter.go('/settings'); + await WidgetsBinding.instance.endOfFrame; + final context = windowsRootNavigatorKey.currentContext; + if (context != null && context.mounted) { + await showWindowsWebCalSubscriptionFlow( + context, + ref, + initialUrl: activation.value, + ); + } + case DesktopActivationKind.notification: + final scheduleId = activation.payload?['notificationScheduleId']; + if (scheduleId == null) return; + await ref + .read(notificationSchedulerProvider) + .handleActivation( + notificationScheduleId: scheduleId, + action: activation.action!, + ); + if (activation.action == 'default' || activation.action == 'open') { + windowsAppRouter.go('/schedule'); + } + } + } + + void _handleNavigation(DesktopNavigationRequest request) { + windowsAppRouter.go(switch (request.destination) { + DesktopNavigationDestination.schedule => '/schedule', + DesktopNavigationDestination.tasks => '/tasks', + DesktopNavigationDestination.settings => '/settings', + DesktopNavigationDestination.signIn => '/sign-in', + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_activationSubscription?.cancel()); + unawaited(_navigationSubscription?.cancel()); + unawaited(_appearanceSubscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(appSettingsControllerProvider); + final dispatcher = WidgetsBinding.instance.platformDispatcher; + final appearance = ref.watch(systemAppearanceSourceProvider); + final highContrast = appearance.highContrast; + final reducedMotion = dispatcher.accessibilityFeatures.disableAnimations; + final accentColor = appearance.accentColor?.toAccentColor(); + FluentThemeData theme(Brightness brightness) { + final background = brightness == Brightness.dark + ? Colors.black + : Colors.white; + final foreground = brightness == Brightness.dark + ? Colors.white + : Colors.black; + return FluentThemeData( + brightness: brightness, + accentColor: highContrast ? foreground.toAccentColor() : accentColor, + activeColor: highContrast ? background : null, + inactiveColor: highContrast ? foreground : null, + typography: _windowsTypography(brightness), + scaffoldBackgroundColor: highContrast ? background : null, + cardColor: highContrast ? background : null, + menuColor: highContrast ? background : null, + shadowColor: highContrast ? foreground : null, + fasterAnimationDuration: reducedMotion ? Duration.zero : null, + fastAnimationDuration: reducedMotion ? Duration.zero : null, + mediumAnimationDuration: reducedMotion ? Duration.zero : null, + slowAnimationDuration: reducedMotion ? Duration.zero : null, + ); + } + + return FluentApp.router( + key: ValueKey(( + _platformChangeSequence, + appearance.brightness, + highContrast, + reducedMotion, + )), + title: 'BusyMax', + debugShowCheckedModeBanner: false, + routerConfig: windowsAppRouter, + theme: theme(Brightness.light), + darkTheme: theme(Brightness.dark), + themeMode: settings.themeMode, + locale: settings.locale, + // FluentApp appends Fluent and framework delegates. BusyMax adds only + // its generated delegate here; Ubuntu delegates remain Linux-only. + localizationsDelegates: const [AppLocalizations.delegate], + localeListResolutionCallback: resolveBusyMaxLocales, + supportedLocales: busyMaxSupportedLocales, + builder: (context, child) => WindowsDesktopRuntime( + startMinimizedAtLaunch: widget.startMinimizedAtLaunch, + child: child ?? const SizedBox.shrink(), + ), + ); + } +} + +Typography _windowsTypography(Brightness brightness) { + final defaults = Typography.fromBrightness(brightness: brightness); + TextStyle? windowsFont(TextStyle? style) => style?.copyWith( + fontFamily: 'Segoe UI Variable', + fontFamilyFallback: const ['Segoe UI'], + ); + return Typography.raw( + display: windowsFont(defaults.display), + titleLarge: windowsFont(defaults.titleLarge), + title: windowsFont(defaults.title), + subtitle: windowsFont(defaults.subtitle), + bodyLarge: windowsFont(defaults.bodyLarge), + bodyStrong: windowsFont(defaults.bodyStrong), + body: windowsFont(defaults.body), + caption: windowsFont(defaults.caption), + ); +} + +class _WindowsHomeGate extends ConsumerWidget { + const _WindowsHomeGate(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(authSessionControllerProvider); + return switch (session.status) { + AuthSessionStatus.loading => const Center(child: ProgressRing()), + AuthSessionStatus.signedIn => const WindowsWorkspaceShell( + destination: WindowsWorkspaceDestination.schedule, + child: WindowsSchedulePage(), + ), + _ => const WindowsSignInPage(), + }; + } +} diff --git a/lib/src/config/build_config.dart b/lib/src/config/build_config.dart index df0974de..616470e0 100644 --- a/lib/src/config/build_config.dart +++ b/lib/src/config/build_config.dart @@ -11,6 +11,12 @@ class BuildConfig { this.microsoftGraphBaseUrl = 'https://graph.microsoft.com/v1.0', this.googleApiBaseUrl = 'https://www.googleapis.com', this.feedbackEndpoint = 'https://busystack.org/api/feedback', + this.privacyPolicyUrl = '', + this.supportUrl = '', + this.homepageUrl = 'https://busystack.org', + this.windowsAppUserModelId = '', + this.windowsPackageVersion = '', + this.windowsStoreMode = false, String? apiBaseUrl, required this.oauthAuthorizationEndpoint, required this.oauthTokenEndpoint, @@ -51,6 +57,23 @@ class BuildConfig { 'BUSYSTACK_FEEDBACK_ENDPOINT', defaultValue: 'https://busystack.org/api/feedback', ), + privacyPolicyUrl: const String.fromEnvironment( + 'BUSYMAX_PRIVACY_POLICY_URL', + ), + supportUrl: const String.fromEnvironment('BUSYMAX_SUPPORT_URL'), + homepageUrl: const String.fromEnvironment( + 'BUSYMAX_HOMEPAGE_URL', + defaultValue: 'https://busystack.org', + ), + windowsAppUserModelId: const String.fromEnvironment( + 'BUSYMAX_WINDOWS_AUMID', + ), + windowsPackageVersion: const String.fromEnvironment( + 'BUSYMAX_MSIX_VERSION', + ), + windowsStoreMode: const bool.fromEnvironment( + 'BUSYMAX_WINDOWS_STORE_MODE', + ), oauthAuthorizationEndpoint: const String.fromEnvironment( 'GOOGLE_OAUTH_AUTHORIZATION_ENDPOINT', defaultValue: 'https://accounts.google.com/o/oauth2/v2/auth', @@ -82,6 +105,12 @@ class BuildConfig { final String microsoftGraphBaseUrl; final String googleApiBaseUrl; final String feedbackEndpoint; + final String privacyPolicyUrl; + final String supportUrl; + final String homepageUrl; + final String windowsAppUserModelId; + final String windowsPackageVersion; + final bool windowsStoreMode; final String apiBaseUrl; final String oauthAuthorizationEndpoint; final String oauthTokenEndpoint; @@ -107,8 +136,8 @@ class BuildConfig { 'Please install an official build.'; } - return 'Missing provider configuration. Run with at least one provider:\n' - 'flutter run -d linux ' + return 'Missing provider configuration. Run the platform entrypoint with ' + 'at least one provider:\n' '--dart-define=GOOGLE_OAUTH_CLIENT_ID= ' '--dart-define=GOOGLE_OAUTH_CLIENT_SECRET= ' '--dart-define=MICROSOFT_OAUTH_CLIENT_ID='; diff --git a/lib/src/core/time/linux_gweather_location_source.dart b/lib/src/core/time/linux_gweather_location_source.dart index 9beb8788..d0949f8a 100644 --- a/lib/src/core/time/linux_gweather_location_source.dart +++ b/lib/src/core/time/linux_gweather_location_source.dart @@ -3,27 +3,9 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'package:flutter/foundation.dart'; - -@immutable -class BusyMaxSystemTimeZoneLocation { - const BusyMaxSystemTimeZoneLocation({ - required this.name, - required this.englishName, - required this.countryCode, - required this.timeZoneId, - }); - - final String name; - final String englishName; - final String? countryCode; - final String timeZoneId; +import 'system_time_zone_location.dart'; - bool matches(String normalizedQuery) { - return name.toLowerCase().contains(normalizedQuery) || - englishName.toLowerCase().contains(normalizedQuery); - } -} +export 'system_time_zone_location.dart'; Future> loadLinuxSystemTimeZoneLocations() async { diff --git a/lib/src/core/time/local_time_zone.dart b/lib/src/core/time/local_time_zone.dart index 59453c8a..31b6bb16 100644 --- a/lib/src/core/time/local_time_zone.dart +++ b/lib/src/core/time/local_time_zone.dart @@ -1,5 +1,17 @@ import 'dart:io'; +import '../../platform/common/desktop_services.dart'; + +final class LinuxLocalTimeZoneSource implements LocalTimeZoneSource { + const LinuxLocalTimeZoneSource(); + + @override + String? get diagnostic => null; + + @override + Future currentIanaTimeZone() async => localIanaTimeZone(); +} + String localIanaTimeZone() { try { final timezoneFile = File('/etc/timezone'); diff --git a/lib/src/core/time/system_time_zone_location.dart b/lib/src/core/time/system_time_zone_location.dart new file mode 100644 index 00000000..dbeb87f4 --- /dev/null +++ b/lib/src/core/time/system_time_zone_location.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class BusyMaxSystemTimeZoneLocation { + const BusyMaxSystemTimeZoneLocation({ + required this.name, + required this.englishName, + required this.countryCode, + required this.timeZoneId, + }); + + final String name; + final String englishName; + final String? countryCode; + final String timeZoneId; + + bool matches(String normalizedQuery) { + return name.toLowerCase().contains(normalizedQuery) || + englishName.toLowerCase().contains(normalizedQuery); + } +} diff --git a/lib/src/core/time/time_zone_catalog.dart b/lib/src/core/time/time_zone_catalog.dart index b59f4e6c..b71362b5 100644 --- a/lib/src/core/time/time_zone_catalog.dart +++ b/lib/src/core/time/time_zone_catalog.dart @@ -2,7 +2,10 @@ import 'package:flutter/foundation.dart' show immutable, visibleForTesting; import 'package:timezone/data/latest_all.dart' as time_zone_data; import 'package:timezone/timezone.dart' as time_zone; -import 'linux_gweather_location_source.dart'; +import 'system_time_zone_location.dart'; + +typedef BusyMaxSystemTimeZoneLocationLoader = + Future> Function(); @immutable class BusyMaxTimeZoneLocation { @@ -72,6 +75,17 @@ abstract final class BusyMaxTimeZoneCatalog { static List? _systemLocations; static Future>? _systemLocationLoad; static List? _locationSearchOptions; + static BusyMaxSystemTimeZoneLocationLoader _systemLocationLoader = + _emptySystemLocations; + + static void configureSystemLocationLoader( + BusyMaxSystemTimeZoneLocationLoader loader, + ) { + _systemLocationLoader = loader; + _systemLocations = null; + _systemLocationLoad = null; + _locationSearchOptions = null; + } static List get locations { return _locations ??= _buildLocations(); @@ -103,10 +117,13 @@ abstract final class BusyMaxTimeZoneCatalog { static bool get isLocationSearchReady => _systemLocations != null; static Future prepareLocationSearch() async { - final load = _systemLocationLoad ??= loadLinuxSystemTimeZoneLocations(); + final load = _systemLocationLoad ??= _systemLocationLoader(); _systemLocations ??= await load; } + static Future> + _emptySystemLocations() async => const []; + static Future> searchLocations( String query, ) async { diff --git a/lib/src/demo/demo_profile.dart b/lib/src/demo/demo_profile.dart index e6d1d72c..1e07ed81 100644 --- a/lib/src/demo/demo_profile.dart +++ b/lib/src/demo/demo_profile.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; @@ -207,12 +206,12 @@ class DemoDesktopNotificationBackend implements DesktopNotificationBackend { @override Future close() async {} + @override + Future cancel(String stableId) async {} + @override Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], + BusyMaxNotificationRequest request, { DesktopNotificationActionHandler? onAction, }) async {} } diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index c641a840..57ff368e 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -26,6 +26,7 @@ import 'package:busymax/src/core/secrets/secret_store.dart'; import '../../../l10n/l10n.dart'; import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../../sync/sync_auth_error.dart'; enum _OnboardingStep { accounts, preferences } diff --git a/lib/src/features/calendar/presentation/ical_import_flow.dart b/lib/src/features/calendar/presentation/ical_import_flow.dart index c1905ad6..0bf2f428 100644 --- a/lib/src/features/calendar/presentation/ical_import_flow.dart +++ b/lib/src/features/calendar/presentation/ical_import_flow.dart @@ -9,6 +9,7 @@ import '../../../ical/ical_import_service.dart'; import '../../../ical/ical_ingestion.dart'; import '../../../l10n/l10n.dart'; import '../../../providers/busy_provider.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../data/calendar_repository.dart'; Future showIcsImportFlow( diff --git a/lib/src/features/feedback/data/feedback_submission.dart b/lib/src/features/feedback/data/feedback_submission.dart index 16bc3d4f..6c2c85ab 100644 --- a/lib/src/features/feedback/data/feedback_submission.dart +++ b/lib/src/features/feedback/data/feedback_submission.dart @@ -31,12 +31,11 @@ class FeedbackSubmission { required this.subject, required this.message, required this.replyEmail, + required this.platform, this.technicalDetails, }); static const applicationId = 'busymax'; - static const platform = 'linux'; - final String submissionId; final String appVersion; final String buildNumber; @@ -44,6 +43,7 @@ class FeedbackSubmission { final String subject; final String message; final String? replyEmail; + final String platform; final FeedbackTechnicalDetails? technicalDetails; Map toJson() => { diff --git a/lib/src/features/feedback/presentation/feedback_dialog.dart b/lib/src/features/feedback/presentation/feedback_dialog.dart index b383485b..fdda7c68 100644 --- a/lib/src/features/feedback/presentation/feedback_dialog.dart +++ b/lib/src/features/feedback/presentation/feedback_dialog.dart @@ -324,6 +324,7 @@ class _BusyMaxFeedbackDialogState extends State { subject: subject, message: message, replyEmail: replyEmail, + platform: Platform.operatingSystem, technicalDetails: includeTechnicalDetails ? FeedbackTechnicalDetails( osVersion: diff --git a/lib/src/features/notifications/desktop_notification_backend.dart b/lib/src/features/notifications/desktop_notification_backend.dart new file mode 100644 index 00000000..e2f5a2f1 --- /dev/null +++ b/lib/src/features/notifications/desktop_notification_backend.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; + +typedef DesktopNotificationActionHandler = + Future Function(String action, Map? payload); + +enum BusyMaxNotificationCategory { + general, + reminder, + synchronization, + conflict, + networkError, +} + +@immutable +final class BusyMaxNotificationAction { + const BusyMaxNotificationAction(this.id, this.label); + + final String id; + final String label; +} + +@immutable +final class BusyMaxNotificationRequest { + const BusyMaxNotificationRequest({ + required this.stableId, + required this.title, + this.body = '', + this.category = BusyMaxNotificationCategory.general, + this.payload, + this.actions = const [], + this.transient = true, + this.private = false, + }); + + final String stableId; + final String title; + final String body; + final BusyMaxNotificationCategory category; + final Map? payload; + final List actions; + final bool transient; + final bool private; +} + +abstract interface class DesktopNotificationBackend { + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }); + + Future cancel(String stableId); + + Future close(); +} + +final class DisabledDesktopNotificationBackend + implements DesktopNotificationBackend { + const DisabledDesktopNotificationBackend(); + + @override + Future cancel(String stableId) async {} + + @override + Future close() async {} + + @override + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }) async { + throw UnsupportedError('Desktop notifications are unavailable.'); + } +} diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index 7a75c7e0..cad42446 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -1,15 +1,14 @@ import 'dart:async'; import 'dart:ui'; -import 'package:desktop_notifications/desktop_notifications.dart'; - import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_settings.dart'; import '../../core/logging/redacting_logger.dart'; import '../../l10n/locale_resolution.dart'; import '../sync/sync_failure_notification_policy.dart'; +import 'desktop_notification_backend.dart'; -typedef DesktopNotificationActionHandler = Future Function(String action); +export 'desktop_notification_backend.dart'; enum ReminderNotificationAction { open, snooze, dismiss } @@ -37,53 +36,6 @@ final class ReminderDeliveryResult { typedef ReminderNotificationActionHandler = Future Function(ReminderNotificationAction action); -abstract class DesktopNotificationBackend { - Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], - DesktopNotificationActionHandler? onAction, - }); - - Future close(); -} - -class FreedesktopNotificationBackend implements DesktopNotificationBackend { - FreedesktopNotificationBackend({NotificationsClient? client}) - : _client = client ?? NotificationsClient(); - - final NotificationsClient _client; - - @override - Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], - DesktopNotificationActionHandler? onAction, - }) async { - final notification = await _client.notify( - summary, - appName: 'BusyMax', - appIcon: 'io.busystack.busymax', - body: body, - hints: hints, - actions: actions, - ); - if (onAction != null) { - unawaited( - notification.action - .then(onAction) - .catchError((Object _) => Future.value()), - ); - } - } - - @override - Future close() => _client.close(); -} - class DesktopNotificationService { DesktopNotificationService({ required DesktopNotificationBackend backend, @@ -141,7 +93,8 @@ class DesktopNotificationService { await _safeNotify( _strings.syncFailureTitle, body, - NotificationCategory.networkError(), + BusyMaxNotificationCategory.networkError, + stableId: 'sync-failure', ); } @@ -155,7 +108,8 @@ class DesktopNotificationService { await _safeNotify( _strings.conflictTitle, body, - NotificationCategory.deviceError(), + BusyMaxNotificationCategory.conflict, + stableId: 'conflict', ); } @@ -166,13 +120,16 @@ class DesktopNotificationService { await _safeNotify( _strings.dueTodayTitle, _strings.dueTodayBody(count), - NotificationCategory.device(), + BusyMaxNotificationCategory.reminder, + stableId: 'due-today', ); } Future notifyEventReminder( String title, String? body, { + String stableId = 'event-reminder', + Map? payload, Future Function()? onActivated, ReminderNotificationActionHandler? onAction, }) async { @@ -189,7 +146,10 @@ class DesktopNotificationService { private ? _strings.detailsHidden : _nonEmpty(redactForLog(body ?? ''), _strings.eventReminderBody), - NotificationCategory.device(), + BusyMaxNotificationCategory.reminder, + stableId: stableId, + payload: payload, + private: private, onActivated: onActivated, onReminderAction: onAction, transient: false, @@ -204,6 +164,8 @@ class DesktopNotificationService { Future notifyTaskReminder( String title, String? body, { + String stableId = 'task-reminder', + Map? payload, Future Function()? onActivated, ReminderNotificationActionHandler? onAction, }) async { @@ -220,7 +182,10 @@ class DesktopNotificationService { private ? _strings.detailsHidden : _nonEmpty(redactForLog(body ?? ''), _strings.taskReminderBody), - NotificationCategory.device(), + BusyMaxNotificationCategory.reminder, + stableId: stableId, + payload: payload, + private: private, onActivated: onActivated, onReminderAction: onAction, transient: false, @@ -240,7 +205,10 @@ class DesktopNotificationService { Future _safeNotify( String summary, String body, - NotificationCategory category, { + BusyMaxNotificationCategory category, { + required String stableId, + Map? payload, + bool private = false, Future Function()? onActivated, ReminderNotificationActionHandler? onReminderAction, bool transient = true, @@ -248,20 +216,30 @@ class DesktopNotificationService { try { final hasActions = onActivated != null || onReminderAction != null; await _backend.notify( - summary, - body: body, - actions: !hasActions - ? const [] - : [ - NotificationAction('default', _strings.openAction), - if (onReminderAction != null) - NotificationAction('snooze', _strings.snoozeAction), - if (onReminderAction != null) - NotificationAction('dismiss', _strings.dismissAction), - ], + BusyMaxNotificationRequest( + stableId: stableId, + title: summary, + body: body, + category: category, + payload: payload, + transient: transient, + private: private, + actions: !hasActions + ? const [] + : [ + BusyMaxNotificationAction('default', _strings.openAction), + if (onReminderAction != null) + BusyMaxNotificationAction('snooze', _strings.snoozeAction), + if (onReminderAction != null) + BusyMaxNotificationAction( + 'dismiss', + _strings.dismissAction, + ), + ], + ), onAction: !hasActions ? null - : (action) async { + : (action, _) async { switch (action) { case 'default': if (onReminderAction != null) { @@ -279,14 +257,11 @@ class DesktopNotificationService { ); } }, - hints: [ - NotificationHint.category(category), - if (transient) NotificationHint.transient(), - ], ); return true; } on Object { - // DBus notifications may be unavailable in tests or headless sessions. + // A desktop notification backend may be unavailable in tests or a + // headless session. return false; } } diff --git a/lib/src/features/notifications/notification_scheduler.dart b/lib/src/features/notifications/notification_scheduler.dart index 720dc217..44529ab7 100644 --- a/lib/src/features/notifications/notification_scheduler.dart +++ b/lib/src/features/notifications/notification_scheduler.dart @@ -123,12 +123,16 @@ class NotificationScheduler { result = await _notifications.notifyEventReminder( row.title, row.body, + stableId: row.id, + payload: _activationPayload(row), onAction: (action) => _handleReminderAction(row, action), ); } else if (row.sourceType == 'task') { result = await _notifications.notifyTaskReminder( row.title, row.body, + stableId: row.id, + payload: _activationPayload(row), onAction: (action) => _handleReminderAction(row, action), ); } else { @@ -151,6 +155,13 @@ class NotificationScheduler { } } + Map _activationPayload(NotificationScheduleData row) => { + 'notificationScheduleId': row.id, + 'itemKind': row.sourceType, + 'accountId': row.accountId, + 'itemId': row.sourceId, + }; + Future _scheduleNextDueCheck() async { _dueTimer?.cancel(); _dueTimer = null; @@ -258,6 +269,38 @@ class NotificationScheduler { await checkNow(); } + /// Routes a process-independent desktop activation through the same action + /// path used for an in-process notification callback. + Future handleActivation({ + required String notificationScheduleId, + required String action, + }) async { + final row = + await (_database.select(_database.notificationSchedule) + ..where((table) => table.id.equals(notificationScheduleId))) + .getSingleOrNull(); + if (row == null) return; + final parsedAction = switch (action) { + 'default' || 'open' => ReminderNotificationAction.open, + 'snooze' => ReminderNotificationAction.snooze, + 'dismiss' => ReminderNotificationAction.dismiss, + _ => null, + }; + if (parsedAction == null) return; + + // Snooze and dismiss are safe under duplicate Windows toast delivery. + if (parsedAction == ReminderNotificationAction.dismiss && + row.dismissedAtUtc != null) { + return; + } + if (parsedAction == ReminderNotificationAction.snooze && + row.snoozedUntilUtc != null && + row.snoozedUntilUtc! > _nowUtc().millisecondsSinceEpoch) { + return; + } + await _handleReminderAction(row, parsedAction); + } + Future> _signedInAccountIds() async { final accounts = await (_database.select( _database.accounts, diff --git a/lib/src/features/recurrence/domain/recurrence_rule.dart b/lib/src/features/recurrence/domain/recurrence_rule.dart index 79f189a4..250558fb 100644 --- a/lib/src/features/recurrence/domain/recurrence_rule.dart +++ b/lib/src/features/recurrence/domain/recurrence_rule.dart @@ -490,18 +490,12 @@ _NormalizedEditorRule? _normalizeMonthlyOrYearly({ ); } - final hasOrdinalByDay = byDay.any( - (value) => RegExp(r'^-?[1-5](?:MO|TU|WE|TH|FR|SA|SU)$').hasMatch(value), - ); - if (byMonth.length > 1 && (bySetPositions.isNotEmpty || hasOrdinalByDay)) { - return null; - } - if (byDay.length == 1 && bySetPositions.isEmpty) { final ordinal = RegExp( r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$', ).firstMatch(byDay.single); if (ordinal != null) { + if (byMonth.length > 1) return null; final position = int.parse(ordinal.group(1)!); if (_editorSetPositions.contains(position)) { return ( @@ -518,6 +512,12 @@ _NormalizedEditorRule? _normalizeMonthlyOrYearly({ bySetPositions.length == 1 && _allowedEditorByDay(byDay) && _editorSetPositions.contains(bySetPositions.single)) { + // A single weekday plus BYSETPOS is ordinal within the whole yearly + // candidate set when multiple BYMONTH values are present. BusyMax's + // editor models an ordinal inside each selected month, so keep that RFC + // shape opaque instead of silently changing its meaning. Multi-weekday + // workday/weekend sets remain representable for Nextcloud tasks. + if (byMonth.length > 1 && byDay.length == 1) return null; return ( byDay: byDay, byMonth: byMonth, diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index c48054f5..b53ba814 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -13,6 +13,7 @@ import '../../../app/busymax_glyphs.dart'; import '../../../calendar_providers/calendar_colors.dart'; import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../webcal/webcal_subscription_service.dart'; diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 44e4e293..d6a3cc10 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -25,6 +25,7 @@ import '../../../features/sync/sync_auth_error.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../../../schedule/schedule_commands.dart'; import '../../../schedule/schedule_filters.dart'; import '../../../schedule/schedule_item.dart'; diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index b988e93c..fed6e0e4 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -25,6 +25,7 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; import '../../../l10n/app_locale.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import '../../../webcal/webcal_subscription_service.dart'; import '../../../webcal/webcal_uri.dart'; import 'package:busymax/src/providers/busy_provider.dart'; diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 5333d6f7..acc47990 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -8,6 +8,7 @@ import '../../../app/busymax_dialogs.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/linux_header_bar_service.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../../accounts/data/accounts_repository.dart'; diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 437eaf1d..41a159fe 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_provider.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../../accounts/data/accounts_repository.dart'; diff --git a/lib/src/platform/common/desktop_services.dart b/lib/src/platform/common/desktop_services.dart new file mode 100644 index 00000000..280d7a2a --- /dev/null +++ b/lib/src/platform/common/desktop_services.dart @@ -0,0 +1,296 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +const busyMaxStartMinimizedArgument = '--start-minimized'; + +abstract interface class DesktopWindowService { + Future setHideOnClose(bool enabled); + + Future hideWindow(); + + Future showWindow(); + + Future quitApp(); + + Future isWindowVisible(); +} + +abstract interface class DesktopTrayService { + bool get isAvailable; + + Future start(); + + Future refresh(); + + Future stop(); +} + +enum DesktopAutostartState { + enabled, + disabled, + disabledByUser, + disabledByPolicy, + unavailable, +} + +abstract interface class DesktopAutostartService { + Future state(); + + Future setEnabled(bool enabled); +} + +enum DesktopActivationKind { + normalLaunch, + startMinimized, + icsFile, + webCal, + notification, +} + +@immutable +final class DesktopActivation { + const DesktopActivation({ + required this.kind, + this.value, + this.action, + this.payload, + }); + + static const maximumEncodedBytes = 16 * 1024; + + final DesktopActivationKind kind; + final String? value; + final String? action; + final Map? payload; + + bool get requiresVisibleWindow => switch (kind) { + DesktopActivationKind.startMinimized => false, + DesktopActivationKind.notification => + action == 'default' || action == 'open', + _ => true, + }; + + Map toJson() => { + 'version': 1, + 'kind': kind.name, + if (value != null) 'value': value, + if (action != null) 'action': action, + if (payload != null) 'payload': payload, + }; + + String encode() => jsonEncode(toJson()); + + static DesktopActivation? tryDecode(String encoded) { + if (utf8.encode(encoded).length > maximumEncodedBytes) return null; + try { + final decoded = jsonDecode(encoded); + if (decoded is! Map || decoded['version'] != 1) { + return null; + } + final kindName = decoded['kind']; + if (kindName is! String) return null; + final kind = DesktopActivationKind.values + .where((candidate) => candidate.name == kindName) + .firstOrNull; + if (kind == null) return null; + final allowedKeys = switch (kind) { + DesktopActivationKind.normalLaunch || + DesktopActivationKind.startMinimized => const {'version', 'kind'}, + DesktopActivationKind.icsFile || + DesktopActivationKind.webCal => const {'version', 'kind', 'value'}, + DesktopActivationKind.notification => const { + 'version', + 'kind', + 'action', + 'payload', + }, + }; + if (decoded.keys.any((key) => !allowedKeys.contains(key))) return null; + final value = decoded['value']; + final action = decoded['action']; + final rawPayload = decoded['payload']; + if (value != null && value is! String || + action != null && action is! String || + rawPayload != null && rawPayload is! Map) { + return null; + } + Map? payload; + if (rawPayload != null) { + payload = {}; + for (final MapEntry(:key, :value) + in (rawPayload as Map).entries) { + if (key is! String || value is! String) return null; + if (key.length > 64 || value.length > 2048) return null; + payload[key] = value; + } + } + final activation = DesktopActivation( + kind: kind, + value: value as String?, + action: action as String?, + payload: payload, + ); + return activation.isValid ? activation : null; + } on FormatException { + return null; + } + } + + bool get isValid { + switch (kind) { + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + return value == null && action == null && payload == null; + case DesktopActivationKind.icsFile: + final path = value; + return path != null && + path.isNotEmpty && + path.length <= 32767 && + path.toLowerCase().endsWith('.ics') && + !path.contains('\u0000'); + case DesktopActivationKind.webCal: + final uri = Uri.tryParse(value ?? ''); + return uri != null && + uri.scheme.toLowerCase() == 'webcal' && + uri.host.isNotEmpty && + uri.userInfo.isEmpty && + !uri.hasFragment; + case DesktopActivationKind.notification: + const permittedActions = {'default', 'open', 'snooze', 'dismiss'}; + const permittedPayloadKeys = { + 'notificationScheduleId', + 'itemKind', + 'accountId', + 'sourceId', + 'itemId', + }; + return permittedActions.contains(action) && + payload != null && + payload!.containsKey('notificationScheduleId') && + payload!.entries.every( + (entry) => + permittedPayloadKeys.contains(entry.key) && + entry.value.isNotEmpty && + entry.value.length <= 2048, + ); + } + } +} + +abstract interface class DesktopActivationService { + Stream get activations; + + Future initialize(); + + Future dispose(); +} + +final class NoOpDesktopActivationService implements DesktopActivationService { + const NoOpDesktopActivationService(); + + @override + Stream get activations => const Stream.empty(); + + @override + Future dispose() async {} + + @override + Future initialize() async {} +} + +enum DesktopNavigationDestination { schedule, tasks, settings, signIn } + +@immutable +final class DesktopNavigationRequest { + const DesktopNavigationRequest(this.destination); + + final DesktopNavigationDestination destination; +} + +abstract interface class DesktopNavigationService { + Stream get requests; + + void open(DesktopNavigationDestination destination); +} + +final class QueuedDesktopNavigationService implements DesktopNavigationService { + QueuedDesktopNavigationService(); + + final _requests = StreamController.broadcast(); + + @override + Stream get requests => _requests.stream; + + @override + void open(DesktopNavigationDestination destination) { + _requests.add(DesktopNavigationRequest(destination)); + } + + Future dispose() => _requests.close(); +} + +abstract interface class SystemAppearanceSource { + Color? get accentColor; + + Brightness get brightness; + + bool get highContrast; + + Stream get changes; +} + +abstract interface class LocalTimeZoneSource { + /// A non-sensitive technical diagnostic when the source had to fall back. + String? get diagnostic; + + Future currentIanaTimeZone(); +} + +final class FixedLocalTimeZoneSource implements LocalTimeZoneSource { + const FixedLocalTimeZoneSource(this.timeZone); + + final String timeZone; + + @override + String? get diagnostic => null; + + @override + Future currentIanaTimeZone() async => timeZone; +} + +final class UnavailableDesktopAutostartService + implements DesktopAutostartService { + const UnavailableDesktopAutostartService(); + + @override + Future state() async { + return DesktopAutostartState.unavailable; + } + + @override + Future setEnabled(bool enabled) { + throw UnsupportedError('Launch at login is unavailable in this build.'); + } +} + +final class NoOpDesktopWindowService implements DesktopWindowService { + const NoOpDesktopWindowService(); + + @override + Future hideWindow() async {} + + @override + Future isWindowVisible() async => true; + + @override + Future quitApp() async {} + + @override + Future setHideOnClose(bool enabled) async {} + + @override + Future showWindow() async {} +} diff --git a/lib/src/platform/external_calendar_open_service.dart b/lib/src/platform/external_calendar_open_service.dart index 4a3e9357..a2f7ea0d 100644 --- a/lib/src/platform/external_calendar_open_service.dart +++ b/lib/src/platform/external_calendar_open_service.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/services.dart'; +import 'common/desktop_services.dart'; + const externalCalendarOpenChannelName = 'io.busystack.busymax/external_calendar_open'; @@ -14,7 +17,10 @@ final class ExternalCalendarOpenRequest { final String value; } -final class ExternalCalendarOpenService { +/// Validates and queues native desktop activations before exposing them to the +/// application. Missing native support is treated as an ordinary launch, so an +/// unpackaged or test build never fails merely because the channel is absent. +final class ExternalCalendarOpenService implements DesktopActivationService { ExternalCalendarOpenService({ MethodChannel channel = const MethodChannel( externalCalendarOpenChannelName, @@ -22,42 +28,125 @@ final class ExternalCalendarOpenService { }) : _channel = channel; final MethodChannel _channel; - final _requests = StreamController(); + late final StreamController _activations = + StreamController.broadcast(onListen: _flushPending); + final _calendarRequests = + StreamController.broadcast(); bool _initialized = false; + final List _pending = []; + + @override + Stream get activations => _activations.stream; - Stream get requests => _requests.stream; + /// Compatibility stream retained for the Linux presentation while the + /// common activation model is used by both application roots. + Stream get requests => _calendarRequests.stream; + @override Future initialize() async { if (_initialized) return; _initialized = true; _channel.setMethodCallHandler((call) async { - if (call.method != 'openItem' || call.arguments is! Map) return; - final arguments = Map.from(call.arguments as Map); - final kind = arguments['kind']; - final value = arguments['value']; - if (value is! String || value.isEmpty) return; - switch (kind) { - case 'webcal': - _requests.add( - ExternalCalendarOpenRequest( - kind: ExternalCalendarOpenKind.webCal, - value: value, - ), - ); - case 'ics': - _requests.add( - ExternalCalendarOpenRequest( - kind: ExternalCalendarOpenKind.icsFile, - value: value, - ), - ); + switch (call.method) { + case 'openItem': + _acceptLegacyOpenItem(call.arguments); + case 'activation': + _acceptEncoded(call.arguments); + } + }); + try { + final pending = await _channel.invokeListMethod('ready'); + for (final value in pending ?? const []) { + _acceptEncoded(value); + } + } on MissingPluginException { + // Tests and unsupported/unpackaged runners have no activation bridge. + } on PlatformException { + // Invalid native activation state must not prevent ordinary startup. + } + } + + void _acceptLegacyOpenItem(Object? raw) { + if (raw is! Map) return; + final arguments = Map.from(raw); + final kind = arguments['kind']; + final value = arguments['value']; + if (value is! String) return; + final activation = switch (kind) { + 'webcal' => DesktopActivation( + kind: DesktopActivationKind.webCal, + value: value, + ), + 'ics' => DesktopActivation( + kind: DesktopActivationKind.icsFile, + value: value, + ), + _ => null, + }; + if (activation?.isValid == true) _emit(activation!); + } + + void _acceptEncoded(Object? raw) { + final DesktopActivation? activation; + if (raw is String) { + activation = DesktopActivation.tryDecode(raw); + } else if (raw is Map) { + try { + activation = DesktopActivation.tryDecode( + jsonEncode(Map.from(raw)), + ); + } on JsonUnsupportedObjectError { + return; + } + } else { + activation = null; + } + if (activation != null) _emit(activation); + } + + void _emit(DesktopActivation activation) { + if (_activations.hasListener) { + _activations.add(activation); + } else { + _pending.add(activation); + } + switch (activation.kind) { + case DesktopActivationKind.webCal: + _calendarRequests.add( + ExternalCalendarOpenRequest( + kind: ExternalCalendarOpenKind.webCal, + value: activation.value!, + ), + ); + case DesktopActivationKind.icsFile: + _calendarRequests.add( + ExternalCalendarOpenRequest( + kind: ExternalCalendarOpenKind.icsFile, + value: activation.value!, + ), + ); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + break; + } + } + + void _flushPending() { + if (_pending.isEmpty) return; + final queued = List.of(_pending); + _pending.clear(); + scheduleMicrotask(() { + for (final activation in queued) { + if (!_activations.isClosed) _activations.add(activation); } }); - await _channel.invokeMethod('ready'); } + @override Future dispose() async { _channel.setMethodCallHandler(null); - await _requests.close(); + await _activations.close(); + await _calendarRequests.close(); } } diff --git a/lib/src/platform/linux/linux_notification_backend.dart b/lib/src/platform/linux/linux_notification_backend.dart new file mode 100644 index 00000000..ac82398c --- /dev/null +++ b/lib/src/platform/linux/linux_notification_backend.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:desktop_notifications/desktop_notifications.dart'; + +import '../../features/notifications/desktop_notification_backend.dart'; + +final class FreedesktopNotificationBackend + implements DesktopNotificationBackend { + FreedesktopNotificationBackend({NotificationsClient? client}) + : _client = client ?? NotificationsClient(); + + final NotificationsClient _client; + final Map _notifications = {}; + + @override + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }) async { + final previous = _notifications[request.stableId]; + final notification = await _client.notify( + request.title, + appName: 'BusyMax', + appIcon: 'io.busystack.busymax', + body: request.body, + replacesId: previous?.id ?? 0, + hints: [ + NotificationHint.category(_category(request.category)), + if (request.transient) NotificationHint.transient(), + ], + actions: [ + for (final action in request.actions) + NotificationAction(action.id, action.label), + ], + ); + _notifications[request.stableId] = notification; + if (onAction != null) { + unawaited( + notification.action + .then((action) => onAction(action, request.payload)) + .catchError((Object _) => Future.value()), + ); + } + } + + NotificationCategory _category(BusyMaxNotificationCategory category) { + return switch (category) { + BusyMaxNotificationCategory.networkError => + NotificationCategory.networkError(), + BusyMaxNotificationCategory.conflict => + NotificationCategory.deviceError(), + _ => NotificationCategory.device(), + }; + } + + @override + Future cancel(String stableId) async { + final notification = _notifications.remove(stableId); + await notification?.close(); + } + + @override + Future close() => _client.close(); +} diff --git a/lib/src/platform/linux_autostart_service.dart b/lib/src/platform/linux_autostart_service.dart index 7ac432f7..23f59f25 100644 --- a/lib/src/platform/linux_autostart_service.dart +++ b/lib/src/platform/linux_autostart_service.dart @@ -2,10 +2,11 @@ import 'dart:io'; import 'package:path/path.dart' as path; +import 'common/desktop_services.dart'; + const busyMaxAutostartFileName = 'io.busystack.busymax-autostart.desktop'; -const busyMaxStartMinimizedArgument = '--start-minimized'; -final class LinuxAutostartService { +final class LinuxAutostartService implements DesktopAutostartService { LinuxAutostartService({ Map? environment, String? executable, @@ -23,6 +24,14 @@ final class LinuxAutostartService { return _autostartFile().exists(); } + @override + Future state() async { + return await isEnabled() + ? DesktopAutostartState.enabled + : DesktopAutostartState.disabled; + } + + @override Future setEnabled(bool enabled) async { if (!_isLinux) { throw UnsupportedError('Launch at login is supported only on Linux.'); diff --git a/lib/src/platform/linux_window_service.dart b/lib/src/platform/linux_window_service.dart index ee81b532..1615c51e 100644 --- a/lib/src/platform/linux_window_service.dart +++ b/lib/src/platform/linux_window_service.dart @@ -1,28 +1,35 @@ import 'package:flutter/services.dart'; -class LinuxWindowService { +import 'common/desktop_services.dart'; + +class LinuxWindowService implements DesktopWindowService { const LinuxWindowService({ MethodChannel channel = const MethodChannel('io.busystack.busymax/window'), }) : _channel = channel; final MethodChannel _channel; + @override Future setHideOnClose(bool enabled) { return _channel.invokeMethod('setHideOnClose', enabled); } + @override Future hideWindow() { return _channel.invokeMethod('hideWindow'); } + @override Future showWindow() { return _channel.invokeMethod('showWindow'); } + @override Future quitApp() { return _channel.invokeMethod('quitApp'); } + @override Future isWindowVisible() async { return await _channel.invokeMethod('isWindowVisible') ?? false; } diff --git a/lib/src/platform/windows/windows_activation_service.dart b/lib/src/platform/windows/windows_activation_service.dart new file mode 100644 index 00000000..0097dabb --- /dev/null +++ b/lib/src/platform/windows/windows_activation_service.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../common/desktop_services.dart'; +import 'windows_window_service.dart'; + +Future forwardWindowsActivationToPrimary( + DesktopActivation activation, { + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), +}) async { + if (!activation.isValid) return false; + try { + return await channel.invokeMethod( + 'forwardActivation', + activation.encode(), + ) ?? + false; + } on MissingPluginException { + return false; + } +} + +final class WindowsActivationService implements DesktopActivationService { + WindowsActivationService({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) : _channel = channel { + _controller = StreamController.broadcast( + onListen: _flushPending, + ); + } + + final MethodChannel _channel; + late final StreamController _controller; + final _pending = []; + bool _initialized = false; + + @override + Stream get activations => _controller.stream; + + @override + Future initialize() async { + if (_initialized) return; + _initialized = true; + _channel.setMethodCallHandler((call) async { + if (call.method == 'activation') { + acceptEncoded(call.arguments); + } + }); + try { + final initial = + await _channel.invokeListMethod('takeInitialActivations') ?? + const []; + for (final encoded in initial) { + acceptEncoded(encoded); + } + await _channel.invokeMethod('activationReady'); + } on MissingPluginException { + // Unpackaged tests may not load the native runner bridge. + } + _flushPending(); + } + + void acceptEncoded(Object? encoded) { + if (encoded is! String) return; + final activation = DesktopActivation.tryDecode(encoded); + if (activation != null) accept(activation); + } + + void accept(DesktopActivation activation) { + if (!activation.isValid) return; + if (!_initialized || !_controller.hasListener) { + _pending.add(activation); + } else { + _controller.add(activation); + } + } + + void _flushPending() { + if (!_initialized || !_controller.hasListener || _pending.isEmpty) return; + for (final activation in _pending) { + _controller.add(activation); + } + _pending.clear(); + } + + @override + Future dispose() async { + _channel.setMethodCallHandler(null); + await _controller.close(); + } +} diff --git a/lib/src/platform/windows/windows_autostart_service.dart b/lib/src/platform/windows/windows_autostart_service.dart new file mode 100644 index 00000000..d59edcd0 --- /dev/null +++ b/lib/src/platform/windows/windows_autostart_service.dart @@ -0,0 +1,40 @@ +import 'package:flutter/services.dart'; + +import '../common/desktop_services.dart'; +import 'windows_window_service.dart'; + +final class WindowsAutostartService implements DesktopAutostartService { + const WindowsAutostartService({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + @override + Future state() async { + try { + final value = await _channel.invokeMethod('getStartupTaskState'); + return switch (value) { + 'enabled' => DesktopAutostartState.enabled, + 'disabled' => DesktopAutostartState.disabled, + 'disabledByUser' => DesktopAutostartState.disabledByUser, + 'disabledByPolicy' => DesktopAutostartState.disabledByPolicy, + _ => DesktopAutostartState.unavailable, + }; + } on MissingPluginException { + return DesktopAutostartState.unavailable; + } on PlatformException { + return DesktopAutostartState.unavailable; + } + } + + @override + Future setEnabled(bool enabled) async { + final current = await state(); + if (current == DesktopAutostartState.unavailable || + current == DesktopAutostartState.disabledByPolicy) { + throw UnsupportedError('Windows StartupTask is unavailable.'); + } + await _channel.invokeMethod('setStartupTaskEnabled', enabled); + } +} diff --git a/lib/src/platform/windows/windows_notification_backend.dart b/lib/src/platform/windows/windows_notification_backend.dart new file mode 100644 index 00000000..96e555fe --- /dev/null +++ b/lib/src/platform/windows/windows_notification_backend.dart @@ -0,0 +1,187 @@ +import 'dart:convert'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../../features/notifications/desktop_notification_backend.dart'; +import '../common/desktop_services.dart'; + +const busyMaxToastActivatorClsid = '7B854A6D-8B2A-45A5-B998-1F51EC5A81D7'; +const busyMaxNotificationActivationServerArgument = + '----AppNotificationActivationServer'; +const busyMaxNotificationForwarderArgument = '--busymax-notification-forwarder'; + +final class WindowsNotificationBackend implements DesktopNotificationBackend { + WindowsNotificationBackend._({ + required FlutterLocalNotificationsPlugin plugin, + required void Function(DesktopActivation activation) onActivation, + }) : _plugin = plugin, + _onActivation = onActivation; + + static Future create({ + required String appUserModelId, + required void Function(DesktopActivation activation) onActivation, + FlutterLocalNotificationsPlugin? plugin, + }) async { + if (appUserModelId.trim().isEmpty) { + throw ArgumentError.value( + appUserModelId, + 'appUserModelId', + 'A stable AUMID is required for Windows notifications.', + ); + } + final notifications = plugin ?? FlutterLocalNotificationsPlugin(); + late final WindowsNotificationBackend backend; + backend = WindowsNotificationBackend._( + plugin: notifications, + onActivation: onActivation, + ); + await notifications.initialize( + settings: InitializationSettings( + windows: WindowsInitializationSettings( + appName: 'BusyMax', + appUserModelId: appUserModelId, + guid: busyMaxToastActivatorClsid, + ), + ), + onDidReceiveNotificationResponse: backend._handleResponse, + ); + final launch = await notifications.getNotificationAppLaunchDetails(); + if (launch?.didNotificationLaunchApp ?? false) { + backend._handleResponse(launch!.notificationResponse!); + } + return backend; + } + + final FlutterLocalNotificationsPlugin _plugin; + final void Function(DesktopActivation activation) _onActivation; + + @override + Future notify( + BusyMaxNotificationRequest request, { + DesktopNotificationActionHandler? onAction, + }) async { + const permittedPayloadKeys = { + 'notificationScheduleId', + 'itemKind', + 'accountId', + 'sourceId', + 'itemId', + }; + final requestPayload = request.payload ?? const {}; + if (request.stableId.isEmpty || + request.stableId.length > 2048 || + request.actions.any( + (action) => !const { + 'default', + 'open', + 'snooze', + 'dismiss', + }.contains(action.id), + ) || + requestPayload.entries.any( + (entry) => + !permittedPayloadKeys.contains(entry.key) || + entry.value.isEmpty || + entry.value.length > 2048, + )) { + throw ArgumentError( + 'Windows notification activation data must contain only bounded ' + 'BusyMax identifiers.', + ); + } + final payload = { + 'version': 1, + ...requestPayload, + 'stableId': request.stableId, + }; + await _plugin.show( + id: _notificationId(request.stableId), + title: request.title, + body: request.body, + payload: jsonEncode(payload), + notificationDetails: NotificationDetails( + windows: WindowsNotificationDetails( + actions: request.actions + .map( + (action) => WindowsAction( + content: action.label, + // Windows supplies the selected action's arguments instead + // of the toast's root launch value. Include the opaque + // identifiers in every action so warm and cold activation + // use the same idempotent route. + arguments: jsonEncode({...payload, 'action': action.id}), + ), + ) + .toList(growable: false), + ), + ), + ); + } + + void _handleResponse(NotificationResponse response) { + final parsed = _decodePayload(response.payload); + if (parsed == null) return; + final stableId = parsed.remove('stableId'); + if (stableId == null || stableId != parsed['notificationScheduleId']) { + return; + } + final encodedAction = parsed.remove('action'); + final action = + response.notificationResponseType == + NotificationResponseType.notificationDismissed + ? 'dismiss' + : encodedAction ?? 'default'; + final activation = DesktopActivation( + kind: DesktopActivationKind.notification, + action: action, + payload: parsed, + ); + if (!activation.isValid) return; + // Warm and cold responses deliberately use the same idempotent activation + // router. Calling an in-memory closure here as well would process snooze or + // dismiss twice in the warm-process case. + _onActivation(activation); + } + + Map? _decodePayload(String? encoded) { + if (encoded == null || + encoded.length > DesktopActivation.maximumEncodedBytes) { + return null; + } + try { + final decoded = jsonDecode(encoded); + if (decoded is! Map || decoded['version'] != 1) return null; + final values = {}; + for (final entry in decoded.entries) { + if (entry.key == 'version') continue; + if (entry.key is! String || entry.value is! String) return null; + values[entry.key as String] = entry.value as String; + } + return values; + } on FormatException { + return null; + } + } + + @override + Future cancel(String stableId) async { + await _plugin.cancel(id: _notificationId(stableId)); + } + + @override + Future close() async { + _plugin + .resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows + >() + ?.dispose(); + } +} + +int _notificationId(String stableId) { + var hash = 0x811c9dc5; + for (final byte in utf8.encode(stableId)) { + hash = ((hash ^ byte) * 0x01000193) & 0x7fffffff; + } + return hash; +} diff --git a/lib/src/platform/windows/windows_system_appearance_source.dart b/lib/src/platform/windows/windows_system_appearance_source.dart new file mode 100644 index 00000000..437bf574 --- /dev/null +++ b/lib/src/platform/windows/windows_system_appearance_source.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:system_theme/system_theme.dart'; + +import '../common/desktop_services.dart'; + +final class WindowsSystemAppearanceSource implements SystemAppearanceSource { + WindowsSystemAppearanceSource() { + _subscription = SystemTheme.onChange.listen((_) => _changes.add(null)); + } + + final _changes = StreamController.broadcast(); + late final StreamSubscription _subscription; + + @override + Color get accentColor => SystemTheme.accentColor.accent; + + @override + Brightness get brightness => + WidgetsBinding.instance.platformDispatcher.platformBrightness; + + @override + bool get highContrast => WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .highContrast; + + @override + Stream get changes => _changes.stream; + + Future dispose() async { + await _subscription.cancel(); + await _changes.close(); + } +} diff --git a/lib/src/platform/windows/windows_time_zone_source.dart b/lib/src/platform/windows/windows_time_zone_source.dart new file mode 100644 index 00000000..b04c5290 --- /dev/null +++ b/lib/src/platform/windows/windows_time_zone_source.dart @@ -0,0 +1,101 @@ +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:logging/logging.dart'; +import 'package:timezone/data/latest_all.dart' as time_zone_data; +import 'package:timezone/timezone.dart' as time_zone; + +import '../common/desktop_services.dart'; + +/// Reads the current Windows zone and normalizes it to the IANA identifiers +/// used by BusyMax and package:timezone. +final class WindowsLocalTimeZoneSource implements LocalTimeZoneSource { + WindowsLocalTimeZoneSource({Future Function()? load}) + : _load = load ?? FlutterTimezone.getLocalTimezone; + + final Future Function() _load; + final _logger = Logger('busymax.windows.timezone'); + String? _lastUnrecognizedIdentifier; + + String? get lastUnrecognizedIdentifier => _lastUnrecognizedIdentifier; + + @override + String? get diagnostic => switch (_lastUnrecognizedIdentifier) { + null => null, + '' => 'local-time-zone/unavailable; fallback=Etc/UTC', + final identifier => + 'local-time-zone/unrecognized=$identifier; fallback=Etc/UTC', + }; + + @override + Future currentIanaTimeZone() async { + try { + final result = await _load(); + final identifier = result.identifier.trim(); + time_zone_data.initializeTimeZones(); + if (time_zone.timeZoneDatabase.locations.containsKey(identifier)) { + _lastUnrecognizedIdentifier = null; + return identifier == 'UTC' ? 'Etc/UTC' : identifier; + } + final mapped = windowsToIanaTimeZones[identifier]; + if (mapped != null) { + _lastUnrecognizedIdentifier = null; + return mapped; + } + _lastUnrecognizedIdentifier = identifier; + _logger.warning('Unrecognized Windows time zone; using Etc/UTC.'); + } on Object { + _lastUnrecognizedIdentifier = ''; + _logger.warning('Windows time zone is unavailable; using Etc/UTC.'); + } + return 'Etc/UTC'; + } +} + +/// Stable CLDR-compatible primary-zone mappings for Windows identifiers. The +/// plugin normally returns IANA on current Windows releases; this table covers +/// native Windows IDs returned by older plugin/OS combinations without guessing. +const windowsToIanaTimeZones = { + 'Dateline Standard Time': 'Etc/GMT+12', + 'UTC-11': 'Etc/GMT+11', + 'Aleutian Standard Time': 'America/Adak', + 'Hawaiian Standard Time': 'Pacific/Honolulu', + 'Alaskan Standard Time': 'America/Anchorage', + 'Pacific Standard Time': 'America/Los_Angeles', + 'US Mountain Standard Time': 'America/Phoenix', + 'Mountain Standard Time': 'America/Denver', + 'Central Standard Time': 'America/Chicago', + 'Eastern Standard Time': 'America/New_York', + 'Atlantic Standard Time': 'America/Halifax', + 'Newfoundland Standard Time': 'America/St_Johns', + 'SA Eastern Standard Time': 'America/Cayenne', + 'Greenland Standard Time': 'America/Nuuk', + 'UTC': 'Etc/UTC', + 'GMT Standard Time': 'Europe/London', + 'W. Europe Standard Time': 'Europe/Berlin', + 'Central Europe Standard Time': 'Europe/Budapest', + 'Romance Standard Time': 'Europe/Paris', + 'FLE Standard Time': 'Europe/Kyiv', + 'Turkey Standard Time': 'Europe/Istanbul', + 'Israel Standard Time': 'Asia/Jerusalem', + 'Egypt Standard Time': 'Africa/Cairo', + 'South Africa Standard Time': 'Africa/Johannesburg', + 'Russian Standard Time': 'Europe/Moscow', + 'Arabian Standard Time': 'Asia/Dubai', + 'Iran Standard Time': 'Asia/Tehran', + 'Afghanistan Standard Time': 'Asia/Kabul', + 'Pakistan Standard Time': 'Asia/Karachi', + 'India Standard Time': 'Asia/Kolkata', + 'Nepal Standard Time': 'Asia/Kathmandu', + 'Bangladesh Standard Time': 'Asia/Dhaka', + 'Myanmar Standard Time': 'Asia/Yangon', + 'SE Asia Standard Time': 'Asia/Bangkok', + 'China Standard Time': 'Asia/Shanghai', + 'Tokyo Standard Time': 'Asia/Tokyo', + 'Korea Standard Time': 'Asia/Seoul', + 'AUS Central Standard Time': 'Australia/Darwin', + 'AUS Eastern Standard Time': 'Australia/Sydney', + 'Tasmania Standard Time': 'Australia/Hobart', + 'West Pacific Standard Time': 'Pacific/Port_Moresby', + 'New Zealand Standard Time': 'Pacific/Auckland', + 'Chatham Islands Standard Time': 'Pacific/Chatham', + 'Tonga Standard Time': 'Pacific/Tongatapu', +}; diff --git a/lib/src/platform/windows/windows_tray_service.dart b/lib/src/platform/windows/windows_tray_service.dart new file mode 100644 index 00000000..1234b70e --- /dev/null +++ b/lib/src/platform/windows/windows_tray_service.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:tray_manager/tray_manager.dart'; + +import '../../features/tray/domain/tray_presentation.dart'; +import '../common/desktop_services.dart'; + +enum WindowsTrayCommand { + open, + newEvent, + newTask, + today, + synchronize, + settings, + quit, +} + +final class WindowsTrayService with TrayListener implements DesktopTrayService { + WindowsTrayService({ + required Future Function() loadPresentation, + required Future Function(WindowsTrayCommand command) onCommand, + this.iconPath = 'assets/windows/busymax_tray.ico', + this.offlineIconPath = 'assets/windows/busymax_tray_offline.ico', + }) : _loadPresentation = loadPresentation, + _onCommand = onCommand; + + final Future Function() _loadPresentation; + final Future Function(WindowsTrayCommand command) _onCommand; + final String iconPath; + final String offlineIconPath; + bool _available = false; + bool _listenerAttached = false; + + @override + bool get isAvailable => _available; + + @override + Future start() async { + if (_available) return true; + try { + if (!_listenerAttached) { + trayManager.addListener(this); + _listenerAttached = true; + } + await trayManager.setIcon(iconPath); + await trayManager.setToolTip('BusyMax'); + _available = true; + await refresh(); + return _available; + } on Object { + _removeListener(); + _available = false; + return false; + } + } + + @override + Future refresh() async { + if (!_available) return; + try { + final presentation = await _loadPresentation(); + await trayManager.setContextMenu(_menu(presentation)); + await trayManager.setToolTip( + presentation.offline + ? 'BusyMax — ${presentation.offlineTitle}' + : 'BusyMax', + ); + // Reapplying the icon also recovers the tray after Explorer recreates + // its notification area. + await trayManager.setIcon( + presentation.offline ? offlineIconPath : iconPath, + ); + } on Object { + _removeListener(); + _available = false; + try { + await trayManager.destroy(); + } on Object { + // Explorer may already have discarded the old tray host. + } + } + } + + Menu _menu(BusyMaxTrayMenuPresentation value) { + MenuItem item( + String key, + String label, { + bool enabled = true, + WindowsTrayCommand? command, + }) => MenuItem( + key: key, + label: label, + disabled: !enabled, + onClick: command == null ? null : (_) => unawaited(_onCommand(command)), + ); + + return Menu( + items: [ + item('open', value.showBusyMaxLabel, command: WindowsTrayCommand.open), + MenuItem.separator(), + item( + 'new-event', + value.newEventLabel, + enabled: value.canCreateEvent, + command: WindowsTrayCommand.newEvent, + ), + item( + 'new-task', + value.newTaskLabel, + enabled: value.canCreateTask, + command: WindowsTrayCommand.newTask, + ), + MenuItem.separator(), + item('today-label', value.todayLabel), + ...value.eventRows.map( + (row) => item( + 'event:${row.event.eventId}', + row.label, + command: WindowsTrayCommand.today, + ), + ), + if (value.taskSummaryLabel != null) + item( + 'tasks-today', + value.taskSummaryLabel!, + command: WindowsTrayCommand.today, + ), + if (value.showEmptyState) item('empty', value.emptyStateLabel), + item( + 'today', + value.openTodayAgendaLabel, + command: WindowsTrayCommand.today, + ), + MenuItem.separator(), + item( + 'sync', + value.syncNowLabel, + enabled: value.canSynchronize, + command: WindowsTrayCommand.synchronize, + ), + item('sync-status', value.synchronizationStatusLabel), + MenuItem.separator(), + item( + 'settings', + value.settingsLabel, + command: WindowsTrayCommand.settings, + ), + item('quit', value.quitBusyMaxLabel, command: WindowsTrayCommand.quit), + ], + ); + } + + @override + void onTrayIconMouseDown() { + unawaited(_onCommand(WindowsTrayCommand.open)); + } + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + // MenuItem callbacks are used so the adapter also works on plugin builds + // that do not emit this listener callback on Windows. + } + + @override + Future stop() async { + _removeListener(); + try { + await trayManager.destroy(); + } on Object { + // Quit must not be blocked by an unavailable Explorer tray host. + } + _available = false; + } + + void _removeListener() { + if (!_listenerAttached) return; + trayManager.removeListener(this); + _listenerAttached = false; + } +} diff --git a/lib/src/platform/windows/windows_window_service.dart b/lib/src/platform/windows/windows_window_service.dart new file mode 100644 index 00000000..4355990a --- /dev/null +++ b/lib/src/platform/windows/windows_window_service.dart @@ -0,0 +1,30 @@ +import 'package:flutter/services.dart'; + +import '../common/desktop_services.dart'; + +const windowsDesktopChannelName = 'org.busystack.busymax/windows_desktop'; + +final class WindowsWindowService implements DesktopWindowService { + const WindowsWindowService({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) : _channel = channel; + + final MethodChannel _channel; + + @override + Future hideWindow() => _channel.invokeMethod('hide'); + + @override + Future isWindowVisible() async => + await _channel.invokeMethod('isVisible') ?? true; + + @override + Future quitApp() => _channel.invokeMethod('quit'); + + @override + Future setHideOnClose(bool enabled) => + _channel.invokeMethod('setHideOnClose', enabled); + + @override + Future showWindow() => _channel.invokeMethod('showAndFocus'); +} diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index a64e7daf..7b3b6f40 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -145,6 +145,41 @@ class ScheduleRepository { return filtered; } + /// Lists every visible task without applying a calendar date window. + /// + /// The Tasks workspace is a complete task collection, unlike the Schedule + /// workspace, whose projections are intentionally bounded by a visible + /// range. Keeping this query here also ensures every platform observes the + /// same account, source, completion, search, and hierarchy rules. + Future> listAllTasks({ + ScheduleFilters filters = const ScheduleFilters( + includeCalendarEvents: false, + showCompletedTasks: true, + showNoDateTasks: true, + ), + }) async { + if (!filters.includeTasks) return const []; + final context = await _accountContext(filters); + if (context == null) return const []; + + // `_taskItems` ignores this range when `searching` is true. Here that flag + // deliberately requests the same unbounded query used by global search. + final items = await _taskItems( + ScheduleRange.day(DateTime.now()), + filters, + true, + context.accountIds, + context.providers, + context.accountDisplayNames, + context.accountEmails, + ); + final tasks = items.whereType().where((item) { + return filters.query.trim().isEmpty || + matchesScheduleQuery(item, filters.query); + }).toList()..sort(compareScheduleItems); + return ScheduleProjection.arrangeHierarchy(tasks); + } + Future listOverdueTasks({ required DateTime before, required int limit, diff --git a/lib/src/ui/common/busymax_glyph.dart b/lib/src/ui/common/busymax_glyph.dart new file mode 100644 index 00000000..9c6a23ee --- /dev/null +++ b/lib/src/ui/common/busymax_glyph.dart @@ -0,0 +1,29 @@ +enum BusyMaxGlyph { + calendar, + task, + add, + edit, + delete, + refresh, + settings, + account, + sync, + warning, + error, + information, + search, + previous, + next, + today, + month, + agenda, + recurrence, + reminder, + close, + more, + check, + signOut, + diagnostics, + feedback, + privacy, +} diff --git a/lib/src/ui/linux/linux_busymax_glyphs.dart b/lib/src/ui/linux/linux_busymax_glyphs.dart new file mode 100644 index 00000000..e48e5750 --- /dev/null +++ b/lib/src/ui/linux/linux_busymax_glyphs.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:yaru/yaru.dart'; + +import '../common/busymax_glyph.dart'; + +IconData linuxBusyMaxGlyph(BusyMaxGlyph glyph) => switch (glyph) { + BusyMaxGlyph.calendar => YaruIcons.calendar, + BusyMaxGlyph.task => Icons.task_alt_outlined, + BusyMaxGlyph.add => YaruIcons.plus, + BusyMaxGlyph.edit => Icons.edit_outlined, + BusyMaxGlyph.delete => YaruIcons.trash, + BusyMaxGlyph.refresh || BusyMaxGlyph.sync => YaruIcons.refresh, + BusyMaxGlyph.settings => YaruIcons.settings, + BusyMaxGlyph.account => YaruIcons.user, + BusyMaxGlyph.warning => YaruIcons.warning, + BusyMaxGlyph.error => YaruIcons.error, + BusyMaxGlyph.information => Icons.info_outline, + BusyMaxGlyph.search => YaruIcons.search, + BusyMaxGlyph.previous => YaruIcons.arrow_left, + BusyMaxGlyph.next => YaruIcons.arrow_right, + BusyMaxGlyph.today => Icons.calendar_today_outlined, + BusyMaxGlyph.month => Icons.calendar_view_month, + BusyMaxGlyph.agenda => Icons.view_agenda_outlined, + BusyMaxGlyph.recurrence => Icons.repeat, + BusyMaxGlyph.reminder => YaruIcons.bell, + BusyMaxGlyph.close => YaruIcons.window_close, + BusyMaxGlyph.more => YaruIcons.view_more, + BusyMaxGlyph.check => YaruIcons.ok, + BusyMaxGlyph.signOut => YaruIcons.log_out, + BusyMaxGlyph.diagnostics => YaruIcons.monitor, + BusyMaxGlyph.feedback => Icons.feedback_outlined, + BusyMaxGlyph.privacy => YaruIcons.shield, +}; + +String? linuxNativeMenuGlyph(BusyMaxGlyph glyph) => switch (glyph) { + BusyMaxGlyph.calendar || BusyMaxGlyph.today => 'x-office-calendar-symbolic', + BusyMaxGlyph.task || BusyMaxGlyph.check => 'checkbox-checked-symbolic', + BusyMaxGlyph.add => 'list-add-symbolic', + BusyMaxGlyph.edit => 'document-edit-symbolic', + BusyMaxGlyph.delete => 'user-trash-symbolic', + BusyMaxGlyph.refresh || BusyMaxGlyph.sync => 'view-refresh-symbolic', + BusyMaxGlyph.settings => 'preferences-system-symbolic', + BusyMaxGlyph.account => 'avatar-default-symbolic', + BusyMaxGlyph.warning => 'dialog-warning-symbolic', + BusyMaxGlyph.error => 'dialog-error-symbolic', + BusyMaxGlyph.information => 'help-about-symbolic', + BusyMaxGlyph.search => 'system-search-symbolic', + BusyMaxGlyph.reminder => 'preferences-system-notifications-symbolic', + BusyMaxGlyph.privacy => 'security-high-symbolic', + _ => null, +}; diff --git a/lib/src/ui/windows/windows_account_removal_dialog.dart b/lib/src/ui/windows/windows_account_removal_dialog.dart new file mode 100644 index 00000000..08ef1775 --- /dev/null +++ b/lib/src/ui/windows/windows_account_removal_dialog.dart @@ -0,0 +1,62 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; + +class WindowsAccountRemovalOptions { + const WindowsAccountRemovalOptions({required this.revokeGoogleAuthorization}); + + final bool revokeGoogleAuthorization; +} + +Future showWindowsAccountRemovalDialog( + BuildContext context, { + required String accountLabel, + required bool canRevokeGoogleAuthorization, +}) { + var revoke = false; + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.removeAccountTitle(accountLabel)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.removeAccountConfirmation), + if (canRevokeGoogleAuthorization) ...[ + const SizedBox(height: 16), + Checkbox( + checked: revoke, + onChanged: (value) => setState(() => revoke = value ?? false), + content: Text(l10n.revokeGoogleAccess), + ), + const SizedBox(height: 4), + Text( + l10n.revokeGoogleAccessDescription, + style: FluentTheme.of(context).typography.caption, + ), + ], + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogContext, + WindowsAccountRemovalOptions(revokeGoogleAuthorization: revoke), + ), + child: Text(l10n.removeAccountAction), + ), + ], + ); + }, + ), + ); +} diff --git a/lib/src/ui/windows/windows_busymax_glyphs.dart b/lib/src/ui/windows/windows_busymax_glyphs.dart new file mode 100644 index 00000000..ab0a77e1 --- /dev/null +++ b/lib/src/ui/windows/windows_busymax_glyphs.dart @@ -0,0 +1,34 @@ +import 'package:fluent_ui/fluent_ui.dart' show IconData; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; + +import '../common/busymax_glyph.dart'; + +IconData windowsBusyMaxGlyph(BusyMaxGlyph glyph) => switch (glyph) { + BusyMaxGlyph.calendar => FluentIcons.calendar_24_regular, + BusyMaxGlyph.task => FluentIcons.clipboard_task_24_regular, + BusyMaxGlyph.add => FluentIcons.add_24_regular, + BusyMaxGlyph.edit => FluentIcons.edit_24_regular, + BusyMaxGlyph.delete => FluentIcons.delete_24_regular, + BusyMaxGlyph.refresh || + BusyMaxGlyph.sync => FluentIcons.arrow_sync_24_regular, + BusyMaxGlyph.settings => FluentIcons.settings_24_regular, + BusyMaxGlyph.account => FluentIcons.person_24_regular, + BusyMaxGlyph.warning => FluentIcons.warning_24_regular, + BusyMaxGlyph.error => FluentIcons.error_circle_24_regular, + BusyMaxGlyph.information => FluentIcons.info_24_regular, + BusyMaxGlyph.search => FluentIcons.search_24_regular, + BusyMaxGlyph.previous => FluentIcons.arrow_left_24_regular, + BusyMaxGlyph.next => FluentIcons.arrow_right_24_regular, + BusyMaxGlyph.today => FluentIcons.calendar_today_24_regular, + BusyMaxGlyph.month => FluentIcons.calendar_month_24_regular, + BusyMaxGlyph.agenda => FluentIcons.calendar_agenda_24_regular, + BusyMaxGlyph.recurrence => FluentIcons.arrow_repeat_all_24_regular, + BusyMaxGlyph.reminder => FluentIcons.clock_alarm_24_regular, + BusyMaxGlyph.close => FluentIcons.dismiss_24_regular, + BusyMaxGlyph.more => FluentIcons.more_horizontal_24_regular, + BusyMaxGlyph.check => FluentIcons.checkmark_24_regular, + BusyMaxGlyph.signOut => FluentIcons.sign_out_24_regular, + BusyMaxGlyph.diagnostics => FluentIcons.clipboard_error_24_regular, + BusyMaxGlyph.feedback => FluentIcons.comment_24_regular, + BusyMaxGlyph.privacy => FluentIcons.shield_24_regular, +}; diff --git a/lib/src/ui/windows/windows_calendar_activation_flows.dart b/lib/src/ui/windows/windows_calendar_activation_flows.dart new file mode 100644 index 00000000..d7a980ab --- /dev/null +++ b/lib/src/ui/windows/windows_calendar_activation_flows.dart @@ -0,0 +1,269 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../ical/ical_import_service.dart'; +import '../../ical/ical_ingestion.dart'; + +Future showWindowsIcsImportFlow( + BuildContext context, + WidgetRef ref, { + String? filePath, +}) async { + const typeGroup = XTypeGroup( + label: 'iCalendar', + extensions: ['ics'], + mimeTypes: ['text/calendar'], + ); + final selected = filePath == null + ? await openFile(acceptedTypeGroups: const [typeGroup]) + : XFile(filePath); + if (selected == null || !context.mounted) return; + try { + if (await selected.length() > icalIngestionDecodedBodyLimit) { + throw const IcalIngestionException( + 'IcalBodyTooLarge', + 'The calendar data exceeds the 16 MiB limit.', + ); + } + final service = ref.read(icalImportServiceProvider); + final preview = service.parsePreview(await selected.readAsBytes()); + final destinations = await service.writableDestinations(); + if (!context.mounted) return; + final destination = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _WindowsIcsPreviewDialog( + preview: preview, + destinations: destinations, + ), + ); + if (destination == null || !context.mounted) return; + final report = await service.importPreview( + preview: preview, + destination: destination, + ); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) => _WindowsIcsReportDialog(report: report), + ); + } on Object catch (error) { + if (!context.mounted) return; + final code = switch (error) { + IcalIngestionException(:final code) => code, + FileSystemException(:final osError) => + osError?.errorCode.toString() ?? 'FileError', + _ => 'IcalImportFailed', + }; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).importIcsPreview), + content: InfoBar( + title: Text(AppLocalizations.of(context).importIcsFailed(code)), + severity: InfoBarSeverity.error, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + } +} + +class _WindowsIcsPreviewDialog extends StatefulWidget { + const _WindowsIcsPreviewDialog({ + required this.preview, + required this.destinations, + }); + + final IcalImportPreview preview; + final List destinations; + + @override + State<_WindowsIcsPreviewDialog> createState() => + _WindowsIcsPreviewDialogState(); +} + +class _WindowsIcsPreviewDialogState extends State<_WindowsIcsPreviewDialog> { + CalendarSourceEntity? _destination; + + @override + void initState() { + super.initState(); + _destination = widget.destinations.firstOrNull; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final omitted = widget.preview.fieldsThatWillBeOmitted.toList()..sort(); + return ContentDialog( + title: Text(l10n.importIcsPreview), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.importEventsFound(widget.preview.eventCount)), + if (widget.preview.invalidEventCount > 0) + Text(l10n.importInvalidEvents(widget.preview.invalidEventCount)), + if (omitted.isNotEmpty) + Text(l10n.importFieldsOmitted(omitted.join(', '))), + const SizedBox(height: 16), + if (widget.destinations.isEmpty) + InfoBar( + title: Text(l10n.noWritableCalendars), + severity: InfoBarSeverity.warning, + ) + else + InfoLabel( + label: l10n.importDestinationCalendar, + child: ComboBox( + isExpanded: true, + value: _destination, + items: [ + for (final source in widget.destinations) + ComboBoxItem(value: source, child: Text(source.summary)), + ], + onChanged: (value) => setState(() => _destination = value), + ), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _destination == null + ? null + : () => Navigator.pop(context, _destination), + child: Text(l10n.importIcsConfirm), + ), + ], + ); + } +} + +class _WindowsIcsReportDialog extends StatelessWidget { + const _WindowsIcsReportDialog({required this.report}); + + final IcalImportReport report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final omitted = report.fieldsIntentionallyOmitted.toList()..sort(); + return ContentDialog( + title: Text(l10n.importIcsComplete), + content: Text( + [ + l10n.importQueued(report.queued), + l10n.importDuplicatesSkipped(report.duplicatesSkipped), + l10n.importUnsupportedSets(report.unsupportedRecurrenceSets.length), + l10n.importInvalidEvents(report.invalidEvents), + if (omitted.isNotEmpty) l10n.importFieldsOmitted(omitted.join(', ')), + ].join('\n'), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + } +} + +Future showWindowsWebCalSubscriptionFlow( + BuildContext context, + WidgetRef ref, { + String? initialUrl, +}) async { + final url = TextEditingController(text: initialUrl); + final name = TextEditingController(); + var busy = false; + String? error; + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.addCalendarSubscription), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.calendarSubscriptionsDescription), + const SizedBox(height: 12), + InfoLabel( + label: l10n.subscriptionUrl, + child: TextBox(controller: url, enabled: !busy), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.subscriptionName, + child: TextBox(controller: name, enabled: !busy), + ), + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar(title: Text(error!), severity: InfoBarSeverity.error), + ], + ], + ), + actions: [ + Button( + onPressed: busy ? null : () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: busy || url.text.trim().isEmpty + ? null + : () async { + setState(() { + busy = true; + error = null; + }); + try { + await ref + .read(webCalSubscriptionServiceProvider) + .addSubscription( + subscriptionUrl: url.text.trim(), + localName: name.text.trim().isEmpty + ? null + : name.text.trim(), + ); + if (dialogContext.mounted) Navigator.pop(dialogContext); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.subscriptionOperationFailed( + l10n.operationFailed, + ); + }); + } + }, + child: busy + ? const SizedBox(width: 16, height: 16, child: ProgressRing()) + : Text(l10n.addSubscriptionAction), + ), + ], + ); + }, + ), + ); + url.dispose(); + name.dispose(); +} diff --git a/lib/src/ui/windows/windows_dav_account_dialogs.dart b/lib/src/ui/windows/windows_dav_account_dialogs.dart new file mode 100644 index 00000000..5e2b9e5b --- /dev/null +++ b/lib/src/ui/windows/windows_dav_account_dialogs.dart @@ -0,0 +1,154 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; + +class WindowsAppleCredentialInput { + const WindowsAppleCredentialInput({ + required this.email, + required this.password, + }); + + final String email; + final String password; +} + +Future showWindowsAppleICloudDialog( + BuildContext context, +) async { + final email = TextEditingController(); + final password = TextEditingController(); + var validationAttempted = false; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final invalid = + validationAttempted && + (email.text.trim().isEmpty || password.text.trim().isEmpty); + return ContentDialog( + title: Text(l10n.connectAppleICloudTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.appleAccountEmail, + child: TextBox(controller: email, autofocus: true), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.appleAppSpecificPassword, + child: TextBox( + controller: password, + obscureText: true, + enableSuggestions: false, + ), + ), + const SizedBox(height: 8), + Text( + l10n.appleAppSpecificPasswordHelp, + style: FluentTheme.of(context).typography.caption, + ), + if (invalid) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(l10n.requiredField), + severity: InfoBarSeverity.warning, + ), + ], + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () { + if (email.text.trim().isEmpty || password.text.trim().isEmpty) { + setState(() => validationAttempted = true); + return; + } + Navigator.pop( + dialogContext, + WindowsAppleCredentialInput( + email: email.text.trim(), + password: password.text, + ), + ); + }, + child: Text(l10n.connectAccountAction), + ), + ], + ); + }, + ), + ); + email.dispose(); + password.dispose(); + return result; +} + +Future showWindowsNextcloudServerDialog(BuildContext context) async { + final server = TextEditingController(); + var validationAttempted = false; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.connectNextcloudTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.nextcloudServerUrl, + child: TextBox(controller: server, autofocus: true), + ), + const SizedBox(height: 8), + Text( + l10n.nextcloudServerUrlHelp, + style: FluentTheme.of(context).typography.caption, + ), + const SizedBox(height: 8), + Text( + l10n.nextcloudBrowserAuthorizationHelp, + style: FluentTheme.of(context).typography.caption, + ), + if (validationAttempted && server.text.trim().isEmpty) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(l10n.requiredField), + severity: InfoBarSeverity.warning, + ), + ], + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () { + if (server.text.trim().isEmpty) { + setState(() => validationAttempted = true); + return; + } + Navigator.pop(dialogContext, server.text.trim()); + }, + child: Text(l10n.connectAccountAction), + ), + ], + ); + }, + ), + ); + server.dispose(); + return result; +} diff --git a/lib/src/ui/windows/windows_desktop_runtime.dart b/lib/src/ui/windows/windows_desktop_runtime.dart new file mode 100644 index 00000000..86719820 --- /dev/null +++ b/lib/src/ui/windows/windows_desktop_runtime.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/tray/domain/tray_presentation_formatter.dart'; +import '../../platform/common/desktop_services.dart'; +import '../../platform/windows/windows_tray_service.dart'; +import 'windows_event_editor_dialog.dart'; +import 'windows_task_editor_dialog.dart'; + +class WindowsDesktopRuntime extends ConsumerStatefulWidget { + const WindowsDesktopRuntime({ + required this.child, + required this.startMinimizedAtLaunch, + super.key, + }); + + final Widget child; + final bool startMinimizedAtLaunch; + + @override + ConsumerState createState() => + _WindowsDesktopRuntimeState(); +} + +class _WindowsDesktopRuntimeState extends ConsumerState { + WindowsTrayService? _tray; + BusyMaxTrayPresentationFormatter? _formatter; + Timer? _refreshTimer; + bool _configurationRunning = false; + bool _configurationPending = false; + bool _startMinimizedHandled = false; + String? _localeTag; + + @override + void dispose() { + _refreshTimer?.cancel(); + final tray = _tray; + if (tray != null) unawaited(tray.stop()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(appSettingsControllerProvider); + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + if (_localeTag != locale || _formatter == null) { + _localeTag = locale; + _formatter = BusyMaxTrayPresentationFormatter( + BusyMaxTrayPresentationStrings( + showBusyMax: l10n.trayShowBusyMax, + newEvent: l10n.trayNewEvent, + newTask: l10n.trayNewTask, + today: l10n.trayToday, + allDay: l10n.trayAllDay, + now: l10n.trayNow, + calendarEvent: l10n.trayCalendarEvent, + untitledEvent: l10n.trayUntitledEvent, + nothingElseToday: l10n.trayNothingElseToday, + openTodayAgenda: l10n.trayOpenTodayAgenda, + syncNow: l10n.traySyncNow, + syncing: l10n.traySyncing, + notConnected: l10n.trayNotConnected, + notYetSynced: l10n.trayNotYetSynced, + settings: l10n.traySettings, + quitBusyMax: l10n.trayQuitBusyMax, + offline: l10n.networkOffline, + offlineDescription: l10n.networkOfflineDescription, + formatTime: (value) => DateFormat.jm(locale).format(value), + tasksDueToday: l10n.trayTasksDueToday, + lastSyncedJustNow: l10n.trayLastSyncedJustNow, + lastSyncedMinutesAgo: l10n.trayLastSyncedMinutesAgo, + lastSyncedHoursAgo: l10n.trayLastSyncedHoursAgo, + lastSyncedDaysAgo: l10n.trayLastSyncedDaysAgo, + ), + ); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_configure(settings)); + }); + return widget.child; + } + + Future _configure(AppSettings settings) async { + if (_configurationRunning) { + _configurationPending = true; + return; + } + _configurationRunning = true; + try { + final needsTray = + settings.showTrayIcon || + settings.runInBackgroundWhenClosed || + settings.startMinimizedToTray || + widget.startMinimizedAtLaunch; + final window = ref.read(desktopWindowServiceProvider); + if (!needsTray) { + await window.setHideOnClose(false); + await _tray?.stop(); + _refreshTimer?.cancel(); + _refreshTimer = null; + return; + } + final tray = _tray ??= WindowsTrayService( + loadPresentation: () async { + final presentation = await ref + .read(trayPresentationServiceProvider) + .load(); + return _formatter!.format(presentation); + }, + onCommand: _handleCommand, + ); + var available = tray.isAvailable || await tray.start(); + if (available) { + await tray.refresh(); + available = tray.isAvailable; + } + await window.setHideOnClose( + settings.runInBackgroundWhenClosed && available, + ); + if (!available) { + // Never strand a process without either a visible window or tray. + await window.showWindow(); + } else { + // The presentation and online/offline icon were refreshed above. + } + _refreshTimer ??= Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) { + unawaited(_configure(ref.read(appSettingsControllerProvider))); + } + }); + if (!_startMinimizedHandled && + (widget.startMinimizedAtLaunch || settings.startMinimizedToTray)) { + _startMinimizedHandled = true; + if (available) { + await window.hideWindow(); + } else { + await window.showWindow(); + } + } + } finally { + _configurationRunning = false; + if (_configurationPending && mounted) { + _configurationPending = false; + unawaited(_configure(ref.read(appSettingsControllerProvider))); + } + } + } + + Future _handleCommand(WindowsTrayCommand command) async { + final window = ref.read(desktopWindowServiceProvider); + switch (command) { + case WindowsTrayCommand.open: + await window.showWindow(); + case WindowsTrayCommand.newEvent: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.schedule); + await WidgetsBinding.instance.endOfFrame; + final context = this.context; + if (context.mounted) { + await showWindowsEventEditorDialog(context, ref); + } + case WindowsTrayCommand.newTask: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.tasks); + await WidgetsBinding.instance.endOfFrame; + final context = this.context; + if (context.mounted) await showWindowsTaskEditorDialog(context, ref); + case WindowsTrayCommand.today: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.schedule); + case WindowsTrayCommand.synchronize: + await ref.read(allAccountsSyncRunnerProvider)(); + await _tray?.refresh(); + case WindowsTrayCommand.settings: + await window.showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open(DesktopNavigationDestination.settings); + case WindowsTrayCommand.quit: + ref.read(notificationSchedulerProvider).stop(); + ref.read(syncSchedulerProvider).stop(); + try { + await _tray?.stop(); + } on Object { + // Continue deterministic shutdown even if Explorer is unavailable. + } + try { + await ref.read(desktopNotificationBackendProvider).close(); + } on Object { + // The package notification host may already be disconnected. + } + try { + await ref.read(databaseProvider).close(); + } on Object { + // Native quit remains the final step so no invisible process lingers. + } + await window.quitApp(); + } + } +} diff --git a/lib/src/ui/windows/windows_diagnostics_dialog.dart b/lib/src/ui/windows/windows_diagnostics_dialog.dart new file mode 100644 index 00000000..2796f611 --- /dev/null +++ b/lib/src/ui/windows/windows_diagnostics_dialog.dart @@ -0,0 +1,230 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../core/logging/redacting_logger.dart'; +import '../../db/app_database.dart'; +import '../../google_tasks/api/google_tasks_api_surface.dart'; +import '../../google_tasks/api/tasks_discovery_revision.dart'; +import '../../features/sync/pending_op_resolution_service.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; + +Future showWindowsDiagnosticsDialog(BuildContext context, WidgetRef ref) { + return showDialog( + context: context, + builder: (context) => const _WindowsDiagnosticsDialog(), + ); +} + +class _WindowsDiagnosticsDialog extends ConsumerWidget { + const _WindowsDiagnosticsDialog(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final methods = implementedGoogleTasksMethods.toList()..sort(); + final accountId = ref.watch(activeAccountProvider); + final database = ref.watch(databaseProvider); + final settings = ref.watch(appSettingsControllerProvider); + final resolutionService = ref.watch(pendingOpResolutionServiceProvider); + final timeZone = ref.watch(localTimeZoneProvider); + final timeZoneDiagnostic = ref + .watch(localTimeZoneSourceProvider) + .diagnostic; + return ContentDialog( + title: Text(l10n.diagnostics), + constraints: const BoxConstraints(maxWidth: 760, maxHeight: 700), + content: ListView( + children: [ + SelectableText(Platform.operatingSystemVersion), + SelectableText(timeZone), + if (timeZoneDiagnostic != null) SelectableText(timeZoneDiagnostic), + const SizedBox(height: 16), + Text( + l10n.googleTasksApi, + style: FluentTheme.of(context).typography.subtitle, + ), + const SizedBox(height: 4), + SelectableText(l10n.discoveryRevision(googleTasksDiscoveryRevision)), + const SizedBox(height: 12), + Expander( + header: Text(l10n.implementedMethods), + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final method in methods) + ListTile( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.diagnostics), + ), + title: SelectableText(method), + subtitle: Text( + method.endsWith('.list') || method.endsWith('.get') + ? l10n.supportsTasksScopes + : l10n.requiresTasksScope, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + l10n.blockedPendingOperations, + style: FluentTheme.of(context).typography.subtitle, + ), + const SizedBox(height: 8), + if (accountId == null) + Text(l10n.signInToInspectPendingOperations) + else + StreamBuilder>( + stream: database.pendingOpsDao.watchBlockedOps(accountId), + builder: (context, snapshot) { + final operations = snapshot.data ?? const []; + if (operations.isEmpty) { + return Text(l10n.noBlockedPendingOperations); + } + return Column( + children: [ + for (final operation in operations) + _PendingOperationTile( + operation: operation, + resolutionService: resolutionService, + redactDetails: settings.redactTaskContentInDiagnostics, + ), + ], + ); + }, + ), + ], + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + } +} + +class _PendingOperationTile extends StatelessWidget { + const _PendingOperationTile({ + required this.operation, + required this.resolutionService, + required this.redactDetails, + }); + + final PendingOp operation; + final PendingOpResolutionService? resolutionService; + final bool redactDetails; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final error = [ + if (operation.lastErrorCode != null) operation.lastErrorCode, + if (operation.lastErrorMessage != null) + redactDetails + ? redactForLog(operation.lastErrorMessage) + : operation.lastErrorMessage, + ].join(' - '); + return ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.warning)), + title: Text('${operation.entityType}: ${operation.operation}'), + subtitle: Text( + [ + if (operation.taskListId != null) + l10n.pendingOpListId(operation.taskListId!), + if (operation.taskId != null) l10n.pendingOpTaskId(operation.taskId!), + l10n.pendingOpAttempts(operation.attemptCount), + if (error.isNotEmpty) error, + ].join('\n'), + ), + trailing: Tooltip( + message: l10n.operationActions, + child: DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + items: [ + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), + text: Text(l10n.retry), + onPressed: () => _retry(context), + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + text: Text(l10n.discard), + onPressed: () => _discard(context), + ), + ], + ), + ), + ); + } + + Future _retry(BuildContext context) async { + try { + final service = resolutionService; + if (service == null) return; + await service.retryNow(operation.id); + if (context.mounted) { + await _showResult(context, AppLocalizations.of(context).retryCompleted); + } + } on Object catch (error) { + if (context.mounted) await _showResult(context, redactForLog('$error')); + } + } + + Future _discard(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.discardPendingOperation), + content: Text(l10n.discardPendingOperationConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.discard), + ), + ], + ), + ); + if (!(confirmed ?? false)) return; + try { + final service = resolutionService; + if (service == null) return; + await service.discard(operation.id); + if (context.mounted) { + await _showResult( + context, + AppLocalizations.of(context).pendingOperationDiscarded, + ); + } + } on Object catch (error) { + if (context.mounted) await _showResult(context, redactForLog('$error')); + } + } + + Future _showResult(BuildContext context, String message) { + return showDialog( + context: context, + builder: (context) => ContentDialog( + content: Text(message), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/windows/windows_event_editor_dialog.dart b/lib/src/ui/windows/windows_event_editor_dialog.dart new file mode 100644 index 00000000..4f5b1510 --- /dev/null +++ b/lib/src/ui/windows/windows_event_editor_dialog.dart @@ -0,0 +1,1338 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../calendar_providers/calendar_mutation.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/calendar/domain/event_move_policy.dart'; +import '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../providers/busy_provider.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_event_reminders.dart'; +import 'windows_guest_update_dialog.dart'; +import 'windows_recurrence_dialog.dart'; +import 'windows_time_zone_dialog.dart'; + +Future showWindowsEventEditorDialog( + BuildContext context, + WidgetRef ref, { + String? eventId, + DateTime? initialStart, +}) async { + final repository = ref.read(calendarRepositoryProvider); + final detail = eventId == null + ? null + : await repository.loadEventDetail(eventId); + if (eventId != null && detail == null) return false; + final originalDraft = detail == null + ? null + : EventEditorDraft.fromEventDetail(detail); + final accounts = await ref + .read(accountsRepositoryProvider) + .watchAccounts() + .first; + final sources = writableCalendarSources( + await ref + .read(calendarRepositoryProvider) + .watchSourcesForAccounts(accounts.map((account) => account.id).toList()) + .first, + ); + if (!context.mounted) return false; + if (sources.isEmpty) { + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).newEvent), + content: Text(AppLocalizations.of(context).noCalendarsSynced), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + return false; + } + + final title = TextEditingController(text: originalDraft?.title); + final description = TextEditingController(text: originalDraft?.description); + final location = TextEditingController(text: originalDraft?.location); + final guestEmail = TextEditingController(); + final categories = TextEditingController( + text: originalDraft?.categories.join(', '), + ); + var selectedSource = sources.firstWhere( + (source) => + source.id == originalDraft?.sourceId && + source.accountId == originalDraft?.accountId, + orElse: () => sources.first, + ); + final requestedStart = initialStart == null + ? null + : DateTime( + initialStart.year, + initialStart.month, + initialStart.day, + initialStart.hour == 0 && initialStart.minute == 0 + ? 9 + : initialStart.hour, + initialStart.minute, + ); + var start = + originalDraft?.start ?? + requestedStart ?? + DateTime.now().add(const Duration(hours: 1)); + start = DateTime( + start.year, + start.month, + start.day, + start.hour, + start.minute, + ); + var end = originalDraft?.end ?? start.add(const Duration(hours: 1)); + var allDay = originalDraft?.allDay ?? false; + var selectedTimeZone = + selectedSource.timeZone ?? ref.read(localTimeZoneProvider) ?? 'Etc/UTC'; + var recurrence = EventRecurrenceCodec.decode( + selectedSource.provider, + originalDraft?.recurrence, + baseDate: start, + ); + var recurrenceChanged = false; + var reminderMinutes = decodeWindowsEventReminderMinutes( + originalDraft?.reminders, + ); + var remindersChanged = false; + var attendees = [...?originalDraft?.attendees]; + var attendeesChanged = false; + var categoriesChanged = false; + var onlineMeeting = + originalDraft?.conference != null || + (originalDraft?.createConference ?? false); + var responseRequested = originalDraft?.responseRequested ?? true; + var hideAttendees = originalDraft?.hideAttendees ?? false; + var allowNewTimeProposals = originalDraft?.allowNewTimeProposals ?? true; + var importance = originalDraft?.importance ?? 'normal'; + var showAs = _showAsForProvider( + originalDraft?.showAs, + selectedSource.provider, + ); + var visibility = _visibilityForProvider( + originalDraft?.visibilityOrSensitivity, + selectedSource.provider, + ); + final initialSourceAccountId = selectedSource.accountId; + final initialSourceId = selectedSource.id; + final initialTitle = title.text; + final initialDescription = description.text; + final initialLocation = location.text; + final initialCategories = categories.text; + final initialStartValue = start; + final initialEndValue = end; + final initialAllDay = allDay; + final initialTimeZone = selectedTimeZone; + final initialOnlineMeeting = onlineMeeting; + final initialResponseRequested = responseRequested; + final initialHideAttendees = hideAttendees; + final initialAllowNewTimeProposals = allowNewTimeProposals; + final initialImportance = importance; + final initialShowAs = showAs; + final initialVisibility = visibility; + String? guestError; + var saving = false; + var saved = false; + var allowPop = false; + String? error; + bool hasPendingEdits() => + selectedSource.accountId != initialSourceAccountId || + selectedSource.id != initialSourceId || + title.text != initialTitle || + description.text != initialDescription || + location.text != initialLocation || + categories.text != initialCategories || + start != initialStartValue || + end != initialEndValue || + allDay != initialAllDay || + selectedTimeZone != initialTimeZone || + recurrenceChanged || + remindersChanged || + attendeesChanged || + categoriesChanged || + onlineMeeting != initialOnlineMeeting || + responseRequested != initialResponseRequested || + hideAttendees != initialHideAttendees || + allowNewTimeProposals != initialAllowNewTimeProposals || + importance != initialImportance || + showAs != initialShowAs || + visibility != initialVisibility; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final validEnd = allDay + ? DateTime( + end.year, + end.month, + end.day, + ).isAfter(DateTime(start.year, start.month, start.day)) + : end.isAfter(start); + void closeDialog() { + setState(() => allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (dialogContext.mounted) Navigator.pop(dialogContext); + }); + } + + return PopScope( + canPop: allowPop || (!saving && !hasPendingEdits()), + onPopInvokedWithResult: (didPop, _) async { + if (didPop || saving || allowPop) return; + if (await _confirmDiscardEventChanges(context) && context.mounted) { + closeDialog(); + } + }, + child: ContentDialog( + title: Text(detail == null ? l10n.newEvent : l10n.editEvent), + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.title, + child: TextBox( + controller: title, + autofocus: true, + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.calendar, + child: ComboBox( + isExpanded: true, + value: selectedSource, + items: [ + for (final source in sources) + ComboBoxItem( + value: source, + child: Text(source.summary), + ), + ], + onChanged: saving + ? null + : (source) { + if (source != null) { + setState(() { + selectedSource = source; + selectedTimeZone = + source.timeZone ?? + ref.read(localTimeZoneProvider) ?? + 'Etc/UTC'; + recurrence = const RecurrenceRule.none(); + reminderMinutes = const []; + recurrenceChanged = true; + remindersChanged = true; + showAs = _showAsForProvider( + null, + source.provider, + ); + visibility = _visibilityForProvider( + null, + source.provider, + ); + importance = 'normal'; + onlineMeeting = false; + if (source.provider == BusyProvider.google) { + categories.clear(); + categoriesChanged = true; + } + }); + } + }, + ), + ), + const SizedBox(height: 12), + ToggleSwitch( + checked: allDay, + onChanged: saving + ? null + : (value) => setState(() => allDay = value), + content: Text(l10n.allDay), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.startDateTime, + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + DatePicker( + selected: start, + onChanged: saving + ? null + : (date) => setState(() { + final duration = end.difference(start); + start = DateTime( + date.year, + date.month, + date.day, + start.hour, + start.minute, + ); + end = start.add(duration); + }), + ), + if (!allDay) + TimePicker( + selected: start, + onChanged: saving + ? null + : (time) => setState(() { + final duration = end.difference(start); + start = DateTime( + start.year, + start.month, + start.day, + time.hour, + time.minute, + ); + end = start.add(duration); + }), + ), + ], + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.endDateTime, + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + DatePicker( + selected: end, + onChanged: saving + ? null + : (date) => setState(() { + end = DateTime( + date.year, + date.month, + date.day, + end.hour, + end.minute, + ); + }), + ), + if (!allDay) + TimePicker( + selected: end, + onChanged: saving + ? null + : (time) => setState(() { + end = DateTime( + end.year, + end.month, + end.day, + time.hour, + time.minute, + ); + }), + ), + ], + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.selectTimeZone, + child: Button( + onPressed: saving + ? null + : () async { + final result = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: selectedTimeZone, + ); + if (result != null) { + setState(() => selectedTimeZone = result); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(selectedTimeZone), + ), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.reminder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for ( + var index = 0; + index < reminderMinutes.length; + index += 1 + ) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: ComboBox( + isExpanded: true, + value: reminderMinutes[index], + items: [ + for (final minutes + in windowsEventReminderValuesFor( + reminderMinutes[index], + )) + ComboBoxItem( + value: minutes, + child: Text( + _reminderLabel(l10n, minutes), + ), + ), + ], + onChanged: saving + ? null + : (value) { + if (value == null) return; + setState(() { + reminderMinutes = + normalizeWindowsEventReminderMinutes( + [ + ...reminderMinutes.take( + index, + ), + value, + ...reminderMinutes.skip( + index + 1, + ), + ], + ); + remindersChanged = true; + }); + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + onPressed: saving + ? null + : () => setState(() { + reminderMinutes = [ + ...reminderMinutes.take(index), + ...reminderMinutes.skip(index + 1), + ]; + remindersChanged = true; + }), + ), + ], + ), + ), + if (reminderMinutes.isEmpty || + (selectedSource.provider != + BusyProvider.microsoft && + reminderMinutes.length < + windowsEventReminderMinuteOptions.length)) + Button( + onPressed: saving + ? null + : () => setState(() { + reminderMinutes = [ + ...reminderMinutes, + nextWindowsEventReminderMinute( + reminderMinutes, + ), + ]; + remindersChanged = true; + }), + child: Text(l10n.addReminder), + ), + ], + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.repeat, + child: Button( + onPressed: saving + ? null + : () async { + final result = await showWindowsRecurrenceDialog( + context, + initial: recurrence, + baseDate: start, + allDay: allDay, + timeZone: selectedTimeZone, + providerLabel: + selectedSource.provider.displayName, + limits: EventRecurrenceCodec.limitsFor( + selectedSource.provider, + ), + ); + if (result != null) { + setState(() { + recurrence = result; + recurrenceChanged = true; + }); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + _recurrenceLabel(l10n, recurrence.frequency), + ), + ), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.availabilityShowAs, + child: ComboBox( + isExpanded: true, + value: showAs, + items: [ + for (final value in _showAsValues( + selectedSource.provider, + )) + ComboBoxItem( + value: value, + child: Text(_availabilityLabel(l10n, value)), + ), + ], + onChanged: saving + ? null + : (value) { + if (value != null) setState(() => showAs = value); + }, + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.visibility, + child: ComboBox( + isExpanded: true, + value: visibility, + items: [ + for (final value in _visibilityValues( + selectedSource.provider, + )) + ComboBoxItem( + value: value, + child: Text(_visibilityLabel(l10n, value)), + ), + ], + onChanged: saving + ? null + : (value) { + if (value != null) { + setState(() => visibility = value); + } + }, + ), + ), + if (selectedSource.provider == BusyProvider.microsoft) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.importance, + child: ComboBox( + isExpanded: true, + value: importance, + items: [ + ComboBoxItem( + value: 'low', + child: Text(l10n.importanceLow), + ), + ComboBoxItem( + value: 'normal', + child: Text(l10n.importanceNormal), + ), + ComboBoxItem( + value: 'high', + child: Text(l10n.importanceHigh), + ), + ], + onChanged: saving + ? null + : (value) { + if (value != null) { + setState(() => importance = value); + } + }, + ), + ), + ], + if (selectedSource.provider != BusyProvider.google) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.categories, + child: TextBox( + controller: categories, + enabled: !saving, + onChanged: (_) => + setState(() => categoriesChanged = true), + ), + ), + ], + if (selectedSource.provider == BusyProvider.google || + selectedSource.provider == BusyProvider.microsoft) ...[ + const SizedBox(height: 16), + ToggleSwitch( + checked: onlineMeeting, + onChanged: saving + ? null + : (value) => setState(() => onlineMeeting = value), + content: Text( + onlineMeeting && originalDraft?.conference != null + ? l10n.onlineMeetingAdded + : selectedSource.provider == BusyProvider.google + ? l10n.addGoogleMeet + : l10n.addTeamsMeeting, + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.guests, + child: Column( + children: [ + for (final attendee in attendees) + _WindowsAttendeeRow( + attendee: attendee, + requiredLabel: l10n.attendeeRequired, + optionalLabel: l10n.attendeeOptional, + canEdit: + !saving && + !attendee.self && + !attendee.organizer && + (originalDraft?.canManageAttendees ?? true), + onOptionalChanged: (optional) => setState(() { + attendees = [ + for (final item in attendees) + if (identical(item, attendee)) + EventAttendeeDraft( + email: item.email, + displayName: item.displayName, + optional: optional, + self: item.self, + organizer: item.organizer, + responseStatus: item.responseStatus, + ) + else + item, + ]; + attendeesChanged = true; + }), + onDelete: () => setState(() { + attendees = [ + for (final item in attendees) + if (!identical(item, attendee)) item, + ]; + attendeesChanged = true; + }), + ), + if (originalDraft?.canManageAttendees ?? true) + _WindowsAddGuestRow( + controller: guestEmail, + label: l10n.addGuest, + enabled: !saving, + onChanged: () => + setState(() => guestError = null), + onAdd: () { + final email = guestEmail.text.trim(); + if (!_looksLikeEmail(email)) { + setState( + () => + guestError = l10n.feedbackInvalidEmail, + ); + return; + } + if (!attendees.any( + (item) => + item.email.toLowerCase() == + email.toLowerCase(), + )) { + setState(() { + attendees = [ + ...attendees, + EventAttendeeDraft(email: email), + ]; + attendeesChanged = true; + }); + } + guestEmail.clear(); + }, + ), + if (guestError != null) + InfoBar( + title: Text(guestError!), + severity: InfoBarSeverity.warning, + ), + ], + ), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: responseRequested, + onChanged: saving + ? null + : (value) => + setState(() => responseRequested = value), + content: Text(l10n.requestResponses), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: hideAttendees, + onChanged: saving + ? null + : (value) => setState(() => hideAttendees = value), + content: Text(l10n.hideGuestList), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: allowNewTimeProposals, + onChanged: saving + ? null + : (value) => + setState(() => allowNewTimeProposals = value), + content: Text(l10n.allowNewTimeProposals), + ), + ], + const SizedBox(height: 12), + InfoLabel( + label: l10n.location, + child: TextBox(controller: location), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.description, + child: TextBox(controller: description, maxLines: 4), + ), + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(error!), + severity: InfoBarSeverity.error, + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: saving + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardEventChanges(context)) { + return; + } + closeDialog(); + }, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: saving || title.text.trim().isEmpty || !validEnd + ? null + : () async { + setState(() { + saving = true; + error = null; + }); + try { + final effectiveEnd = allDay + ? DateTime(end.year, end.month, end.day) + : end; + final effectiveStart = allDay + ? DateTime(start.year, start.month, start.day) + : start; + final encodedRecurrence = + recurrenceChanged && recurrence.repeats + ? EventRecurrenceCodec.encode( + selectedSource.provider, + recurrence, + baseDate: start, + allDay: allDay, + timeZone: selectedTimeZone, + ) + : null; + var draft = + (originalDraft ?? + EventEditorDraft.newEvent( + accountId: selectedSource.accountId, + sourceId: selectedSource.id, + providerCalendarId: + selectedSource.providerCalendarId, + start: effectiveStart, + end: effectiveEnd, + )) + .copyWith( + accountId: selectedSource.accountId, + sourceId: selectedSource.id, + providerCalendarId: + selectedSource.providerCalendarId, + title: title.text.trim(), + allDay: allDay, + start: effectiveStart, + end: effectiveEnd, + location: location.text.trim(), + description: description.text.trim(), + startTimeZone: selectedTimeZone, + endTimeZone: selectedTimeZone, + recurrence: recurrenceChanged + ? encodedRecurrence + : null, + recurrenceChanged: recurrenceChanged, + clearRecurrence: + recurrenceChanged && + !recurrence.repeats, + reminders: remindersChanged + ? encodeWindowsEventReminderPayload( + selectedSource.provider, + reminderMinutes, + ) + : null, + remindersChanged: remindersChanged, + attendees: attendeesChanged + ? attendees + : null, + attendeesChanged: attendeesChanged, + importance: + selectedSource.provider == + BusyProvider.microsoft + ? importance + : null, + showAs: showAs, + visibilityOrSensitivity: visibility, + categories: categoriesChanged + ? _categories(categories.text) + : null, + categoriesChanged: categoriesChanged, + createConference: + onlineMeeting && + originalDraft?.conference == null, + clearConference: !onlineMeeting, + responseRequested: responseRequested, + hideAttendees: hideAttendees, + allowNewTimeProposals: + allowNewTimeProposals, + ); + if (originalDraft?.providerRecurringEventId != null) { + final scope = + await _chooseRecurringEventMutationScope( + dialogContext, + selectedSource.provider, + ); + if (scope == null) { + if (dialogContext.mounted) { + setState(() => saving = false); + } + return; + } + draft = draft.copyWith( + recurringMutationScope: scope, + ); + } + + final moveSource = originalDraft == null + ? null + : sources.firstWhere( + (source) => + source.id == originalDraft.sourceId && + source.accountId == + originalDraft.accountId, + orElse: () => selectedSource, + ); + final moveStrategy = moveSource == null + ? CalendarEventMoveStrategy.none + : calendarEventMoveStrategy( + sourceAccountId: moveSource.accountId, + sourceId: moveSource.id, + sourceProviderCalendarId: + moveSource.providerCalendarId, + sourceProvider: moveSource.provider, + sourceDavCollectionId: + moveSource.davCollectionId, + destinationAccountId: + selectedSource.accountId, + destinationId: selectedSource.id, + destinationProviderCalendarId: + selectedSource.providerCalendarId, + destinationProvider: selectedSource.provider, + destinationDavCollectionId: + selectedSource.davCollectionId, + eventType: originalDraft?.eventType, + recurring: + originalDraft?.providerRecurringEventId != + null, + recurringScope: draft.recurringMutationScope, + ); + if (moveStrategy == + CalendarEventMoveStrategy.copyThenDelete) { + if (!dialogContext.mounted) return; + final confirmed = await _confirmCopyThenDelete( + dialogContext, + sourceLabel: _calendarSourceLabel( + moveSource!, + accounts, + ), + destinationLabel: _calendarSourceLabel( + selectedSource, + accounts, + ), + ); + if (!confirmed) { + if (dialogContext.mounted) { + setState(() => saving = false); + } + return; + } + } + + var guestUpdatePolicy = + CalendarGuestUpdatePolicy.send; + final guestProvider = + selectedSource.provider == BusyProvider.google || + selectedSource.provider == + BusyProvider.microsoft + ? selectedSource.provider + : moveSource?.provider; + if ((guestProvider == BusyProvider.google || + guestProvider == BusyProvider.microsoft) && + draft.isOrganizer == true && + _hasExternalGuests( + draft.attendees, + originalDraft?.attendees ?? const [], + )) { + if (!dialogContext.mounted) return; + final choice = await showWindowsGuestUpdateDialog( + dialogContext, + provider: guestProvider!, + action: WindowsGuestUpdateAction.save, + ); + if (choice == null) { + if (dialogContext.mounted) { + setState(() => saving = false); + } + return; + } + guestUpdatePolicy = choice; + } + if (detail == null) { + await repository.createLocalEvent( + draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + } else { + await repository.updateLocalEvent( + draft, + guestUpdatePolicy: guestUpdatePolicy, + ); + } + saved = true; + closeDialog(); + } on Object catch (_) { + setState(() { + saving = false; + error = l10n.operationFailed; + }); + } + }, + child: saving + ? const SizedBox( + width: 16, + height: 16, + child: ProgressRing(), + ) + : Text(detail == null ? l10n.create : l10n.save), + ), + ], + ), + ); + }, + ), + ); + title.dispose(); + description.dispose(); + location.dispose(); + guestEmail.dispose(); + categories.dispose(); + return saved; +} + +class _WindowsAttendeeRow extends StatelessWidget { + const _WindowsAttendeeRow({ + required this.attendee, + required this.requiredLabel, + required this.optionalLabel, + required this.canEdit, + required this.onOptionalChanged, + required this.onDelete, + }); + + final EventAttendeeDraft attendee; + final String requiredLabel; + final String optionalLabel; + final bool canEdit; + final ValueChanged onOptionalChanged; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final compact = + constraints.maxWidth < 440 || + MediaQuery.textScalerOf(context).scale(1) > 1.3; + final identity = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(attendee.email), + if (attendee.displayName != null) + Text( + attendee.displayName!, + style: FluentTheme.of(context).typography.caption, + ), + ], + ); + final controls = canEdit + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + ComboBox( + value: attendee.optional, + items: [ + ComboBoxItem(value: false, child: Text(requiredLabel)), + ComboBoxItem(value: true, child: Text(optionalLabel)), + ], + onChanged: (value) { + if (value != null) onOptionalChanged(value); + }, + ), + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + onPressed: onDelete, + ), + ], + ) + : Text(attendee.optional ? optionalLabel : requiredLabel); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + identity, + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerEnd, + child: controls, + ), + ], + ) + : Row( + children: [ + Expanded(child: identity), + const SizedBox(width: 8), + controls, + ], + ), + ); + }, + ); +} + +class _WindowsAddGuestRow extends StatelessWidget { + const _WindowsAddGuestRow({ + required this.controller, + required this.label, + required this.enabled, + required this.onChanged, + required this.onAdd, + }); + + final TextEditingController controller; + final String label; + final bool enabled; + final VoidCallback onChanged; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final compact = + constraints.maxWidth < 440 || + MediaQuery.textScalerOf(context).scale(1) > 1.3; + final field = TextBox( + controller: controller, + enabled: enabled, + placeholder: label, + onChanged: (_) => onChanged(), + onSubmitted: enabled ? (_) => onAdd() : null, + ); + final button = Button( + onPressed: enabled ? onAdd : null, + child: Text(label), + ); + return compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + field, + const SizedBox(height: 8), + Align(alignment: AlignmentDirectional.centerEnd, child: button), + ], + ) + : Row( + children: [ + Expanded(child: field), + const SizedBox(width: 8), + button, + ], + ); + }, + ); +} + +String _recurrenceLabel(AppLocalizations l10n, RecurrenceFrequency frequency) => + switch (frequency) { + RecurrenceFrequency.none => l10n.repeatNone, + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + }; + +String _reminderLabel(AppLocalizations l10n, int minutes) { + if (minutes == 0) return l10n.reminderAtStart; + const minutesPerDay = Duration.minutesPerHour * Duration.hoursPerDay; + if (minutes % minutesPerDay == 0) { + return l10n.reminderDaysBefore(minutes ~/ minutesPerDay); + } + if (minutes % Duration.minutesPerHour == 0) { + return l10n.reminderHoursBefore(minutes ~/ Duration.minutesPerHour); + } + return l10n.reminderMinutesBefore(minutes); +} + +List _showAsValues(BusyProvider provider) => + provider == BusyProvider.microsoft + ? const ['free', 'tentative', 'busy', 'oof', 'workingElsewhere'] + : const ['opaque', 'transparent']; + +String _showAsForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'free' || 'tentative' || 'busy' || 'oof' || 'workingElsewhere' => value!, + 'transparent' => 'free', + _ => 'busy', + }; + } + return switch (value) { + 'opaque' || 'transparent' => value!, + 'free' => 'transparent', + _ => 'opaque', + }; +} + +String _availabilityLabel(AppLocalizations l10n, String value) => + switch (value) { + 'opaque' || 'busy' => l10n.busy, + 'transparent' || 'free' => l10n.availabilityFree, + 'tentative' => l10n.availabilityTentative, + 'oof' => l10n.availabilityOutOfOffice, + 'workingElsewhere' => l10n.availabilityWorkingElsewhere, + _ => value, + }; + +List _visibilityValues(BusyProvider provider) => + provider == BusyProvider.microsoft + ? const ['normal', 'personal', 'private', 'confidential'] + : const ['default', 'public', 'private', 'confidential']; + +String _visibilityForProvider(String? value, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return switch (value) { + 'normal' || 'personal' || 'private' || 'confidential' => value!, + _ => 'normal', + }; + } + return switch (value) { + 'default' || 'public' || 'private' || 'confidential' => value!, + 'personal' => 'private', + _ => 'default', + }; +} + +String _visibilityLabel(AppLocalizations l10n, String value) => switch (value) { + 'default' => l10n.visibilityDefault, + 'public' => l10n.visibilityPublic, + 'private' => l10n.visibilityPrivate, + 'confidential' => l10n.visibilityConfidential, + 'normal' => l10n.sensitivityNormal, + 'personal' => l10n.sensitivityPersonal, + _ => value, +}; + +List _categories(String value) => value + .split(',') + .map((category) => category.trim()) + .where((category) => category.isNotEmpty) + .toSet() + .toList(growable: false); + +bool _looksLikeEmail(String value) => + RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value); + +bool _hasExternalGuests( + List attendees, [ + List previous = const [], +]) => [ + ...attendees, + ...previous, +].any((attendee) => !attendee.self && !attendee.organizer); + +Future _confirmDiscardEventChanges(BuildContext context) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.discardChanges), + content: const SizedBox.shrink(), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; +} + +Future _chooseRecurringEventMutationScope( + BuildContext context, + BusyProvider provider, +) { + final l10n = AppLocalizations.of(context); + final supportsFollowing = supportsThisAndFollowingEventMutation(provider); + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.recurringEventScope), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.chooseRecurringEventScope), + const SizedBox(height: 12), + Button( + onPressed: () => Navigator.pop( + dialogContext, + RecurringEventMutationScope.singleOccurrence, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(l10n.singleOccurrence), + ), + ), + const SizedBox(height: 8), + Button( + onPressed: supportsFollowing + ? () => Navigator.pop( + dialogContext, + RecurringEventMutationScope.thisAndFuture, + ) + : null, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + supportsFollowing + ? l10n.thisAndFollowingEvents + : '${l10n.thisAndFollowingEvents}\n' + '${l10n.thisAndFutureUnavailable}', + ), + ), + ), + const SizedBox(height: 8), + Button( + onPressed: () => Navigator.pop( + dialogContext, + RecurringEventMutationScope.entireSeries, + ), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(l10n.entireSeries), + ), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + ], + ), + ); +} + +Future _confirmCopyThenDelete( + BuildContext context, { + required String sourceLabel, + required String destinationLabel, +}) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.copyEventAndDeleteOriginal), + content: Text( + l10n.copyEventMoveWarning(sourceLabel, destinationLabel), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.copyAndDelete), + ), + ], + ), + ) ?? + false; +} + +String _calendarSourceLabel( + CalendarSourceEntity source, + List accounts, +) { + for (final account in accounts) { + if (account.id == source.accountId) { + return '${account.selectorLabel} · ${source.summary}'; + } + } + return '${source.provider.displayName} · ${source.summary}'; +} diff --git a/lib/src/ui/windows/windows_event_reminders.dart b/lib/src/ui/windows/windows_event_reminders.dart new file mode 100644 index 00000000..2ff60712 --- /dev/null +++ b/lib/src/ui/windows/windows_event_reminders.dart @@ -0,0 +1,66 @@ +import '../../providers/busy_provider.dart'; + +const windowsEventReminderMinuteOptions = [0, 5, 10, 30, 60, 1440]; + +List decodeWindowsEventReminderMinutes(Object? reminders) { + if (reminders is! Map) return const []; + final direct = reminders['reminderMinutesBeforeStart']; + if (direct is int) return normalizeWindowsEventReminderMinutes([direct]); + final overrides = reminders['overrides']; + if (overrides is List) { + final result = []; + for (final value in overrides) { + if (value is Map && value['minutes'] is int) { + result.add(value['minutes'] as int); + } + } + return normalizeWindowsEventReminderMinutes(result); + } + final davMinutes = reminders['minutes']; + return davMinutes is List + ? normalizeWindowsEventReminderMinutes(davMinutes.whereType()) + : const []; +} + +List normalizeWindowsEventReminderMinutes(Iterable values) { + final result = []; + for (final value in values) { + if (value < 0 || result.contains(value)) continue; + result.add(value); + } + return result; +} + +Object encodeWindowsEventReminderPayload( + BusyProvider provider, + List minutes, +) { + final normalized = normalizeWindowsEventReminderMinutes(minutes); + if (provider == BusyProvider.microsoft) { + return normalized.isEmpty + ? const {'isReminderOn': false} + : { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': normalized.first, + }; + } + return { + 'useDefault': false, + 'overrides': [ + for (final value in normalized) + {'method': 'popup', 'minutes': value}, + ], + }; +} + +List windowsEventReminderValuesFor(int selected) => + windowsEventReminderMinuteOptions.contains(selected) + ? windowsEventReminderMinuteOptions + : [...windowsEventReminderMinuteOptions, selected]; + +int nextWindowsEventReminderMinute(List existing) { + for (final value in windowsEventReminderMinuteOptions) { + if (!existing.contains(value)) return value; + } + return windowsEventReminderMinuteOptions.first; +} diff --git a/lib/src/ui/windows/windows_feedback_dialog.dart b/lib/src/ui/windows/windows_feedback_dialog.dart new file mode 100644 index 00000000..39ab5ca0 --- /dev/null +++ b/lib/src/ui/windows/windows_feedback_dialog.dart @@ -0,0 +1,285 @@ +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/connectivity/network_connectivity_service.dart'; +import '../../features/feedback/data/feedback_api_client.dart'; +import '../../features/feedback/data/feedback_submission.dart'; + +Future showWindowsFeedbackDialog(BuildContext context, WidgetRef ref) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => WindowsFeedbackDialog( + submissionService: ref.read(feedbackSubmissionServiceProvider), + ), + ); +} + +class WindowsFeedbackDialog extends StatefulWidget { + const WindowsFeedbackDialog({required this.submissionService, super.key}); + + final FeedbackSubmissionService submissionService; + + @override + State createState() => _WindowsFeedbackDialogState(); +} + +class _WindowsFeedbackDialogState extends State { + final _subject = TextEditingController(); + final _message = TextEditingController(); + final _replyEmail = TextEditingController(); + FeedbackCategory? _category; + var _includeTechnicalDetails = false; + var _submitting = false; + var _validationAttempted = false; + String? _status; + var _statusIsError = false; + var _submissionId = const Uuid().v4(); + + bool get _hasDraft => + _category != null || + _subject.text.trim().isNotEmpty || + _message.text.trim().isNotEmpty || + _replyEmail.text.trim().isNotEmpty || + _includeTechnicalDetails; + + @override + void dispose() { + _subject.dispose(); + _message.dispose(); + _replyEmail.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final validationMessage = _validationMessage(l10n); + return ContentDialog( + title: Text(l10n.reportAnIssue), + constraints: const BoxConstraints(maxWidth: 680, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.feedbackCategory, + child: ComboBox( + isExpanded: true, + value: _category, + placeholder: Text(l10n.feedbackSelectCategory), + items: [ + for (final category in FeedbackCategory.values) + ComboBoxItem( + value: category, + child: Text(_categoryLabel(l10n, category)), + ), + ], + onChanged: _submitting + ? null + : (value) => setState(() => _category = value), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.feedbackSubject, + child: TextBox( + controller: _subject, + enabled: !_submitting, + onChanged: (_) => setState(() => _status = null), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.feedbackDetailedMessage, + child: TextBox( + controller: _message, + enabled: !_submitting, + minLines: 4, + maxLines: 8, + onChanged: (_) => setState(() => _status = null), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.feedbackReplyEmail, + child: TextBox( + controller: _replyEmail, + enabled: !_submitting, + onChanged: (_) => setState(() => _status = null), + ), + ), + const SizedBox(height: 16), + Checkbox( + checked: _includeTechnicalDetails, + onChanged: _submitting + ? null + : (value) => setState( + () => _includeTechnicalDetails = value ?? false, + ), + content: Text(l10n.feedbackIncludeTechnicalDetails), + ), + const SizedBox(height: 4), + Text( + l10n.feedbackTechnicalDetailsDisclosure, + style: FluentTheme.of(context).typography.caption, + ), + if (validationMessage != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(validationMessage), + severity: InfoBarSeverity.warning, + ), + ], + if (_status != null) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: InfoBar( + title: Text(_status!), + severity: _statusIsError + ? InfoBarSeverity.error + : InfoBarSeverity.success, + ), + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: _submitting ? null : _cancel, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox(width: 16, height: 16, child: ProgressRing()) + : Text(l10n.feedbackSubmit), + ), + ], + ); + } + + String? _validationMessage(AppLocalizations l10n) { + if (!_validationAttempted) return null; + if (!FeedbackValidation.categoryIsValid(_category)) { + return l10n.feedbackCategoryRequired; + } + if (!FeedbackValidation.subjectIsValid(_subject.text)) { + return l10n.feedbackSubjectLengthError; + } + if (!FeedbackValidation.messageIsValid(_message.text)) { + return l10n.feedbackMessageLengthError; + } + if (!FeedbackValidation.replyEmailIsValid(_replyEmail.text)) { + return l10n.feedbackInvalidEmail; + } + return null; + } + + Future _cancel() async { + if (!_hasDraft) { + if (mounted) Navigator.pop(context); + return; + } + final l10n = AppLocalizations.of(context); + final discard = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.discardChanges), + content: Text(l10n.discardChangesConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ); + if ((discard ?? false) && mounted) Navigator.pop(context); + } + + Future _submit() async { + setState(() { + _validationAttempted = true; + _status = null; + }); + final category = _category; + if (category == null || + _validationMessage(AppLocalizations.of(context)) != null) { + return; + } + setState(() => _submitting = true); + final locale = Localizations.localeOf(context).toLanguageTag(); + try { + final package = await PackageInfo.fromPlatform(); + final receipt = await widget.submissionService.submit( + FeedbackSubmission( + submissionId: _submissionId, + appVersion: package.version.isEmpty ? 'unknown' : package.version, + buildNumber: package.buildNumber.isEmpty ? '0' : package.buildNumber, + category: category, + subject: _subject.text, + message: _message.text, + replyEmail: _replyEmail.text, + platform: Platform.operatingSystem, + technicalDetails: _includeTechnicalDetails + ? FeedbackTechnicalDetails( + osVersion: Platform.operatingSystemVersion, + locale: locale, + ) + : null, + ), + ); + if (!mounted) return; + _subject.clear(); + _message.clear(); + _replyEmail.clear(); + setState(() { + _category = null; + _includeTechnicalDetails = false; + _validationAttempted = false; + _status = AppLocalizations.of(context).feedbackSuccess(receipt.id); + _statusIsError = false; + _submissionId = const Uuid().v4(); + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _status = _failureMessage(AppLocalizations.of(context), error); + _statusIsError = true; + }); + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} + +String _categoryLabel(AppLocalizations l10n, FeedbackCategory category) => + switch (category) { + FeedbackCategory.problem => l10n.feedbackCategoryProblem, + FeedbackCategory.feature => l10n.feedbackCategoryFeature, + FeedbackCategory.privacySecurity => l10n.feedbackCategoryPrivacySecurity, + FeedbackCategory.usability => l10n.feedbackCategoryUsability, + FeedbackCategory.other => l10n.feedbackCategoryOther, + }; + +String _failureMessage(AppLocalizations l10n, Object error) => switch (error) { + NetworkUnavailableException() || + FeedbackConnectionFailure() => l10n.feedbackConnectionError, + FeedbackTimeoutFailure() => l10n.feedbackTimeoutError, + FeedbackRateLimitedFailure() => l10n.feedbackRateLimitedError, + FeedbackRejectedFailure() => l10n.feedbackRejectedError, + _ => l10n.feedbackServerError, +}; diff --git a/lib/src/ui/windows/windows_guest_update_dialog.dart b/lib/src/ui/windows/windows_guest_update_dialog.dart new file mode 100644 index 00000000..f02d5593 --- /dev/null +++ b/lib/src/ui/windows/windows_guest_update_dialog.dart @@ -0,0 +1,74 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../calendar_providers/calendar_mutation.dart'; +import '../../providers/busy_provider.dart'; + +enum WindowsGuestUpdateAction { save, delete } + +Future showWindowsGuestUpdateDialog( + BuildContext context, { + required BusyProvider provider, + required WindowsGuestUpdateAction action, +}) { + final l10n = AppLocalizations.of(context); + final saving = action == WindowsGuestUpdateAction.save; + if (provider == BusyProvider.microsoft) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text( + saving + ? l10n.microsoftNotifyGuestsSaveTitle + : l10n.microsoftNotifyGuestsDeleteTitle, + ), + content: Text( + saving + ? l10n.microsoftNotifyGuestsSaveMessage + : l10n.microsoftNotifyGuestsDeleteMessage, + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, CalendarGuestUpdatePolicy.send), + child: Text(saving ? l10n.save : l10n.delete), + ), + ], + ), + ); + } + if (provider != BusyProvider.google) { + return Future.value(CalendarGuestUpdatePolicy.send); + } + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.notifyGuestsTitle), + content: Text( + saving ? l10n.notifyGuestsSaveMessage : l10n.notifyGuestsDeleteMessage, + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + Button( + onPressed: () => + Navigator.pop(dialogContext, CalendarGuestUpdatePolicy.doNotSend), + child: Text(l10n.doNotSend), + ), + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, CalendarGuestUpdatePolicy.send), + child: Text(saving ? l10n.sendUpdates : l10n.sendCancellation), + ), + ], + ), + ); +} diff --git a/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart b/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart new file mode 100644 index 00000000..d06ac8e8 --- /dev/null +++ b/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart @@ -0,0 +1,112 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/busymax_shortcuts.dart'; + +Future showWindowsKeyboardShortcutsDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.keyboardShortcuts), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 640), + content: ListView( + shrinkWrap: true, + children: [ + _ShortcutGroup( + title: l10n.shortcutGroupGeneral, + entries: [ + ( + l10n.keyboardShortcuts, + BusyMaxShortcutLabels.keyboardShortcuts, + ), + (l10n.settings, BusyMaxShortcutLabels.settings), + (l10n.windowsSearch, BusyMaxShortcutLabels.search), + (l10n.showSidebar, BusyMaxShortcutLabels.sidebar), + ], + ), + _ShortcutGroup( + title: l10n.shortcutGroupNavigation, + entries: [ + ( + l10n.shortcutPreviousPeriod, + BusyMaxShortcutLabels.previousPeriod, + ), + (l10n.shortcutNextPeriod, BusyMaxShortcutLabels.nextPeriod), + (l10n.shortcutJumpToToday, BusyMaxShortcutLabels.today), + ], + ), + _ShortcutGroup( + title: l10n.shortcutGroupView, + entries: [ + (l10n.viewDay, BusyMaxShortcutLabels.dayView), + (l10n.viewWeek, BusyMaxShortcutLabels.weekView), + (l10n.viewMonth, BusyMaxShortcutLabels.monthView), + (l10n.viewYear, BusyMaxShortcutLabels.yearView), + (l10n.viewAgenda, BusyMaxShortcutLabels.agendaView), + ], + ), + _ShortcutGroup( + title: l10n.shortcutGroupCreateAndEdit, + entries: [ + (l10n.newEvent, BusyMaxShortcutLabels.newEvent), + (l10n.newTask, BusyMaxShortcutLabels.newTask), + (l10n.shortcutCancelEditing, BusyMaxShortcutLabels.dismiss), + ], + ), + ], + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + }, + ); +} + +class _ShortcutGroup extends StatelessWidget { + const _ShortcutGroup({required this.title, required this.entries}); + + final String title; + final List<(String, String)> entries; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: FluentTheme.of(context).typography.subtitle), + const SizedBox(height: 8), + for (final (label, shortcut) in entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded(child: Text(label)), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border.all( + color: FluentTheme.of( + context, + ).resources.controlStrokeColorDefault, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text(shortcut), + ), + ], + ), + ), + ], + ), + ); +} diff --git a/lib/src/ui/windows/windows_recurrence_dialog.dart b/lib/src/ui/windows/windows_recurrence_dialog.dart new file mode 100644 index 00000000..8904de94 --- /dev/null +++ b/lib/src/ui/windows/windows_recurrence_dialog.dart @@ -0,0 +1,279 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; + +enum _WindowsRecurrenceEnd { never, until, count } + +Future showWindowsRecurrenceDialog( + BuildContext context, { + required RecurrenceRule initial, + required DateTime baseDate, + required bool allDay, + required String timeZone, + required String providerLabel, + required RecurrenceRuleLimits limits, +}) { + var value = initial.isSupported ? initial : const RecurrenceRule.none(); + var end = value.count != null + ? _WindowsRecurrenceEnd.count + : value.untilRaw != null + ? _WindowsRecurrenceEnd.until + : _WindowsRecurrenceEnd.never; + var until = DateTime.tryParse(value.untilDateFor(timeZone: timeZone) ?? ''); + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final valid = limits.supports(value); + return ContentDialog( + title: Text(l10n.repeat), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 680), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.repeat, + child: ComboBox( + isExpanded: true, + value: value.frequency, + items: [ + for (final frequency in RecurrenceFrequency.values) + ComboBoxItem( + value: frequency, + child: Text(_frequencyLabel(l10n, frequency)), + ), + ], + onChanged: (frequency) { + if (frequency == null) return; + setState( + () => value = _ruleForFrequency(frequency, baseDate), + ); + }, + ), + ), + if (value.repeats) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.repeatEvery, + child: NumberBox( + value: value.interval, + min: 1, + max: 366, + mode: SpinButtonPlacementMode.inline, + onChanged: (interval) { + if (interval != null) { + setState( + () => value = value.copyWith(interval: interval), + ); + } + }, + ), + ), + ], + if (value.frequency == RecurrenceFrequency.weekly) ...[ + const SizedBox(height: 12), + Text( + l10n.repeatOn, + style: FluentTheme.of(context).typography.bodyStrong, + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final day in rfcWeekdays) + ToggleButton( + checked: value.byDay.contains(day), + onChanged: (checked) => setState(() { + final days = [...value.byDay]; + if (checked) { + if (!days.contains(day)) days.add(day); + } else if (days.length > 1) { + days.remove(day); + } + days.sort( + (a, b) => rfcWeekdays + .indexOf(a) + .compareTo(rfcWeekdays.indexOf(b)), + ); + value = value.copyWith(byDay: days); + }), + child: Text(_weekdayLabel(context, day)), + ), + ], + ), + ], + if (value.repeats) ...[ + const SizedBox(height: 16), + InfoLabel( + label: l10n.repeatEnd, + child: ComboBox<_WindowsRecurrenceEnd>( + isExpanded: true, + value: end, + items: [ + ComboBoxItem( + value: _WindowsRecurrenceEnd.never, + child: Text(l10n.repeatNever), + ), + ComboBoxItem( + value: _WindowsRecurrenceEnd.until, + child: Text(l10n.repeatUntil), + ), + ComboBoxItem( + value: _WindowsRecurrenceEnd.count, + child: Text(l10n.repeatAfter), + ), + ], + onChanged: (selection) { + if (selection == null) return; + setState(() { + end = selection; + value = switch (selection) { + _WindowsRecurrenceEnd.never => value.copyWith( + count: null, + untilRaw: null, + ), + _WindowsRecurrenceEnd.until => + value + .copyWith(count: null) + .withUntilDate( + _dateValue(until ?? baseDate), + allDay: allDay, + baseDate: baseDate, + timeZone: timeZone, + ), + _WindowsRecurrenceEnd.count => value.copyWith( + count: value.count ?? 10, + untilRaw: null, + ), + }; + }); + }, + ), + ), + if (end == _WindowsRecurrenceEnd.until) ...[ + const SizedBox(height: 12), + DatePicker( + selected: until ?? baseDate, + startDate: baseDate, + onChanged: (date) => setState(() { + until = date; + value = value.withUntilDate( + _dateValue(date), + allDay: allDay, + baseDate: baseDate, + timeZone: timeZone, + ); + }), + ), + ], + if (end == _WindowsRecurrenceEnd.count) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.repeatCount, + child: NumberBox( + value: value.count ?? 10, + min: 1, + max: 3500, + mode: SpinButtonPlacementMode.inline, + onChanged: (count) { + if (count != null) { + setState( + () => value = value.copyWith(count: count), + ); + } + }, + ), + ), + ], + ], + if (!valid) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text( + l10n.recurrenceUnsupportedByProvider(providerLabel), + ), + severity: InfoBarSeverity.warning, + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: valid + ? () => Navigator.pop(dialogContext, value) + : null, + child: Text(l10n.save), + ), + ], + ); + }, + ), + ); +} + +RecurrenceRule _ruleForFrequency( + RecurrenceFrequency frequency, + DateTime baseDate, +) { + if (frequency == RecurrenceFrequency.none) { + return const RecurrenceRule.none(); + } + return RecurrenceRule( + frequency: frequency, + interval: 1, + byDay: frequency == RecurrenceFrequency.weekly + ? [_rfcWeekday(baseDate.weekday)] + : const [], + byMonth: frequency == RecurrenceFrequency.yearly + ? [baseDate.month] + : const [], + byMonthDay: + frequency == RecurrenceFrequency.monthly || + frequency == RecurrenceFrequency.yearly + ? [baseDate.day] + : const [], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); +} + +String _frequencyLabel(AppLocalizations l10n, RecurrenceFrequency frequency) => + switch (frequency) { + RecurrenceFrequency.none => l10n.repeatNone, + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + }; + +String _rfcWeekday(int weekday) => rfcWeekdays[weekday - 1]; + +String _weekdayLabel(BuildContext context, String weekday) { + final day = rfcWeekdays.indexOf(weekday) + 1; + final date = DateTime(2024, 1, day); + return DateFormat.E( + Localizations.localeOf(context).toLanguageTag(), + ).format(date); +} + +String _dateValue(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart new file mode 100644 index 00000000..65346f10 --- /dev/null +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -0,0 +1,1538 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../app/busymax_shortcuts.dart'; +import '../../app/common/busymax_design_values.dart'; +import '../../calendar_providers/calendar_mutation.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../features/schedule/presentation/schedule_item_exporter.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../schedule/schedule_filters.dart'; +import '../../schedule/schedule_item.dart'; +import '../../schedule/schedule_range.dart'; +import '../../schedule/schedule_source_visibility.dart'; +import '../../schedule/schedule_view_mode.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_calendar_activation_flows.dart'; +import 'windows_event_editor_dialog.dart'; +import 'windows_guest_update_dialog.dart'; +import 'windows_schedule_source_pane.dart'; +import 'windows_task_details_dialog.dart'; +import 'windows_task_editor_dialog.dart'; + +class WindowsSchedulePage extends ConsumerStatefulWidget { + const WindowsSchedulePage({super.key}); + + @override + ConsumerState createState() => + _WindowsSchedulePageState(); +} + +class _WindowsSchedulePageState extends ConsumerState { + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + var _selectedDate = DateTime.now(); + var _query = ''; + late ScheduleViewMode _mode; + var _agendaDays = 30; + var _agendaTaskLimit = 100; + var _refreshRevision = 0; + String? _taskListsKey; + Future>? _taskListsFuture; + String? _itemsKey; + Future>? _itemsFuture; + var _sourcePaneCollapsed = false; + + @override + void initState() { + super.initState(); + _mode = ref.read(appSettingsControllerProvider).scheduleViewMode; + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _reload() { + setState(() { + _refreshRevision += 1; + _taskListsKey = null; + _taskListsFuture = null; + _itemsKey = null; + _itemsFuture = null; + }); + } + + @override + Widget build(BuildContext context) { + final accountsState = ref.watch(accountsStreamProvider); + if (accountsState.isLoading && accountsState.valueOrNull == null) { + return const ScaffoldPage(content: Center(child: ProgressRing())); + } + if (accountsState.hasError && accountsState.valueOrNull == null) { + return ScaffoldPage( + content: Center( + child: InfoBar( + title: Text(AppLocalizations.of(context).scheduleUnavailable), + severity: InfoBarSeverity.error, + ), + ), + ); + } + final accounts = accountsState.valueOrNull ?? const []; + final accountIds = [for (final account in accounts) account.id]; + return StreamBuilder>( + stream: ref + .watch(calendarRepositoryProvider) + .watchSourcesForAccounts(accountIds), + builder: (context, sourcesSnapshot) { + if (sourcesSnapshot.connectionState == ConnectionState.waiting && + !sourcesSnapshot.hasData) { + return const ScaffoldPage(content: Center(child: ProgressRing())); + } + if (sourcesSnapshot.hasError && !sourcesSnapshot.hasData) { + return ScaffoldPage( + content: Center( + child: InfoBar( + title: Text(AppLocalizations.of(context).scheduleUnavailable), + severity: InfoBarSeverity.error, + ), + ), + ); + } + final sources = sourcesSnapshot.data ?? const []; + return FutureBuilder>( + future: _taskListsFor(accounts), + builder: (context, listsSnapshot) { + if (listsSnapshot.connectionState != ConnectionState.done && + !listsSnapshot.hasData) { + return const ScaffoldPage(content: Center(child: ProgressRing())); + } + final taskLists = listsSnapshot.data ?? const []; + final settings = ref.watch(appSettingsControllerProvider); + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: sources, + taskLists: taskLists, + settings: settings, + ); + return _buildPage( + context, + accounts: accounts, + sources: sources, + taskLists: taskLists, + visibility: visibility, + ); + }, + ); + }, + ); + } + + Widget _buildPage( + BuildContext context, { + required List accounts, + required List sources, + required List taskLists, + required ScheduleSourceVisibility visibility, + }) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final range = _rangeForMode(); + return LayoutBuilder( + builder: (context, constraints) { + final canShowSourcePane = BusyMaxBreakpoints.showsSourcePane( + constraints.maxWidth, + ); + final showSourcePane = canShowSourcePane && !_sourcePaneCollapsed; + final sourcePane = WindowsScheduleSourcePane( + selectedDate: _selectedDate, + accounts: accounts, + calendarSources: sources, + taskLists: taskLists, + visibleCalendarSourceIds: visibility.visibleCalendarSourceIds, + visibleTaskListKeys: visibility.visibleTaskListKeys, + onDateSelected: _openDay, + onCalendarVisibilityChanged: _setCalendarVisible, + onTaskListVisibilityChanged: _setTaskListVisible, + onSourcesChanged: _reload, + ); + return CallbackShortcuts( + bindings: { + BusyMaxShortcutActivators.search: _focusSearch, + BusyMaxShortcutActivators.sidebar: () { + if (canShowSourcePane) { + setState(() => _sourcePaneCollapsed = !showSourcePane); + } else { + unawaited( + _showSourcesDialog( + accounts: accounts, + sources: sources, + taskLists: taskLists, + visibility: visibility, + ), + ); + } + }, + BusyMaxShortcutActivators.dismiss: _dismissSearch, + const SingleActivator( + LogicalKeyboardKey.arrowLeft, + shift: true, + ): () => + _invokeUnmodifiedShortcut(() => _movePeriod(-1)), + const SingleActivator( + LogicalKeyboardKey.arrowRight, + shift: true, + ): () => + _invokeUnmodifiedShortcut(() => _movePeriod(1)), + const SingleActivator(LogicalKeyboardKey.keyE): () => + _invokeUnmodifiedShortcut(() => unawaited(_createEvent())), + const SingleActivator(LogicalKeyboardKey.keyT): () => + _invokeUnmodifiedShortcut(() => unawaited(_createTask())), + const SingleActivator(LogicalKeyboardKey.keyT, shift: true): () => + _invokeUnmodifiedShortcut(() => _selectDate(DateTime.now())), + const SingleActivator(LogicalKeyboardKey.digit1): () => + _invokeUnmodifiedShortcut(() => _setMode(ScheduleViewMode.day)), + const SingleActivator(LogicalKeyboardKey.numpad1): () => + _invokeUnmodifiedShortcut(() => _setMode(ScheduleViewMode.day)), + const SingleActivator(LogicalKeyboardKey.digit2): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.week), + ), + const SingleActivator(LogicalKeyboardKey.numpad2): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.week), + ), + const SingleActivator(LogicalKeyboardKey.digit3): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.month), + ), + const SingleActivator(LogicalKeyboardKey.numpad3): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.month), + ), + const SingleActivator(LogicalKeyboardKey.digit4): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.year), + ), + const SingleActivator(LogicalKeyboardKey.numpad4): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.year), + ), + const SingleActivator(LogicalKeyboardKey.digit5): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.agenda), + ), + const SingleActivator(LogicalKeyboardKey.numpad5): () => + _invokeUnmodifiedShortcut( + () => _setMode(ScheduleViewMode.agenda), + ), + }, + child: ScaffoldPage( + header: PageHeader( + title: Text(_periodTitle(l10n, locale, range)), + commandBar: CommandBar( + compactBreakpointWidth: BusyMaxBreakpoints.compact, + primaryItems: [ + if (!showSourcePane) + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + label: Text(l10n.showSidebar), + onPressed: () { + if (canShowSourcePane) { + setState(() => _sourcePaneCollapsed = false); + } else { + unawaited( + _showSourcesDialog( + accounts: accounts, + sources: sources, + taskLists: taskLists, + visibility: visibility, + ), + ); + } + }, + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.today)), + label: Text(l10n.today), + onPressed: () => _selectDate(DateTime.now()), + ), + if (_mode != ScheduleViewMode.agenda) ...[ + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.previous)), + tooltip: l10n.shortcutPreviousPeriodDescription, + onPressed: () => _movePeriod(-1), + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.next)), + tooltip: l10n.shortcutNextPeriodDescription, + onPressed: () => _movePeriod(1), + ), + ], + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + label: Text(l10n.newEvent), + onPressed: () => unawaited(_createEvent()), + ), + ], + secondaryItems: [ + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), + label: Text(l10n.newTask), + onPressed: () => unawaited(_createTask()), + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), + label: Text(l10n.refresh), + onPressed: _reload, + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + label: Text(l10n.importIcsFile), + onPressed: () => unawaited( + showWindowsIcsImportFlow( + context, + ref, + ).then((_) => _reload()), + ), + ), + ], + ), + ), + content: Row( + children: [ + if (showSourcePane) ...[ + SizedBox( + width: BusyMaxDimensions.sourcePaneWidth, + child: sourcePane, + ), + const Divider(direction: Axis.vertical), + ], + Expanded( + child: Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 20, + 0, + 20, + 12, + ), + child: Row( + children: [ + Expanded( + child: TextBox( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: l10n.windowsSearch, + prefix: Padding( + padding: const EdgeInsetsDirectional.only( + start: 10, + ), + child: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.search), + ), + ), + onChanged: (value) { + _query = value; + _reload(); + }, + ), + ), + const SizedBox(width: 12), + _ViewModeMenu(mode: _mode, onChanged: _setMode), + ], + ), + ), + Expanded( + child: FutureBuilder>( + future: _itemsFor( + range: range, + accounts: accounts, + visibility: visibility, + ), + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done) { + return const Center(child: ProgressRing()); + } + if (snapshot.hasError) { + return Center( + child: InfoBar( + title: Text(l10n.scheduleUnavailable), + severity: InfoBarSeverity.error, + action: Button( + onPressed: _reload, + child: Text(l10n.retry), + ), + ), + ); + } + final items = snapshot.data ?? const []; + if (items.isEmpty && + _mode != ScheduleViewMode.agenda) { + return _WindowsScheduleEmptyState( + searching: _query.isNotEmpty, + noVisibleSources: + visibility + .visibleCalendarSourceIds + .isEmpty && + visibility.visibleTaskListKeys.isEmpty, + ); + } + return _ScheduleModeView( + mode: _mode, + selectedDate: _selectedDate, + range: range, + items: items, + locale: locale, + onOpen: _showItemDetails, + onSelectDate: _mode == ScheduleViewMode.year + ? _openMonth + : _openDay, + onLoadMoreAgenda: _loadMoreAgenda, + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Future _createEvent() async { + final changed = await showWindowsEventEditorDialog( + context, + ref, + initialStart: _selectedDate, + ); + if (changed && mounted) _reload(); + } + + Future _createTask() async { + final changed = await showWindowsTaskEditorDialog(context, ref); + if (changed && mounted) _reload(); + } + + void _focusSearch() => _searchFocusNode.requestFocus(); + + void _dismissSearch() { + if (!_searchFocusNode.hasFocus && _query.isEmpty) return; + _searchController.clear(); + _query = ''; + _searchFocusNode.unfocus(); + _reload(); + } + + void _invokeUnmodifiedShortcut(VoidCallback callback) { + final focusContext = FocusManager.instance.primaryFocus?.context; + if (focusContext != null && + (focusContext.widget is EditableText || + focusContext.findAncestorWidgetOfExactType() != + null)) { + return; + } + callback(); + } + + Future> _taskListsFor(List accounts) { + final key = + accounts + .where((account) => account.isTaskCapable) + .map((account) => account.id) + .toList() + ..sort(); + final encodedKey = key.join('\u0000'); + if (_taskListsKey != encodedKey || _taskListsFuture == null) { + _taskListsKey = encodedKey; + _taskListsFuture = Future.wait([ + for (final accountId in key) + ref + .read(taskListsRepositoryForAccountProvider(accountId)) + .listTaskLists(), + ]).then((groups) => [for (final group in groups) ...group]); + } + return _taskListsFuture!; + } + + Future> _itemsFor({ + required ScheduleRange range, + required List accounts, + required ScheduleSourceVisibility visibility, + }) { + final calendarIds = visibility.visibleCalendarSourceIds.toList()..sort(); + final taskKeys = + visibility.visibleTaskListKeys + .map((key) => '${key.accountId}/${key.taskListId}') + .toList() + ..sort(); + final accountIds = [for (final account in accounts) account.id]..sort(); + final key = [ + _refreshRevision, + _mode.name, + range.start.toIso8601String(), + range.end.toIso8601String(), + _query, + accountIds.join(','), + calendarIds.join(','), + taskKeys.join(','), + ].join('|'); + if (_itemsKey == key && _itemsFuture != null) return _itemsFuture!; + _itemsKey = key; + final filters = ScheduleFilters( + query: _query, + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibility.visibleTaskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: true, + includeTasks: true, + showCompletedTasks: true, + showNoDateTasks: _mode == ScheduleViewMode.agenda, + ); + _itemsFuture = _loadItems(range, filters); + return _itemsFuture!; + } + + Future> _loadItems( + ScheduleRange range, + ScheduleFilters filters, + ) async { + final repository = ref.read(scheduleRepositoryProvider); + final items = [ + ...await repository.listItems(range: range, filters: filters), + ]; + if (_mode == ScheduleViewMode.agenda && _query.trim().isEmpty) { + final overdue = await repository.listOverdueTasks( + before: range.start, + limit: _agendaTaskLimit, + filters: filters, + ); + final noDate = await repository.listNoDateTasks( + limit: _agendaTaskLimit, + filters: filters, + ); + items.addAll(overdue.items); + items.addAll(noDate.items); + } + final unique = {}; + for (final item in items) { + unique['${item.kind.name}/${item.accountId}/${item.sourceId}/${item.id}'] = + item; + } + final result = unique.values.toList() + ..sort((left, right) { + final leftStart = left.start; + final rightStart = right.start; + if (leftStart == null && rightStart != null) return 1; + if (leftStart != null && rightStart == null) return -1; + final dateOrder = leftStart?.compareTo(rightStart!) ?? 0; + return dateOrder != 0 + ? dateOrder + : left.title.toLowerCase().compareTo(right.title.toLowerCase()); + }); + return result; + } + + ScheduleRange _rangeForMode() => switch (_mode) { + ScheduleViewMode.day => ScheduleRange.day(_selectedDate), + ScheduleViewMode.week => ScheduleRange.week(_selectedDate), + ScheduleViewMode.month => ScheduleRange.month(_selectedDate), + ScheduleViewMode.year => ScheduleRange.year(_selectedDate), + ScheduleViewMode.agenda => ScheduleRange( + start: _dateOnly(DateTime.now()), + end: DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day + _agendaDays, + ), + ), + }; + + String _periodTitle( + AppLocalizations l10n, + String locale, + ScheduleRange range, + ) => switch (_mode) { + ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(_selectedDate), + ScheduleViewMode.week => + '${DateFormat.yMMMd(locale).format(range.start)} – ' + '${DateFormat.yMMMd(locale).format(range.end.subtract(const Duration(days: 1)))}', + ScheduleViewMode.month => DateFormat.yMMMM(locale).format(_selectedDate), + ScheduleViewMode.year => DateFormat.y(locale).format(_selectedDate), + ScheduleViewMode.agenda => l10n.viewAgenda, + }; + + void _selectDate(DateTime date) { + setState(() { + _selectedDate = _dateOnly(date); + _itemsKey = null; + }); + } + + void _openDay(DateTime date) { + _selectDate(date); + _setMode(ScheduleViewMode.day); + } + + void _openMonth(DateTime date) { + _selectDate(DateTime(date.year, date.month)); + _setMode(ScheduleViewMode.month); + } + + void _movePeriod(int direction) { + final next = switch (_mode) { + ScheduleViewMode.day => DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day + direction, + ), + ScheduleViewMode.week => DateTime( + _selectedDate.year, + _selectedDate.month, + _selectedDate.day + (7 * direction), + ), + ScheduleViewMode.month => DateTime( + _selectedDate.year, + _selectedDate.month + direction, + 1, + ), + ScheduleViewMode.year => DateTime( + _selectedDate.year + direction, + _selectedDate.month, + 1, + ), + ScheduleViewMode.agenda => _selectedDate, + }; + _selectDate(next); + } + + void _setMode(ScheduleViewMode mode) { + if (_mode == mode) return; + setState(() { + _mode = mode; + _itemsKey = null; + }); + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setScheduleViewMode(mode), + ); + } + + void _loadMoreAgenda() { + setState(() { + _agendaDays += 30; + _agendaTaskLimit += 100; + _itemsKey = null; + }); + } + + void _setCalendarVisible(CalendarSourceEntity source, bool visible) { + unawaited( + ref + .read(calendarRepositoryProvider) + .setSourceSelected(source.id, visible) + .then((_) => _reload()), + ); + } + + void _setTaskListVisible(TaskListEntity list, bool visible) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setTaskListVisibleInSchedule( + accountId: list.accountId, + taskListId: list.id, + visible: visible, + ) + .then((_) => _reload()), + ); + } + + Future _showSourcesDialog({ + required List accounts, + required List sources, + required List taskLists, + required ScheduleSourceVisibility visibility, + }) { + final visibleCalendars = {...visibility.visibleCalendarSourceIds}; + final visibleTaskLists = {...visibility.visibleTaskListKeys}; + return showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => ContentDialog( + title: Text(AppLocalizations.of(context).showSidebar), + content: SizedBox( + width: 520, + height: math.min(MediaQuery.sizeOf(context).height - 180, 620), + child: WindowsScheduleSourcePane( + selectedDate: _selectedDate, + accounts: accounts, + calendarSources: sources, + taskLists: taskLists, + visibleCalendarSourceIds: visibleCalendars, + visibleTaskListKeys: visibleTaskLists, + onDateSelected: (date) { + Navigator.pop(dialogContext); + _openDay(date); + }, + onCalendarVisibilityChanged: (source, visible) { + setDialogState(() { + if (visible) { + visibleCalendars.add(source.id); + } else { + visibleCalendars.remove(source.id); + } + }); + _setCalendarVisible(source, visible); + }, + onTaskListVisibilityChanged: (list, visible) { + final key = ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ); + setDialogState(() { + if (visible) { + visibleTaskLists.add(key); + } else { + visibleTaskLists.remove(key); + } + }); + _setTaskListVisible(list, visible); + }, + onSourcesChanged: _reload, + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ), + ); + } + + Future _showItemDetails(ScheduleItem item) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final time = _itemDateLabel(item, locale); + return showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(item.title), + content: Text( + [ + time, + ?item.sourceName, + ?item.accountDisplayName, + if (item case CalendarScheduleItem(:final location?)) location, + if (item case CalendarScheduleItem(:final description?)) + description, + if (item case TaskScheduleItem(:final notes?)) notes, + ].where((value) => value.trim().isNotEmpty).join('\n'), + ), + actions: [ + if (item.capabilities.canEdit) + Button( + onPressed: () { + Navigator.pop(dialogContext); + unawaited(_edit(item)); + }, + child: Text( + item is TaskScheduleItem ? l10n.editTask : l10n.editEvent, + ), + ), + if (item.capabilities.canDelete) + Button( + onPressed: () { + Navigator.pop(dialogContext); + unawaited(_delete(item)); + }, + child: Text(l10n.delete), + ), + Button( + onPressed: () { + Navigator.pop(dialogContext); + unawaited(_export(item)); + }, + child: Text(l10n.export), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.close), + ), + ], + ), + ); + } + + Future _edit(ScheduleItem item) async { + if (!mounted) return; + final currentContext = context; + final bool changed; + if (item is CalendarScheduleItem) { + changed = await showWindowsEventEditorDialog( + currentContext, + ref, + eventId: item.id, + ); + } else if (item is TaskScheduleItem) { + changed = await showWindowsTaskDetailsDialog(currentContext, ref, item); + } else { + return; + } + if (changed && mounted) _reload(); + } + + Future _export(ScheduleItem item) async { + final l10n = AppLocalizations.of(context); + try { + final file = await exportScheduleItemWithSaveDialog(item); + if (file == null || !mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.export), + content: Text(l10n.exportedFile(file.path)), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ); + } on Object catch (_) { + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.export), + content: InfoBar( + title: Text(l10n.exportFailed(l10n.operationFailed)), + severity: InfoBarSeverity.error, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ); + } + } + + Future _delete(ScheduleItem item) async { + final l10n = AppLocalizations.of(context); + RecurringEventMutationScope? scope; + if (item is CalendarScheduleItem && item.providerRecurringEventId != null) { + scope = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.deleteEvent), + content: Text(l10n.chooseRecurringEventScope), + actions: [ + Button( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + Button( + onPressed: () => Navigator.pop( + context, + RecurringEventMutationScope.singleOccurrence, + ), + child: Text(l10n.singleOccurrence), + ), + if (supportsThisAndFollowingEventMutation(item.provider)) + Button( + onPressed: () => Navigator.pop( + context, + RecurringEventMutationScope.thisAndFuture, + ), + child: Text(l10n.thisAndFollowingEvents), + ), + FilledButton( + onPressed: () => Navigator.pop( + context, + RecurringEventMutationScope.entireSeries, + ), + child: Text(l10n.entireSeries), + ), + ], + ), + ); + if (scope == null) return; + } + var guestUpdatePolicy = CalendarGuestUpdatePolicy.send; + final hasGuestDeliveryChoice = + item is CalendarScheduleItem && + item.isOrganizer == true && + _calendarItemHasExternalGuests(item); + if (hasGuestDeliveryChoice) { + if (!mounted) return; + final choice = await showWindowsGuestUpdateDialog( + context, + provider: item.provider, + action: WindowsGuestUpdateAction.delete, + ); + if (choice == null) return; + guestUpdatePolicy = choice; + } else if (scope == null) { + if (!mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text( + item is TaskScheduleItem ? l10n.deleteTask : l10n.deleteEvent, + ), + content: Text(item.title), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (!(confirmed ?? false)) return; + } + try { + switch (item) { + case CalendarScheduleItem(): + await ref + .read(calendarRepositoryProvider) + .deleteLocalEvent( + item.id, + recurringScope: scope, + guestUpdatePolicy: guestUpdatePolicy, + ); + case TaskScheduleItem(): + await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .deleteTask(item.sourceId, item.id); + } + if (mounted) _reload(); + } on Object catch (_) { + if (!mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + content: InfoBar( + title: Text(l10n.operationFailed), + severity: InfoBarSeverity.error, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ), + ); + } + } +} + +bool _calendarItemHasExternalGuests(CalendarScheduleItem item) => + item.attendees.any( + (attendee) => attendee['self'] != true && attendee['organizer'] != true, + ); + +class _ViewModeMenu extends StatelessWidget { + const _ViewModeMenu({required this.mode, required this.onChanged}); + + final ScheduleViewMode mode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropDownButton( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(windowsBusyMaxGlyph(_modeGlyph(mode))), + const SizedBox(width: 8), + Text(_modeLabel(AppLocalizations.of(context), mode)), + ], + ), + items: [ + for (final value in ScheduleViewMode.values) + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(_modeGlyph(value))), + text: Text(_modeLabel(AppLocalizations.of(context), value)), + onPressed: () => onChanged(value), + ), + ], + ); + } +} + +class _ScheduleModeView extends StatelessWidget { + const _ScheduleModeView({ + required this.mode, + required this.selectedDate, + required this.range, + required this.items, + required this.locale, + required this.onOpen, + required this.onSelectDate, + required this.onLoadMoreAgenda, + }); + + final ScheduleViewMode mode; + final DateTime selectedDate; + final ScheduleRange range; + final List items; + final String locale; + final ValueChanged onOpen; + final ValueChanged onSelectDate; + final VoidCallback onLoadMoreAgenda; + + @override + Widget build(BuildContext context) => switch (mode) { + ScheduleViewMode.day => _AgendaList( + items: items, + locale: locale, + onOpen: onOpen, + groupByDate: false, + ), + ScheduleViewMode.week => _WeekView( + range: range, + items: items, + locale: locale, + onOpen: onOpen, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.month => _MonthView( + selectedDate: selectedDate, + range: range, + items: items, + locale: locale, + onOpen: onOpen, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.year => _YearView( + selectedDate: selectedDate, + items: items, + locale: locale, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.agenda => _AgendaList( + items: items, + locale: locale, + onOpen: onOpen, + groupByDate: true, + onLoadMore: onLoadMoreAgenda, + ), + }; +} + +class _AgendaList extends StatelessWidget { + const _AgendaList({ + required this.items, + required this.locale, + required this.onOpen, + required this.groupByDate, + this.onLoadMore, + }); + + final List items; + final String locale; + final ValueChanged onOpen; + final bool groupByDate; + final VoidCallback? onLoadMore; + + @override + Widget build(BuildContext context) { + final rows = []; + if (items.isEmpty) { + rows.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Center( + child: Text(AppLocalizations.of(context).noEventsOrTasks), + ), + ), + ); + } + DateTime? previousDay; + var noDateShown = false; + for (final item in items) { + final day = item.start == null ? null : _dateOnly(item.start!); + if (groupByDate && day != previousDay) { + rows.add( + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(4, 14, 4, 6), + child: Text( + day == null + ? AppLocalizations.of(context).noDate + : DateFormat.yMMMMEEEEd(locale).format(day), + style: FluentTheme.of(context).typography.subtitle, + ), + ), + ); + previousDay = day; + noDateShown = day == null; + } else if (groupByDate && day == null && !noDateShown) { + rows.add( + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(4, 14, 4, 6), + child: Text( + AppLocalizations.of(context).noDate, + style: FluentTheme.of(context).typography.subtitle, + ), + ), + ); + noDateShown = true; + } + rows.add( + _ScheduleItemCard( + item: item, + locale: locale, + onPressed: () => onOpen(item), + ), + ); + } + if (onLoadMore != null) { + rows.add( + Padding( + padding: const EdgeInsets.only(top: 12), + child: Button( + onPressed: onLoadMore, + child: Text(AppLocalizations.of(context).windowsAgendaLoadMore), + ), + ), + ); + } + return ListView( + padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 20, 24), + children: rows, + ); + } +} + +class _WeekView extends StatelessWidget { + const _WeekView({ + required this.range, + required this.items, + required this.locale, + required this.onOpen, + required this.onSelectDate, + }); + + final ScheduleRange range; + final List items; + final String locale; + final ValueChanged onOpen; + final ValueChanged onSelectDate; + + @override + Widget build(BuildContext context) { + final days = [ + for (var offset = 0; offset < 7; offset += 1) + DateTime(range.start.year, range.start.month, range.start.day + offset), + ]; + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: math.max(constraints.maxWidth, 840), + height: constraints.maxHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final day in days) + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(3, 0, 3, 12), + child: Card( + child: Column( + children: [ + HyperlinkButton( + onPressed: () => onSelectDate(day), + child: Text(DateFormat.MMMEd(locale).format(day)), + ), + const Divider(), + Expanded( + child: ListView( + padding: const EdgeInsets.all(6), + children: [ + for (final item in items.where( + (item) => _itemOccursOn(item, day), + )) + _CompactScheduleItem( + item: item, + locale: locale, + onPressed: () => onOpen(item), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _MonthView extends StatelessWidget { + const _MonthView({ + required this.selectedDate, + required this.range, + required this.items, + required this.locale, + required this.onOpen, + required this.onSelectDate, + }); + + final DateTime selectedDate; + final ScheduleRange range; + final List items; + final String locale; + final ValueChanged onOpen; + final ValueChanged onSelectDate; + + @override + Widget build(BuildContext context) { + final days = []; + var cursor = range.start; + while (cursor.isBefore(range.end)) { + days.add(cursor); + cursor = DateTime(cursor.year, cursor.month, cursor.day + 1); + } + return Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 4), + child: Row( + children: [ + for (var index = 0; index < 7; index += 1) + Expanded( + child: Center( + child: Text( + DateFormat.E(locale).format(DateTime(2026, 1, 5 + index)), + style: FluentTheme.of(context).typography.caption, + ), + ), + ), + ], + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + childAspectRatio: days.length > 35 ? 1.0 : 0.82, + ), + itemCount: days.length, + itemBuilder: (context, index) { + final day = days[index]; + final dayItems = items + .where((item) => _itemOccursOn(item, day)) + .toList(); + return Card( + child: Padding( + padding: const EdgeInsets.all(5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + HyperlinkButton( + onPressed: () => onSelectDate(day), + child: Text( + '${day.day}', + style: day.month == selectedDate.month + ? null + : TextStyle( + color: FluentTheme.of(context).inactiveColor, + ), + ), + ), + for (final item in dayItems.take(2)) + _CompactScheduleItem( + item: item, + locale: locale, + onPressed: () => onOpen(item), + ), + if (dayItems.length > 2) + Text( + '+${dayItems.length - 2}', + textAlign: TextAlign.center, + style: FluentTheme.of(context).typography.caption, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _YearView extends StatelessWidget { + const _YearView({ + required this.selectedDate, + required this.items, + required this.locale, + required this.onSelectDate, + }); + + final DateTime selectedDate; + final List items; + final String locale; + final ValueChanged onSelectDate; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => GridView.builder( + padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 20, 20), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth >= 900 ? 4 : 3, + childAspectRatio: 1.45, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemCount: 12, + itemBuilder: (context, index) { + final month = DateTime(selectedDate.year, index + 1); + final count = items.where((item) { + final start = item.start; + return start != null && + start.year == month.year && + start.month == month.month; + }).length; + return Button( + onPressed: () => onSelectDate(month), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat.MMMM(locale).format(month), + style: FluentTheme.of(context).typography.subtitle, + ), + const SizedBox(height: 8), + Text(AppLocalizations.of(context).scheduleItemCount(count)), + ], + ), + ); + }, + ), + ); + } +} + +class _CompactScheduleItem extends StatelessWidget { + const _CompactScheduleItem({ + required this.item, + required this.locale, + required this.onPressed, + }); + + final ScheduleItem item; + final String locale; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final start = item.start; + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final label = item.allDay || start == null + ? item.title + : '${DateFormat.jm(locale).format(start)} ${item.title}'; + return Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Button( + onPressed: onPressed, + child: Row( + children: [ + Icon( + windowsBusyMaxGlyph( + task != null + ? task.completed + ? BusyMaxGlyph.check + : BusyMaxGlyph.task + : BusyMaxGlyph.calendar, + ), + size: 13, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: FluentTheme.of(context).typography.caption?.copyWith( + decoration: task?.completed == true + ? TextDecoration.lineThrough + : null, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _WindowsScheduleEmptyState extends StatelessWidget { + const _WindowsScheduleEmptyState({ + required this.searching, + required this.noVisibleSources, + }); + + final bool searching; + final bool noVisibleSources; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar), size: 40), + const SizedBox(height: 12), + Text( + searching + ? l10n.scheduleNoSearchResults + : noVisibleSources + ? l10n.scheduleNoSources + : l10n.noEventsOrTasks, + style: FluentTheme.of(context).typography.subtitle, + ), + if (searching) ...[ + const SizedBox(height: 4), + Text(l10n.scheduleNoSearchResultsDescription), + ], + ], + ), + ); + } +} + +class _ScheduleItemCard extends StatelessWidget { + const _ScheduleItemCard({ + required this.item, + required this.locale, + required this.onPressed, + }); + + final ScheduleItem item; + final String locale; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: ListTile( + leading: Icon( + windowsBusyMaxGlyph( + item.kind == ScheduleItemKind.task + ? BusyMaxGlyph.task + : BusyMaxGlyph.calendar, + ), + ), + title: Text( + item.title, + style: task?.completed == true + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text( + [ + _itemDateLabel(item, locale), + ?item.sourceName, + ].where((value) => value.isNotEmpty).join(' · '), + ), + onPressed: onPressed, + ), + ), + ); + } +} + +BusyMaxGlyph _modeGlyph(ScheduleViewMode mode) => switch (mode) { + ScheduleViewMode.day => BusyMaxGlyph.today, + ScheduleViewMode.week => BusyMaxGlyph.calendar, + ScheduleViewMode.month => BusyMaxGlyph.month, + ScheduleViewMode.year => BusyMaxGlyph.calendar, + ScheduleViewMode.agenda => BusyMaxGlyph.agenda, +}; + +String _modeLabel(AppLocalizations l10n, ScheduleViewMode mode) => + switch (mode) { + ScheduleViewMode.day => l10n.viewDay, + ScheduleViewMode.week => l10n.viewWeek, + ScheduleViewMode.month => l10n.viewMonth, + ScheduleViewMode.year => l10n.viewYear, + ScheduleViewMode.agenda => l10n.viewAgenda, + }; + +String _itemDateLabel(ScheduleItem item, String locale) { + final start = item.start; + if (start == null) return ''; + return item.allDay + ? DateFormat.yMMMd(locale).format(start) + : DateFormat.yMMMd(locale).add_jm().format(start); +} + +DateTime _dateOnly(DateTime value) => + DateTime(value.year, value.month, value.day); + +bool _sameDay(DateTime left, DateTime right) => + left.year == right.year && + left.month == right.month && + left.day == right.day; + +bool _itemOccursOn(ScheduleItem item, DateTime day) { + final start = item.start; + if (start == null) return false; + if (item is! CalendarScheduleItem) return _sameDay(start, day); + final dayStart = _dateOnly(day); + final dayEnd = DateTime(dayStart.year, dayStart.month, dayStart.day + 1); + final end = item.end ?? start; + if (end.isAtSameMomentAs(start)) return _sameDay(start, day); + return start.isBefore(dayEnd) && end.isAfter(dayStart); +} diff --git a/lib/src/ui/windows/windows_schedule_source_pane.dart b/lib/src/ui/windows/windows_schedule_source_pane.dart new file mode 100644 index 00000000..3dae090b --- /dev/null +++ b/lib/src/ui/windows/windows_schedule_source_pane.dart @@ -0,0 +1,746 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../calendar_providers/calendar_colors.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/accounts/domain/account_collection_creation_capabilities.dart'; +import '../../features/calendar/data/calendar_collection_creation_service.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/schedule_filters.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; + +class WindowsScheduleSourcePane extends ConsumerStatefulWidget { + const WindowsScheduleSourcePane({ + required this.selectedDate, + required this.accounts, + required this.calendarSources, + required this.taskLists, + required this.visibleCalendarSourceIds, + required this.visibleTaskListKeys, + required this.onDateSelected, + required this.onCalendarVisibilityChanged, + required this.onTaskListVisibilityChanged, + required this.onSourcesChanged, + super.key, + }); + + final DateTime selectedDate; + final List accounts; + final List calendarSources; + final List taskLists; + final Set visibleCalendarSourceIds; + final Set visibleTaskListKeys; + final ValueChanged onDateSelected; + final void Function(CalendarSourceEntity source, bool visible) + onCalendarVisibilityChanged; + final void Function(TaskListEntity list, bool visible) + onTaskListVisibilityChanged; + final VoidCallback onSourcesChanged; + + @override + ConsumerState createState() => + _WindowsScheduleSourcePaneState(); +} + +class _WindowsScheduleSourcePaneState + extends ConsumerState { + late DateTime _displayedMonth = DateTime( + widget.selectedDate.year, + widget.selectedDate.month, + ); + + @override + void didUpdateWidget(covariant WindowsScheduleSourcePane oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedDate.year != widget.selectedDate.year || + oldWidget.selectedDate.month != widget.selectedDate.month) { + _displayedMonth = DateTime( + widget.selectedDate.year, + widget.selectedDate.month, + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + return ColoredBox( + color: FluentTheme.of(context).cardColor, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: _MiniMonth( + displayedMonth: _displayedMonth, + selectedDate: widget.selectedDate, + locale: locale, + onPrevious: () => setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month - 1, + ); + }), + onNext: () => setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month + 1, + ); + }), + onSelected: widget.onDateSelected, + ), + ), + const Divider(), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + for (final account in widget.accounts) ...[ + _AccountHeader( + account: account, + capabilities: AccountCollectionCreationCapabilities.resolve( + account: account, + networkAvailability: + ref.watch(networkAvailabilityProvider).valueOrNull ?? + ref + .read(networkConnectivityMonitorProvider) + .availability, + ), + onCreateCalendar: () => unawaited(_createCalendar(account)), + onCreateTaskList: () => unawaited(_createTaskList(account)), + ), + for (final source in widget.calendarSources.where( + (source) => + source.accountId == account.id && + !source.hidden && + !source.isDeleted, + )) + _VisibilityRow( + icon: BusyMaxGlyph.calendar, + title: source.summary, + checked: widget.visibleCalendarSourceIds.contains( + source.id, + ), + onChanged: (value) => + widget.onCalendarVisibilityChanged(source, value), + menuItems: _calendarMenuItems(context, source), + ), + for (final list in widget.taskLists.where( + (list) => + list.accountId == account.id && !list.pendingDelete, + )) + _VisibilityRow( + icon: BusyMaxGlyph.task, + title: list.title, + checked: widget.visibleTaskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + ), + onChanged: (value) => + widget.onTaskListVisibilityChanged(list, value), + menuItems: _taskListMenuItems(context, account, list), + ), + ], + if (widget.calendarSources.isEmpty && widget.taskLists.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text(l10n.scheduleNoSources), + ), + ], + ), + ), + ], + ), + ); + } + + List _calendarMenuItems( + BuildContext context, + CalendarSourceEntity source, + ) { + final l10n = AppLocalizations.of(context); + return [ + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.reminder)), + text: Text( + '${l10n.eventReminders} — ' + '${source.remindersEnabled ? l10n.onState : l10n.off}', + ), + onPressed: () => unawaited(_toggleCalendarReminders(source)), + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.settings)), + text: Text(l10n.calendarColor), + onPressed: source.capabilities.canChangeCalendarColor + ? () => unawaited(_changeCalendarColor(source)) + : null, + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.edit)), + text: Text(l10n.rename), + onPressed: source.capabilities.canRenameCalendar + ? () => unawaited(_renameCalendar(source)) + : null, + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + text: Text( + source.capabilities.removalMode == CalendarRemovalMode.removeFromList + ? l10n.removeFromMyCalendars + : l10n.delete, + ), + onPressed: source.capabilities.canRemoveCalendar + ? () => unawaited(_deleteCalendar(source)) + : null, + ), + ]; + } + + List _taskListMenuItems( + BuildContext context, + AccountEntity account, + TaskListEntity list, + ) { + final l10n = AppLocalizations.of(context); + final davCapabilities = list.davCollectionId == null + ? null + : ref + .watch( + davTaskCollectionCapabilitiesProvider(( + accountId: list.accountId, + taskListId: list.id, + )), + ) + .valueOrNull; + final canRename = account.provider == BusyProvider.microsoft + ? list.canRenameOrDeleteForMicrosoft + : list.davCollectionId != null + ? davCapabilities?.supportsListRename ?? false + : true; + final canDelete = account.provider == BusyProvider.microsoft + ? list.canRenameOrDeleteForMicrosoft + : list.davCollectionId != null + ? davCapabilities?.supportsListDelete ?? false + : true; + return [ + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.reminder)), + text: Text( + '${l10n.taskReminders} — ' + '${list.remindersEnabled ? l10n.onState : l10n.off}', + ), + onPressed: () => unawaited(_toggleTaskListReminders(list)), + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.edit)), + text: Text(l10n.rename), + onPressed: canRename ? () => unawaited(_renameTaskList(list)) : null, + ), + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + text: Text(list.isShared == true ? l10n.unshare : l10n.deleteList), + onPressed: canDelete ? () => unawaited(_deleteTaskList(list)) : null, + ), + ]; + } + + Future _createCalendar(AccountEntity account) async { + final title = await _textPrompt( + title: AppLocalizations.of(context).newCalendar, + actionLabel: AppLocalizations.of(context).create, + ); + if (title == null || !mounted) return; + await _runMutation(() async { + final result = await ref + .read(calendarCollectionCreationServiceProvider) + .createCalendar(accountId: account.id, title: title); + if (result.outcome == + CalendarCollectionCreationOutcome.createdRefreshPending && + mounted) { + await _showMessage( + AppLocalizations.of(context).calendarCreatedRefreshPending, + ); + } + }); + } + + Future _createTaskList(AccountEntity account) async { + final title = await _textPrompt( + title: AppLocalizations.of(context).newTaskList, + actionLabel: AppLocalizations.of(context).create, + ); + if (title == null || !mounted) return; + await _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .createTaskList(title), + ); + } + + Future _toggleCalendarReminders(CalendarSourceEntity source) => + _runMutation( + () => ref + .read(calendarRepositoryProvider) + .setSourceRemindersEnabled(source.id, !source.remindersEnabled), + ); + + Future _toggleTaskListReminders(TaskListEntity list) => _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .setRemindersEnabled(list.id, !list.remindersEnabled), + ); + + Future _changeCalendarColor(CalendarSourceEntity source) async { + final choices = calendarColorChoices(source.provider); + final choice = await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(AppLocalizations.of(context).calendarColor), + constraints: const BoxConstraints(maxWidth: 420, maxHeight: 600), + content: ListView( + shrinkWrap: true, + children: [ + for (var index = 0; index < choices.length; index += 1) + ListTile( + leading: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: _colorFromHex(choices[index].backgroundColor), + shape: BoxShape.circle, + ), + ), + title: Text( + AppLocalizations.of(context).calendarColorOption(index + 1), + ), + trailing: _isCurrentCalendarColor(source, choices[index]) + ? Icon(windowsBusyMaxGlyph(BusyMaxGlyph.check)) + : null, + onPressed: () => Navigator.pop(dialogContext, choices[index]), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + ], + ), + ); + if (choice == null || !mounted) return; + await _runCalendarMutation( + source.accountId, + () => ref + .read(calendarRepositoryProvider) + .setSourceColor(source.id, choice), + ); + } + + Future _renameCalendar(CalendarSourceEntity source) async { + final title = await _textPrompt( + title: source.capabilities.renameMode == CalendarRenameMode.personal + ? AppLocalizations.of(context).setCustomCalendarName + : AppLocalizations.of(context).rename, + actionLabel: AppLocalizations.of(context).rename, + initialValue: source.summary, + ); + if (title == null || title == source.summary || !mounted) return; + await _runCalendarMutation( + source.accountId, + () => ref + .read(calendarRepositoryProvider) + .renameLocalSource(source.id, title), + ); + } + + Future _deleteCalendar(CalendarSourceEntity source) async { + final l10n = AppLocalizations.of(context); + final removeFromList = + source.capabilities.removalMode == CalendarRemovalMode.removeFromList; + final confirmed = await _confirm( + title: removeFromList ? l10n.removeFromMyCalendars : l10n.delete, + message: removeFromList + ? l10n.removeCalendarConfirmation(source.summary) + : l10n.deleteCalendarConfirmation(source.summary), + actionLabel: removeFromList ? l10n.removeAction : l10n.delete, + ); + if (!confirmed || !mounted) return; + await _runCalendarMutation( + source.accountId, + () => ref.read(calendarRepositoryProvider).deleteLocalSource(source.id), + ); + } + + Future _renameTaskList(TaskListEntity list) async { + final l10n = AppLocalizations.of(context); + final title = await _textPrompt( + title: l10n.renameList, + actionLabel: l10n.rename, + initialValue: list.title, + ); + if (title == null || title == list.title || !mounted) return; + await _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .renameTaskList(list.id, title), + ); + } + + Future _deleteTaskList(TaskListEntity list) async { + final l10n = AppLocalizations.of(context); + final confirmed = await _confirm( + title: list.isShared == true ? l10n.unshare : l10n.deleteList, + message: list.isShared == true + ? l10n.unshareTaskListConfirmation(list.title) + : l10n.deleteTaskListConfirmation(list.title), + actionLabel: list.isShared == true ? l10n.unshare : l10n.delete, + ); + if (!confirmed || !mounted) return; + await _runMutation( + () => ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .deleteTaskList(list.id), + ); + } + + Future _runCalendarMutation( + String accountId, + Future Function() operation, + ) => _runMutation(() async { + await operation(); + ref + .read(pendingCalendarMutationSyncRequesterForAccountProvider(accountId)) + .request(); + }); + + Future _runMutation(Future Function() operation) async { + try { + await operation(); + if (mounted) widget.onSourcesChanged(); + } on Object { + if (mounted) { + await _showMessage(AppLocalizations.of(context).operationFailed); + } + } + } + + Future _textPrompt({ + required String title, + required String actionLabel, + String initialValue = '', + }) async { + final controller = TextEditingController(text: initialValue); + final value = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(title), + content: InfoLabel( + label: AppLocalizations.of(context).title, + child: TextBox(controller: controller, autofocus: true), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, controller.text.trim()), + child: Text(actionLabel), + ), + ], + ), + ); + controller.dispose(); + return value == null || value.trim().isEmpty ? null : value.trim(); + } + + Future _confirm({ + required String title, + required String message, + required String actionLabel, + }) async => + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(title), + content: Text(message), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(actionLabel), + ), + ], + ), + ) ?? + false; + + Future _showMessage(String message) => showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + content: Text(message), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); +} + +class _AccountHeader extends StatelessWidget { + const _AccountHeader({ + required this.account, + required this.capabilities, + required this.onCreateCalendar, + required this.onCreateTaskList, + }); + + final AccountEntity account; + final AccountCollectionCreationCapabilities capabilities; + final VoidCallback onCreateCalendar; + final VoidCallback onCreateTaskList; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.fromSTEB(14, 10, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + account.displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: FluentTheme.of(context).typography.caption, + ), + ), + if (capabilities.hasActions) + DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add), size: 16), + items: [ + if (capabilities.supportsCalendarCreation) + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + text: Text(AppLocalizations.of(context).newCalendar), + onPressed: capabilities.calendarActionEnabled + ? onCreateCalendar + : null, + ), + if (capabilities.supportsTaskListCreation) + MenuFlyoutItem( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), + text: Text(AppLocalizations.of(context).newTaskList), + onPressed: capabilities.taskListActionEnabled + ? onCreateTaskList + : null, + ), + ], + ), + ], + ), + ); +} + +class _VisibilityRow extends StatelessWidget { + const _VisibilityRow({ + required this.icon, + required this.title, + required this.checked, + required this.onChanged, + required this.menuItems, + }); + + final BusyMaxGlyph icon; + final String title; + final bool checked; + final ValueChanged onChanged; + final List menuItems; + + @override + Widget build(BuildContext context) { + return Semantics( + label: title, + checked: checked, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 3, 8, 3), + child: Row( + children: [ + Checkbox( + checked: checked, + onChanged: (value) => onChanged(value ?? false), + ), + const SizedBox(width: 8), + Icon(windowsBusyMaxGlyph(icon), size: 16), + const SizedBox(width: 8), + Expanded( + child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more), size: 16), + items: menuItems, + ), + ], + ), + ), + ); + } +} + +Color _colorFromHex(String source) { + final hex = source.trim().replaceFirst('#', ''); + final parsed = int.tryParse(hex, radix: 16); + return Color(parsed == null ? 0xFF0078D4 : 0xFF000000 | parsed); +} + +bool _isCurrentCalendarColor( + CalendarSourceEntity source, + CalendarColorChoice choice, +) => switch (source.provider) { + BusyProvider.google => + source.backgroundColor?.toLowerCase() == + choice.backgroundColor.toLowerCase(), + BusyProvider.microsoft => source.colorId == choice.providerValue, + _ => false, +}; + +class _MiniMonth extends StatelessWidget { + const _MiniMonth({ + required this.displayedMonth, + required this.selectedDate, + required this.locale, + required this.onPrevious, + required this.onNext, + required this.onSelected, + }); + + final DateTime displayedMonth; + final DateTime selectedDate; + final String locale; + final VoidCallback onPrevious; + final VoidCallback onNext; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final first = DateTime(displayedMonth.year, displayedMonth.month); + final gridStart = DateTime( + first.year, + first.month, + first.day - (first.weekday - DateTime.monday), + ); + final days = [ + for (var offset = 0; offset < 42; offset += 1) + DateTime(gridStart.year, gridStart.month, gridStart.day + offset), + ]; + final weekdayBase = DateTime(2026, 1, 5); + return Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + DateFormat.yMMMM(locale).format(displayedMonth), + style: FluentTheme.of(context).typography.bodyStrong, + ), + ), + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.previous)), + onPressed: onPrevious, + ), + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.next)), + onPressed: onNext, + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + for (var index = 0; index < 7; index += 1) + Expanded( + child: Center( + child: Text( + DateFormat.E( + locale, + ).format(weekdayBase.add(Duration(days: index))), + maxLines: 1, + style: FluentTheme.of(context).typography.caption, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + for (var week = 0; week < 6; week += 1) + Row( + children: [ + for (var weekday = 0; weekday < 7; weekday += 1) + Expanded( + child: _MiniDayButton( + day: days[week * 7 + weekday], + inMonth: + days[week * 7 + weekday].month == displayedMonth.month, + selected: _sameDay(days[week * 7 + weekday], selectedDate), + onSelected: onSelected, + ), + ), + ], + ), + ], + ); + } +} + +class _MiniDayButton extends StatelessWidget { + const _MiniDayButton({ + required this.day, + required this.inMonth, + required this.selected, + required this.onSelected, + }); + + final DateTime day; + final bool inMonth; + final bool selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final child = SizedBox( + height: 26, + child: Center(child: Text('${day.day}')), + ); + return Padding( + padding: const EdgeInsets.all(1), + child: Opacity( + opacity: inMonth || selected ? 1 : 0.55, + child: selected + ? FilledButton(onPressed: () => onSelected(day), child: child) + : Button(onPressed: () => onSelected(day), child: child), + ), + ); + } +} + +bool _sameDay(DateTime left, DateTime right) => + left.year == right.year && + left.month == right.month && + left.day == right.day; diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart new file mode 100644 index 00000000..534cb147 --- /dev/null +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -0,0 +1,966 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../l10n/app_locale.dart'; +import '../../platform/common/desktop_services.dart'; +import '../../providers/busy_provider.dart'; +import '../../webcal/webcal_subscription_service.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_account_removal_dialog.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_calendar_activation_flows.dart'; +import 'windows_diagnostics_dialog.dart'; +import 'windows_feedback_dialog.dart'; +import 'windows_keyboard_shortcuts_dialog.dart'; + +const _busyMaxRepositoryUrl = 'https://github.com/busystack/busymax/'; +const _apacheLicenseUrl = 'https://www.apache.org/licenses/LICENSE-2.0'; +const _systemLocaleTag = 'system'; + +class WindowsSettingsPage extends ConsumerWidget { + const WindowsSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final settings = ref.watch(appSettingsControllerProvider); + final controller = ref.read(appSettingsControllerProvider.notifier); + final autostart = ref.watch(launchAtLoginStateProvider); + final accounts = ref.watch(accountManagementStreamProvider); + final subscriptions = ref.watch(webCalSubscriptionsProvider); + final config = ref.watch(buildConfigProvider); + return ScaffoldPage.scrollable( + header: PageHeader(title: Text(l10n.settings)), + children: [ + _SectionTitle(l10n.appearance), + Card( + child: _SettingsRow( + icon: BusyMaxGlyph.settings, + title: l10n.theme, + child: ComboBox( + value: settings.themeModePreference, + items: [ + ComboBoxItem( + value: BusyMaxThemeModePreference.system, + child: Text(l10n.themeSystem), + ), + ComboBoxItem( + value: BusyMaxThemeModePreference.light, + child: Text(l10n.themeLight), + ), + ComboBoxItem( + value: BusyMaxThemeModePreference.dark, + child: Text(l10n.themeDark), + ), + ], + onChanged: (value) { + if (value != null) controller.setThemeModePreference(value); + }, + ), + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.scheduleDisplaySettings), + Card( + child: Column( + children: [ + _SettingsRow( + icon: BusyMaxGlyph.calendar, + title: l10n.scheduleDayStartsAt, + child: TimePicker( + selected: _minuteDateTime(settings.scheduleDayStartMinute), + onChanged: (value) => controller.setScheduleDayStartMinute( + value.hour * 60 + value.minute, + ), + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.calendar, + title: l10n.scheduleDayEndsAt, + child: TimePicker( + selected: _minuteDateTime(settings.scheduleDayEndMinute), + onChanged: (value) => controller.setScheduleDayEndMinute( + value.hour == 0 && value.minute == 0 + ? 24 * 60 + : value.hour * 60 + value.minute, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.settingsSystem), + Card( + child: Column( + children: [ + _SettingsRow( + icon: BusyMaxGlyph.settings, + title: l10n.currentLocale, + child: ComboBox( + value: settings.localeTag ?? _systemLocaleTag, + items: [ + ComboBoxItem( + value: _systemLocaleTag, + child: Text(l10n.themeSystem), + ), + for (final option in busyMaxLocaleOptions) + ComboBoxItem( + value: option.tag, + child: Text(option.endonym), + ), + ], + onChanged: (value) { + if (value != null) { + controller.setLocaleTag( + value == _systemLocaleTag ? null : value, + ); + } + }, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.more, + title: l10n.runInBackgroundWhenClosed, + child: ToggleSwitch( + checked: settings.runInBackgroundWhenClosed, + onChanged: controller.setRunInBackgroundWhenClosed, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.information, + title: l10n.showTrayIcon, + child: ToggleSwitch( + checked: settings.showTrayIcon, + onChanged: controller.setShowTrayIcon, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.signOut, + title: l10n.startMinimizedToTray, + child: ToggleSwitch( + checked: settings.startMinimizedToTray, + onChanged: controller.setStartMinimizedToTray, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.sync, + title: l10n.launchAtLogin, + subtitle: _autostartDescription(l10n, autostart), + child: autostart.when( + loading: () => const ProgressRing(), + error: (_, _) => + Icon(windowsBusyMaxGlyph(BusyMaxGlyph.warning)), + data: (state) => ToggleSwitch( + checked: state == DesktopAutostartState.enabled, + onChanged: + state == DesktopAutostartState.disabledByUser || + state == DesktopAutostartState.disabledByPolicy || + state == DesktopAutostartState.unavailable + ? null + : (enabled) => unawaited( + _setWindowsAutostart(context, ref, enabled), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + InfoBar( + title: Text(l10n.windowsReminderExitNotice), + severity: InfoBarSeverity.info, + ), + const SizedBox(height: 20), + _SectionTitle(l10n.notifications), + Card( + child: Column( + children: [ + _SettingsRow( + icon: BusyMaxGlyph.reminder, + title: l10n.eventReminders, + child: ToggleSwitch( + checked: settings.notifyEventReminders, + onChanged: controller.setNotifyEventReminders, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.task, + title: l10n.taskReminders, + child: ToggleSwitch( + checked: settings.notifyTaskReminders, + onChanged: controller.setNotifyTaskReminders, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.today, + title: l10n.notifyDueToday, + child: ToggleSwitch( + checked: settings.notifyDueToday, + onChanged: controller.setNotifyDueToday, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.sync, + title: l10n.notifySyncFailures, + child: ToggleSwitch( + checked: settings.notifySyncFailures, + onChanged: controller.setNotifySyncFailures, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.warning, + title: l10n.notifyConflicts, + child: ToggleSwitch( + checked: settings.notifyConflicts, + onChanged: controller.setNotifyConflicts, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.privacy, + title: l10n.notificationDetailLevel, + child: ComboBox( + value: settings.notificationDetailLevel, + items: [ + ComboBoxItem( + value: NotificationDetailLevel.private, + child: Text(l10n.notificationDetailPrivate), + ), + ComboBoxItem( + value: NotificationDetailLevel.normal, + child: Text(l10n.notificationDetailNormal), + ), + ], + onChanged: (value) { + if (value != null) { + controller.setNotificationDetailLevel(value); + } + }, + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.reminder, + title: l10n.quietHours, + subtitle: l10n.quietHoursDescription, + child: ToggleSwitch( + checked: settings.quietHoursEnabled, + onChanged: controller.setQuietHoursEnabled, + ), + ), + if (settings.quietHoursEnabled) ...[ + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.previous, + title: l10n.quietHoursStart, + child: TimePicker( + selected: _timeStringDateTime(settings.quietHoursStart), + onChanged: (value) => + controller.setQuietHoursStart(_timeString(value)), + ), + ), + const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.next, + title: l10n.quietHoursEnd, + child: TimePicker( + selected: _timeStringDateTime(settings.quietHoursEnd), + onChanged: (value) => + controller.setQuietHoursEnd(_timeString(value)), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.privacy), + Card( + child: _SettingsRow( + icon: BusyMaxGlyph.privacy, + title: l10n.redactTaskContentInDiagnostics, + child: ToggleSwitch( + checked: settings.redactTaskContentInDiagnostics, + onChanged: controller.setRedactTaskContentInDiagnostics, + ), + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.accounts), + Card( + child: accounts.when( + loading: () => const Padding( + padding: EdgeInsets.all(20), + child: Center(child: ProgressRing()), + ), + error: (_, _) => InfoBar( + title: Text(l10n.operationFailed), + severity: InfoBarSeverity.error, + ), + data: (values) => Column( + children: [ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + title: Text(l10n.connectAccountAction), + onPressed: () => context.go('/sign-in?add=true'), + ), + if (values.isNotEmpty) const Divider(), + for (var index = 0; index < values.length; index++) ...[ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.account)), + title: Text( + values[index].displayName ?? + values[index].email ?? + values[index].id, + ), + subtitle: Text(values[index].provider.storageValue), + trailing: DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + items: [ + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + text: Text(l10n.removeAccount), + onPressed: () => unawaited( + _removeWindowsAccount(context, ref, values[index]), + ), + ), + ], + ), + ), + if (index != values.length - 1) const Divider(), + ], + ], + ), + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.calendarImport), + Card( + child: Column( + children: [ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + title: Text(l10n.importIcsFile), + subtitle: Text(l10n.calendarImportDescription), + onPressed: () => showWindowsIcsImportFlow(context, ref), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + title: Text(l10n.addCalendarSubscription), + subtitle: Text(l10n.calendarSubscriptionsDescription), + onPressed: () => + showWindowsWebCalSubscriptionFlow(context, ref), + ), + ...subscriptions.maybeWhen( + data: (values) => [ + for (final subscription in values) ...[ + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + title: Text(subscription.name), + subtitle: Text(subscription.safeOrigin), + trailing: DropDownButton( + title: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + items: [ + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.refresh), + ), + text: Text(l10n.refresh), + onPressed: () => unawaited( + _refreshWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.edit), + ), + text: Text(l10n.rename), + onPressed: () => unawaited( + _renameWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.settings), + ), + text: Text(l10n.calendarColor), + onPressed: () => unawaited( + _colorWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + MenuFlyoutItem( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + text: Text(l10n.unsubscribe), + onPressed: () => unawaited( + _unsubscribeWindowsSubscription( + context, + ref, + subscription, + ), + ), + ), + ], + ), + ), + ], + ], + orElse: () => const [], + ), + ], + ), + ), + const SizedBox(height: 20), + _SectionTitle(l10n.aboutBusyMax), + Card( + child: Column( + children: [ + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.information)), + title: Text(l10n.aboutBusyMax), + subtitle: Text(l10n.aboutBusyMaxDescription), + onPressed: () => showWindowsAboutDialog( + context, + packageVersion: config.windowsPackageVersion, + privacyPolicyUrl: config.privacyPolicyUrl, + supportUrl: config.supportUrl, + homepageUrl: config.homepageUrl, + ), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.privacy)), + title: Text(l10n.privacy), + onPressed: _httpsUri(config.privacyPolicyUrl) == null + ? null + : () => launchUrl(_httpsUri(config.privacyPolicyUrl)!), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.feedback)), + title: Text(l10n.sendFeedback), + onPressed: () => showWindowsFeedbackDialog(context, ref), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.diagnostics)), + title: Text(l10n.diagnostics), + onPressed: () => showWindowsDiagnosticsDialog(context, ref), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + title: Text(l10n.keyboardShortcuts), + onPressed: () => showWindowsKeyboardShortcutsDialog(context), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.feedback)), + title: Text(l10n.windowsSupport), + onPressed: _httpsUri(config.supportUrl) == null + ? null + : () => launchUrl(_httpsUri(config.supportUrl)!), + ), + const Divider(), + ListTile( + leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.information)), + title: Text(l10n.website), + onPressed: _httpsUri(config.homepageUrl) == null + ? null + : () => launchUrl(_httpsUri(config.homepageUrl)!), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ); + } +} + +Future _setWindowsAutostart( + BuildContext context, + WidgetRef ref, + bool enabled, +) async { + try { + await ref.read(desktopAutostartServiceProvider).setEnabled(enabled); + ref.invalidate(launchAtLoginStateProvider); + } on Object { + ref.invalidate(launchAtLoginStateProvider); + if (context.mounted) { + await _showWindowsMessage( + context, + AppLocalizations.of(context).launchAtLoginFailed, + ); + } + } +} + +Future _removeWindowsAccount( + BuildContext context, + WidgetRef ref, + AccountEntity account, +) async { + final l10n = AppLocalizations.of(context); + final options = await showWindowsAccountRemovalDialog( + context, + accountLabel: account.displayLabel, + canRevokeGoogleAuthorization: + account.provider == BusyProvider.google && account.isSignedIn, + ); + if (options == null || !context.mounted) return; + try { + final isDav = + account.provider == BusyProvider.appleICloud || + account.provider == BusyProvider.nextcloud; + final authResult = isDav + ? null + : await ref + .read(authRepositoryProvider) + .removeAccount( + accountId: account.id, + revokeAuthorization: options.revokeGoogleAuthorization, + ); + final davResult = isDav + ? await ref + .read(davAccountOnboardingServiceProvider) + .removeAccount(account.id) + : null; + final remaining = await ref + .read(accountsRepositoryProvider) + .listVisibleAccounts(); + ref.read(selectedAccountIdProvider.notifier).state = + remaining.firstOrNull?.id; + await ref.read(authSessionControllerProvider.notifier).load(); + if (!context.mounted) return; + if (authResult?.authorizationRevocationFailed ?? false) { + await _showWindowsMessage(context, l10n.accountRemovedGoogleRevokeFailed); + } else if (davResult?.remoteRevocationAttempted == true && + !davResult!.remoteRevocationSucceeded) { + await _showWindowsMessage( + context, + l10n.nextcloudAccountRemovedRevokeFailed, + ); + } + if (remaining.isEmpty && context.mounted) context.go('/sign-in'); + } on Object { + if (context.mounted) { + await _showWindowsMessage(context, l10n.removeAccountFailed); + } + } +} + +Future _refreshWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) => _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .refreshSubscription(subscription.id, force: true), +); + +Future _renameWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) async { + final l10n = AppLocalizations.of(context); + final value = await _showWindowsTextPrompt( + context, + title: l10n.rename, + label: l10n.subscriptionName, + initialValue: subscription.name, + actionLabel: l10n.rename, + ); + if (value == null || !context.mounted) return; + await _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .renameSubscription(subscription.id, value), + ); +} + +Future _colorWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) async { + final l10n = AppLocalizations.of(context); + final value = await _showWindowsTextPrompt( + context, + title: l10n.calendarColor, + label: l10n.subscriptionColor, + initialValue: subscription.color ?? '', + actionLabel: l10n.save, + message: l10n.subscriptionColorHelp, + ); + if (value == null || !context.mounted) return; + await _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .changeSubscriptionColor(subscription.id, value), + ); +} + +Future _unsubscribeWindowsSubscription( + BuildContext context, + WidgetRef ref, + WebCalSubscriptionEntity subscription, +) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.unsubscribeCalendarTitle(subscription.name)), + content: Text(l10n.unsubscribeCalendarConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.unsubscribe), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + await _runWindowsSubscriptionOperation( + context, + () => ref + .read(webCalSubscriptionServiceProvider) + .unsubscribe(subscription.id), + ); +} + +Future _runWindowsSubscriptionOperation( + BuildContext context, + Future Function() operation, +) async { + try { + await operation(); + } on Object { + if (context.mounted) { + final l10n = AppLocalizations.of(context); + await _showWindowsMessage( + context, + l10n.subscriptionOperationFailed(l10n.operationFailed), + ); + } + } +} + +Future _showWindowsTextPrompt( + BuildContext context, { + required String title, + required String label, + required String initialValue, + required String actionLabel, + String? message, +}) async { + final controller = TextEditingController(text: initialValue); + final value = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (message != null) ...[Text(message), const SizedBox(height: 12)], + InfoLabel( + label: label, + child: TextBox(controller: controller, autofocus: true), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, controller.text), + child: Text(actionLabel), + ), + ], + ), + ); + controller.dispose(); + return value; +} + +Future _showWindowsMessage(BuildContext context, String message) { + return showDialog( + context: context, + builder: (context) => ContentDialog( + content: Text(message), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); +} + +String _autostartDescription( + AppLocalizations l10n, + AsyncValue value, +) => value.maybeWhen( + data: (state) => switch (state) { + DesktopAutostartState.disabledByUser => l10n.windowsStartupDisabledByUser, + DesktopAutostartState.disabledByPolicy => + l10n.windowsStartupDisabledByPolicy, + DesktopAutostartState.unavailable => l10n.windowsStartupUnavailable, + _ => l10n.launchAtLoginDescription, + }, + orElse: () => l10n.launchAtLoginDescription, +); + +Uri? _httpsUri(String value) { + final uri = Uri.tryParse(value.trim()); + return uri?.scheme == 'https' && uri!.host.isNotEmpty ? uri : null; +} + +DateTime _minuteDateTime(int minute) => + DateTime(2000, 1, 1, (minute ~/ 60) % 24, minute % 60); + +DateTime _timeStringDateTime(String value) { + final parts = value.split(':'); + return DateTime( + 2000, + 1, + 1, + parts.isEmpty ? 0 : int.tryParse(parts[0]) ?? 0, + parts.length < 2 ? 0 : int.tryParse(parts[1]) ?? 0, + ); +} + +String _timeString(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}'; + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Text(text, style: FluentTheme.of(context).typography.subtitle), + ); +} + +class _SettingsRow extends StatelessWidget { + const _SettingsRow({ + required this.icon, + required this.title, + required this.child, + this.subtitle, + }); + + final BusyMaxGlyph icon; + final String title; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + final compact = constraints.maxWidth < 520 || textScale > 1.3; + final label = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(windowsBusyMaxGlyph(icon)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + if (subtitle != null) + Text( + subtitle!, + style: FluentTheme.of(context).typography.caption, + ), + ], + ), + ), + ], + ); + return Padding( + padding: const EdgeInsets.all(16), + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + label, + const SizedBox(height: 12), + Align( + alignment: AlignmentDirectional.centerEnd, + child: child, + ), + ], + ) + : Row( + children: [ + Expanded(child: label), + const SizedBox(width: 16), + child, + ], + ), + ); + }, + ); +} + +Future showWindowsAboutDialog( + BuildContext context, { + required String packageVersion, + required String privacyPolicyUrl, + required String supportUrl, + required String homepageUrl, +}) async { + final packageInfo = await PackageInfo.fromPlatform(); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.aboutBusyMax), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.aboutBusyMaxDescription), + const SizedBox(height: 12), + Text('${l10n.windowsProductVersionLabel}: ${packageInfo.version}'), + Text( + '${l10n.windowsPackageVersionLabel}: ' + '${packageVersion.isEmpty ? l10n.windowsUnpackaged : packageVersion}', + ), + const SizedBox(height: 12), + Text('Copyright © 2026 BusyStack'), + Text(l10n.apacheLicenseName), + const SizedBox(height: 8), + HyperlinkButton( + onPressed: () => + unawaited(launchUrl(Uri.parse(_apacheLicenseUrl))), + child: Text(l10n.license), + ), + HyperlinkButton( + onPressed: () => + unawaited(launchUrl(Uri.parse(_busyMaxRepositoryUrl))), + child: Text(l10n.sourceCode), + ), + if (_httpsUri(privacyPolicyUrl) case final privacyUri?) + HyperlinkButton( + onPressed: () => unawaited(launchUrl(privacyUri)), + child: Text(l10n.privacy), + ), + if (_httpsUri(supportUrl) case final supportUri?) + HyperlinkButton( + onPressed: () => unawaited(launchUrl(supportUri)), + child: Text(l10n.windowsSupport), + ), + if (_httpsUri(homepageUrl) case final homepageUri?) + HyperlinkButton( + onPressed: () => unawaited(launchUrl(homepageUri)), + child: Text(l10n.website), + ), + ], + ), + actions: [ + Button( + onPressed: () => unawaited(showWindowsLicensesDialog(context)), + child: Text(l10n.windowsThirdPartyLicenses), + ), + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + }, + ); +} + +Future showWindowsLicensesDialog(BuildContext context) async { + final entries = await LicenseRegistry.licenses.toList(); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).windowsThirdPartyLicenses), + constraints: const BoxConstraints(maxWidth: 720, maxHeight: 640), + content: ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + return Expander( + header: Text(entry.packages.join(', ')), + content: SelectableText( + entry.paragraphs.map((paragraph) => paragraph.text).join('\n\n'), + ), + ); + }, + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); +} diff --git a/lib/src/ui/windows/windows_sign_in_page.dart b/lib/src/ui/windows/windows_sign_in_page.dart new file mode 100644 index 00000000..5b611596 --- /dev/null +++ b/lib/src/ui/windows/windows_sign_in_page.dart @@ -0,0 +1,217 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../dav/dav_errors.dart'; +import '../../dav/http/dav_http_transport.dart'; +import '../../features/auth/data/auth_repository.dart'; +import '../../providers/busy_provider.dart'; +import 'windows_dav_account_dialogs.dart'; + +class WindowsSignInPage extends ConsumerStatefulWidget { + const WindowsSignInPage({super.key, this.addingAccount = false}); + + final bool addingAccount; + + @override + ConsumerState createState() => _WindowsSignInPageState(); +} + +class _WindowsSignInPageState extends ConsumerState { + BusyProvider? _connectingDavProvider; + DavCancellationToken? _davCancellation; + String? _davError; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final session = ref.watch(authSessionControllerProvider); + final config = ref.watch(buildConfigProvider); + if (session.isSignedIn && !widget.addingAccount) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) context.go('/schedule'); + }); + } + final busy = + session.status == AuthSessionStatus.signingIn || + _connectingDavProvider != null; + return NavigationView( + content: ScaffoldPage( + content: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Card( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + child: Image.asset( + 'assets/branding/busymax-logo.png', + width: 96, + height: 96, + semanticLabel: l10n.appTitle, + ), + ), + const SizedBox(height: 24), + Text( + l10n.onboardingSetupTitle, + textAlign: TextAlign.center, + style: FluentTheme.of(context).typography.title, + ), + const SizedBox(height: 8), + Text( + l10n.providerConnectionDescription, + textAlign: TextAlign.center, + ), + if (session.message != null || _davError != null) ...[ + const SizedBox(height: 16), + InfoBar( + title: Text(_davError ?? session.message!), + severity: InfoBarSeverity.error, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: busy || !config.hasGoogleOAuthClientId + ? null + : () => _signIn( + ref + .read(authSessionControllerProvider.notifier) + .signIn, + ), + child: Text(l10n.signInWithGoogle), + ), + const SizedBox(height: 8), + Button( + onPressed: busy || !config.hasMicrosoftOAuthClientId + ? null + : () => _signIn( + ref + .read(authSessionControllerProvider.notifier) + .signInWithMicrosoft, + ), + child: Text(l10n.signInWithMicrosoft), + ), + const SizedBox(height: 8), + Button( + onPressed: busy + ? null + : () => _connectDav(BusyProvider.appleICloud), + child: Text(l10n.addAppleICloudAccount), + ), + const SizedBox(height: 8), + Button( + onPressed: busy + ? null + : () => _connectDav(BusyProvider.nextcloud), + child: Text(l10n.addNextcloudAccount), + ), + if (busy) ...[ + const SizedBox(height: 20), + const Center(child: ProgressRing()), + const SizedBox(height: 12), + Button( + onPressed: _cancelConnection, + child: Text(l10n.cancel), + ), + ], + if (widget.addingAccount && !busy) ...[ + const SizedBox(height: 8), + Button( + onPressed: () => context.go('/settings'), + child: Text(l10n.cancel), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } + + Future _signIn(Future Function() action) async { + setState(() => _davError = null); + try { + await action(); + } finally { + if (mounted) { + try { + await ref.read(desktopWindowServiceProvider).showWindow(); + } on Object { + // Authentication state remains authoritative if foregrounding is + // temporarily denied by Windows. + } + } + } + if (!mounted) return; + if (ref.read(authSessionControllerProvider).isSignedIn) { + context.go(widget.addingAccount ? '/settings' : '/schedule'); + } + } + + Future _connectDav(BusyProvider provider) async { + WindowsAppleCredentialInput? apple; + String? server; + if (provider == BusyProvider.appleICloud) { + apple = await showWindowsAppleICloudDialog(context); + } else { + server = await showWindowsNextcloudServerDialog(context); + } + if (!mounted || + (provider == BusyProvider.appleICloud && apple == null) || + (provider == BusyProvider.nextcloud && server == null)) { + return; + } + setState(() { + _connectingDavProvider = provider; + _davError = null; + }); + final cancellation = DavCancellationToken(); + _davCancellation = cancellation; + try { + final onboarding = ref.read(davAccountOnboardingServiceProvider); + if (provider == BusyProvider.appleICloud) { + await onboarding.connectAppleICloud( + email: apple!.email, + appSpecificPassword: apple.password, + cancellationToken: cancellation, + ); + } else { + await onboarding.connectNextcloud( + enteredServer: server!, + cancellationToken: cancellation, + ); + } + await ref.read(authSessionControllerProvider.notifier).load(); + if (mounted) { + context.go(widget.addingAccount ? '/settings' : '/schedule'); + } + } on Object catch (error) { + if (error is DavException && error.kind == DavErrorKind.cancelled) return; + if (mounted) { + setState(() => _davError = authErrorMessage(error)); + } + } finally { + _davCancellation = null; + if (mounted) setState(() => _connectingDavProvider = null); + } + } + + Future _cancelConnection() async { + if (_connectingDavProvider != null) { + _davCancellation?.cancel(); + if (_connectingDavProvider == BusyProvider.nextcloud) { + ref.read(davAccountOnboardingServiceProvider).cancelNextcloudLogin(); + } + if (mounted) setState(() => _connectingDavProvider = null); + return; + } + await ref.read(authSessionControllerProvider.notifier).cancelSignIn(); + } +} diff --git a/lib/src/ui/windows/windows_task_create_fields.dart b/lib/src/ui/windows/windows_task_create_fields.dart new file mode 100644 index 00000000..938346ac --- /dev/null +++ b/lib/src/ui/windows/windows_task_create_fields.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import '../../dav/ical/ical_task_alarm.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../features/tasks/domain/task_capabilities.dart'; +import '../../providers/busy_provider.dart'; + +Map buildWindowsTaskCreateFields({ + required TaskCollectionCapabilities capability, + required BusyProvider provider, + required DateTime? due, + required DateTime? start, + required DateTime? reminder, + required List alarms, + required bool scheduledAllDay, + required String timeZone, + required RecurrenceRule recurrence, + required String importance, + required String status, + required int priority, + required int progress, + required String location, + required String taskUrl, + required String classification, + required bool pinned, + required bool hideSubtasks, + required bool hideCompletedSubtasks, +}) { + final fields = {}; + if (capability.supportsDueTime && due != null && !scheduledAllDay) { + fields + ..['microsoftDueDateTime'] = _providerDateTime(due, timeZone) + ..['microsoftDueTimeZone'] = timeZone; + } + if (capability.supportsStartDateTime && start != null && !scheduledAllDay) { + fields + ..['microsoftStartDateTime'] = _providerDateTime(start, timeZone) + ..['microsoftStartTimeZone'] = timeZone; + } + if (capability.supportsReminderDateTime && + !capability.supportsMultipleReminders) { + fields['microsoftIsReminderOn'] = reminder != null; + if (reminder != null) { + fields + ..['microsoftReminderDateTime'] = _providerDateTime(reminder, timeZone) + ..['microsoftReminderTimeZone'] = timeZone; + } + } + if (capability.supportsMultipleReminders && alarms.isNotEmpty) { + fields['taskAlarms'] = [for (final alarm in alarms) alarm.toJson()]; + } + if (capability.supportsRecurrence && recurrence.repeats) { + fields['recurrence'] = provider == BusyProvider.nextcloud + ? jsonDecode(recurrence.toJsonString()) + : _microsoftTaskRecurrence(recurrence.frequency, start ?? due!); + } + if (capability.supportsImportance && !capability.supportsIcalPriority) { + fields['importance'] = importance; + } + if (capability.supportsTaskStatus && status.isNotEmpty) { + fields['taskStatus'] = status; + } + if (capability.supportsIcalPriority) fields['icalPriority'] = priority; + if (capability.supportsPercentComplete) fields['percentComplete'] = progress; + if (capability.supportsLocation) fields['location'] = location.trim(); + if (capability.supportsUrl) fields['taskUrl'] = taskUrl.trim(); + if (capability.supportsClassification) { + fields['taskClassification'] = classification; + } + if (capability.supportsPinning) fields['taskPinned'] = pinned; + if (capability.supportsSubtaskVisibility) { + fields + ..['taskHideSubtasks'] = hideSubtasks + ..['taskHideCompletedSubtasks'] = hideCompletedSubtasks; + } + return fields; +} + +Map _providerDateTime(DateTime value, String timeZone) => { + 'dateTime': value.toIso8601String(), + 'timeZone': timeZone, +}; + +Map _microsoftTaskRecurrence( + RecurrenceFrequency frequency, + DateTime baseDate, +) => { + 'pattern': switch (frequency) { + RecurrenceFrequency.daily => {'type': 'daily', 'interval': 1}, + RecurrenceFrequency.weekly => { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': [_microsoftWeekday(baseDate.weekday)], + 'firstDayOfWeek': 'monday', + }, + RecurrenceFrequency.monthly => { + 'type': 'absoluteMonthly', + 'interval': 1, + 'dayOfMonth': baseDate.day, + }, + RecurrenceFrequency.yearly => { + 'type': 'absoluteYearly', + 'interval': 1, + 'dayOfMonth': baseDate.day, + 'month': baseDate.month, + }, + RecurrenceFrequency.none => {'type': 'daily', 'interval': 1}, + }, + 'range': {'type': 'noEnd', 'startDate': _dateOnly(baseDate)}, +}; + +String _microsoftWeekday(int weekday) => switch (weekday) { + DateTime.monday => 'monday', + DateTime.tuesday => 'tuesday', + DateTime.wednesday => 'wednesday', + DateTime.thursday => 'thursday', + DateTime.friday => 'friday', + DateTime.saturday => 'saturday', + _ => 'sunday', +}; + +String _dateOnly(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; diff --git a/lib/src/ui/windows/windows_task_details_dialog.dart b/lib/src/ui/windows/windows_task_details_dialog.dart new file mode 100644 index 00000000..b4f0d70f --- /dev/null +++ b/lib/src/ui/windows/windows_task_details_dialog.dart @@ -0,0 +1,1583 @@ +import 'dart:convert'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../dav/ical/ical_task_alarm.dart'; +import '../../features/tasks/domain/task_capabilities.dart'; +import '../../features/tasks/data/tasks_repository.dart'; +import '../../features/tasks/presentation/task_details_draft.dart'; +import '../../features/schedule/presentation/schedule_item_exporter.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/schedule_item.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_time_zone_dialog.dart'; + +Future showWindowsTaskDetailsDialog( + BuildContext context, + WidgetRef ref, + TaskScheduleItem task, +) async { + final repository = ref.read( + tasksRepositoryForAccountProvider(task.accountId), + ); + final original = await repository.watchTask(task.sourceId, task.id).first; + final accounts = await ref + .read(accountsRepositoryProvider) + .watchAccounts() + .first; + final account = accounts + .where((value) => value.id == task.accountId) + .firstOrNull; + if (original == null || account == null || !context.mounted) return false; + var capabilities = switch (account.provider) { + BusyProvider.nextcloud => + await ref.read( + davTaskCollectionCapabilitiesProvider(( + accountId: task.accountId, + taskListId: task.sourceId, + )).future, + ) ?? + noTaskCollectionCapabilities, + BusyProvider.google || + BusyProvider.microsoft => adapterDefaultTaskCapabilities(account.provider), + BusyProvider.appleICloud || + BusyProvider.webCal => noTaskCollectionCapabilities, + }; + if (original.recurrenceIdKey != null && + !capabilities.supportsRecurringTaskOccurrenceEditing) { + capabilities = capabilities.asReadOnly(); + } + final taskLists = await ref + .read(taskListsRepositoryForAccountProvider(task.accountId)) + .listTaskLists(); + var hierarchy = capabilities.supportsTaskHierarchy + ? await repository.watchTaskHierarchy(task.sourceId, task.id).first + : const TaskHierarchySnapshot(parent: null, subtasks: []); + if (!context.mounted) return false; + final localTimeZone = ref.read(localTimeZoneProvider); + final originalDraft = TaskDetailsDraft.fromTask(original, localTimeZone); + final title = TextEditingController(text: original.title); + final notes = TextEditingController(text: original.notes); + final categories = TextEditingController( + text: originalDraft.categories.join(', '), + ); + final location = TextEditingController(text: originalDraft.location); + final taskUrl = TextEditingController(text: originalDraft.taskUrl); + final subtaskTitle = TextEditingController(); + var completed = task.completed; + var selectedTaskListId = original.taskListId; + var due = _taskDueDateTime(originalDraft); + var dueHasTime = originalDraft.microsoftDueTime != null; + var dueTimeZone = originalDraft.microsoftDueTimeZone ?? localTimeZone; + var start = _taskDateTime( + originalDraft.microsoftStartDate, + originalDraft.microsoftStartTime, + ); + var startHasTime = originalDraft.microsoftStartTime != null; + var startTimeZone = originalDraft.microsoftStartTimeZone ?? localTimeZone; + var reminder = _taskDateTime( + originalDraft.microsoftReminderDate, + originalDraft.microsoftReminderTime, + ); + var reminderEnabled = originalDraft.microsoftReminderEnabled; + var reminderTimeZone = + originalDraft.microsoftReminderTimeZone ?? localTimeZone; + var recurrenceType = _recurrenceType(originalDraft.recurrenceJson); + var recurrenceJson = originalDraft.recurrenceJson; + var importance = originalDraft.importance; + var priority = originalDraft.icalPriority; + var taskStatus = originalDraft.taskStatus ?? ''; + var progress = originalDraft.percentComplete; + var completedAt = _taskDateTime( + originalDraft.completedDate, + originalDraft.completedTime, + ); + var classification = originalDraft.classification; + var pinned = originalDraft.pinned; + var hideSubtasks = originalDraft.hideSubtasks; + var hideCompletedSubtasks = originalDraft.hideCompletedSubtasks; + var alarms = [...originalDraft.alarms]; + var busy = false; + var changed = false; + var allowPop = false; + TaskScheduleItem? nextTask; + String? error; + String? message; + TaskDetailsDraft currentDraft() => originalDraft.copyWith( + taskListId: selectedTaskListId, + title: title.text.trim(), + notes: notes.text, + dueDate: due == null ? null : _dateOnly(due!), + microsoftDueTime: + due == null || !capabilities.supportsDueTime || !dueHasTime + ? null + : _timeOnly(due!), + microsoftDueTimeZone: dueTimeZone, + microsoftStartDate: start == null ? null : _dateOnly(start!), + microsoftStartTime: start == null || !startHasTime + ? null + : _timeOnly(start!), + microsoftStartTimeZone: startTimeZone, + microsoftReminderEnabled: reminderEnabled, + microsoftReminderDate: !reminderEnabled || reminder == null + ? null + : _dateOnly(reminder!), + microsoftReminderTime: !reminderEnabled || reminder == null + ? null + : _timeOnly(reminder!), + microsoftReminderTimeZone: reminderTimeZone, + recurrenceJson: recurrenceJson, + importance: importance, + categories: _categories(categories.text), + icalPriority: priority, + percentComplete: progress, + taskStatus: taskStatus.isEmpty ? null : taskStatus, + completedDate: completedAt == null ? null : _dateOnly(completedAt!), + completedTime: completedAt == null ? null : _timeOnly(completedAt!), + location: location.text, + taskUrl: taskUrl.text.trim(), + classification: classification, + pinned: pinned, + hideSubtasks: hideSubtasks, + hideCompletedSubtasks: hideCompletedSubtasks, + alarms: alarms, + ); + bool hasPendingEdits() => + !currentDraft().hasSameValues(originalDraft) || + (!capabilities.supportsTaskStatus && completed != task.completed); + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final enabled = !busy && capabilities.canUpdateTasks; + final provisionalDraft = currentDraft(); + final validUrl = + taskUrl.text.trim().isEmpty || + (Uri.tryParse(taskUrl.text.trim())?.hasScheme ?? false); + void closeDialog() { + setState(() => allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (dialogContext.mounted) Navigator.pop(dialogContext); + }); + } + + return PopScope( + canPop: allowPop || (!busy && !hasPendingEdits()), + onPopInvokedWithResult: (didPop, _) async { + if (didPop || busy || allowPop) return; + if (await _confirmDiscardTaskChanges(context) && context.mounted) { + closeDialog(); + } + }, + child: ContentDialog( + title: Text(l10n.editTask), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.title, + child: TextBox( + controller: title, + enabled: enabled, + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + Checkbox( + checked: completed, + onChanged: busy || !capabilities.canUpdateTasks + ? null + : (value) => setState(() { + completed = value ?? false; + if (capabilities.supportsTaskStatus) { + taskStatus = completed + ? 'COMPLETED' + : 'NEEDS-ACTION'; + progress = completed ? 100 : 0; + completedAt = completed ? DateTime.now() : null; + } + }), + content: Text(l10n.completed), + ), + if (capabilities.supportsDueDate) ...[ + const SizedBox(height: 12), + _TaskDateTimeField( + label: l10n.dueDate, + value: due, + showTime: capabilities.supportsDueTime && dueHasTime, + timeZone: capabilities.supportsDueTime && dueHasTime + ? dueTimeZone + : null, + enabled: enabled, + onChanged: (value) => setState(() => due = value), + onChooseTimeZone: () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: dueTimeZone, + ); + if (value != null) setState(() => dueTimeZone = value); + }, + ), + if (due != null && capabilities.supportsDueTime) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: !dueHasTime, + onChanged: enabled + ? (value) => setState(() => dueHasTime = !value) + : null, + content: Text(l10n.allDay), + ), + ], + ], + if (taskLists.isNotEmpty) ...[ + const SizedBox(height: 12), + _TaskListCombo( + taskLists: taskLists, + selectedTaskListId: selectedTaskListId, + enabled: enabled && capabilities.supportsCrossListMove, + onChanged: (value) => + setState(() => selectedTaskListId = value), + ), + ], + if (capabilities.supportsStartDateTime) ...[ + const SizedBox(height: 12), + _TaskDateTimeField( + label: l10n.startDateTime, + value: start, + showTime: startHasTime, + timeZone: startHasTime ? startTimeZone : null, + enabled: enabled, + onChanged: (value) => setState(() => start = value), + onChooseTimeZone: () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: startTimeZone, + ); + if (value != null) { + setState(() => startTimeZone = value); + } + }, + ), + if (start != null) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: !startHasTime, + onChanged: enabled + ? (value) => setState(() => startHasTime = !value) + : null, + content: Text(l10n.allDay), + ), + ], + ], + if (capabilities.supportsReminderDateTime && + !capabilities.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: reminderEnabled, + onChanged: enabled + ? (value) => setState(() { + reminderEnabled = value; + reminder ??= (due ?? start ?? DateTime.now()) + .subtract(const Duration(minutes: 15)); + }) + : null, + content: Text(l10n.reminder), + ), + if (reminderEnabled) ...[ + const SizedBox(height: 8), + _TaskDateTimeField( + label: l10n.reminderTime, + value: reminder, + showTime: true, + timeZone: reminderTimeZone, + enabled: enabled, + onChanged: (value) => setState(() { + reminder = value; + if (value == null) reminderEnabled = false; + }), + onChooseTimeZone: () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: reminderTimeZone, + ); + if (value != null) { + setState(() => reminderTimeZone = value); + } + }, + ), + ], + ], + if (capabilities.supportsRecurrence && + (due != null || start != null)) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.repeat, + value: recurrenceType, + values: { + 'none': l10n.repeatNone, + 'daily': l10n.repeatDaily, + 'weekly': l10n.repeatWeekly, + 'absoluteMonthly': l10n.repeatMonthly, + 'absoluteYearly': l10n.repeatYearly, + if (recurrenceType == 'unsupported') + 'unsupported': l10n.unsupportedRecurrencePreserved, + }, + enabled: enabled && recurrenceType != 'unsupported', + onChanged: (value) => setState(() { + recurrenceType = value; + recurrenceJson = _recurrenceJsonFor( + value, + due: due, + start: start, + dav: account.provider == BusyProvider.nextcloud, + ); + }), + ), + ], + if (capabilities.supportsImportance && + !capabilities.supportsIcalPriority) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.importance, + value: importance, + values: { + 'low': l10n.importanceLow, + 'normal': l10n.importanceNormal, + 'high': l10n.importanceHigh, + }, + enabled: enabled, + onChanged: (value) => setState(() => importance = value), + ), + ], + if (capabilities.supportsCategories) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.categories, + child: TextBox(controller: categories, enabled: enabled), + ), + ], + if (capabilities.supportsTaskStatus) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.taskStatus, + value: taskStatus, + values: { + '': l10n.taskStatusNone, + 'NEEDS-ACTION': l10n.taskStatusNeedsAction, + 'IN-PROCESS': l10n.taskStatusInProcess, + 'COMPLETED': l10n.taskStatusCompleted, + 'CANCELLED': l10n.taskStatusCancelled, + }, + enabled: enabled, + onChanged: (value) => setState(() { + taskStatus = value; + if (value == 'COMPLETED') { + progress = 100; + completed = true; + completedAt ??= DateTime.now(); + } else if (value == 'IN-PROCESS') { + if (progress == 0 || progress == 100) progress = 1; + completed = false; + completedAt = null; + } else if (value == 'NEEDS-ACTION' || value.isEmpty) { + if (progress == 100) progress = 0; + completed = false; + completedAt = null; + } + }), + ), + ], + if (capabilities.supportsPercentComplete) ...[ + const SizedBox(height: 12), + Text(l10n.completionPercent(progress)), + Slider( + value: progress.toDouble(), + max: 100, + divisions: 100, + onChanged: enabled + ? (value) => setState(() { + progress = value.round(); + if (progress == 100) { + taskStatus = 'COMPLETED'; + completed = true; + completedAt ??= DateTime.now(); + } else if (progress == 0) { + taskStatus = 'NEEDS-ACTION'; + completed = false; + completedAt = null; + } else { + taskStatus = 'IN-PROCESS'; + completed = false; + completedAt = null; + } + }) + : null, + ), + ], + if (capabilities.supportsIcalPriority) ...[ + const SizedBox(height: 12), + Text(_priorityLabel(l10n, priority)), + Slider( + value: priority.toDouble(), + max: 9, + divisions: 9, + onChanged: enabled + ? (value) => setState(() => priority = value.round()) + : null, + ), + ], + if (capabilities.supportsLocation) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.location, + child: TextBox(controller: location, enabled: enabled), + ), + ], + if (capabilities.supportsUrl) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.taskUrl, + child: TextBox( + controller: taskUrl, + enabled: enabled, + onChanged: (_) => setState(() {}), + ), + ), + if (!validUrl) + InfoBar( + title: Text(l10n.invalidTaskUrl), + severity: InfoBarSeverity.warning, + ), + ], + if (capabilities.supportsClassification) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.classification, + value: classification, + values: { + 'PUBLIC': l10n.classificationPublic, + 'CONFIDENTIAL': l10n.classificationConfidential, + 'PRIVATE': l10n.classificationPrivate, + }, + enabled: enabled && capabilities.canUpdateClassification, + onChanged: (value) => + setState(() => classification = value), + ), + ], + if (capabilities.supportsPinning) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: pinned, + onChanged: enabled + ? (value) => setState(() => pinned = value) + : null, + content: Text(l10n.pinTask), + ), + ], + if (capabilities.supportsSubtaskVisibility) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: hideSubtasks, + onChanged: enabled + ? (value) => setState(() => hideSubtasks = value) + : null, + content: Text(l10n.hideSubtasks), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: hideCompletedSubtasks, + onChanged: enabled + ? (value) => + setState(() => hideCompletedSubtasks = value) + : null, + content: Text(l10n.hideClosedSubtasks), + ), + ], + if (provisionalDraft.scheduleIssue != + TaskScheduleIssue.none) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text( + provisionalDraft.scheduleIssue == + TaskScheduleIssue.dueBeforeStart + ? l10n.taskDueBeforeStart + : l10n.taskStartDueTimeModeMismatch, + ), + severity: InfoBarSeverity.warning, + ), + ], + const SizedBox(height: 12), + InfoLabel( + label: l10n.notes, + child: TextBox( + controller: notes, + enabled: !busy && capabilities.canUpdateTasks, + maxLines: 5, + ), + ), + if (capabilities.supportsTaskHierarchy) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.subtasks, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hierarchy.parent case final parent?) + ListTile( + leading: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.previous), + ), + title: Text(parent.title), + subtitle: Text(l10n.parent), + onPressed: busy + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardTaskChanges( + context, + )) { + return; + } + nextTask = _scheduleItemForTask( + parent, + account.provider, + ); + closeDialog(); + }, + ), + for (final subtask in hierarchy.subtasks) + ListTile( + leading: Checkbox( + checked: subtask.completed, + onChanged: enabled + ? (value) async { + setState(() => busy = true); + try { + if (subtask.kind == + TaskSubtaskKind.task) { + final child = subtask.task!; + await repository.patchTask( + child.taskListId, + child.id, + TaskPatchInput({ + 'status': value == true + ? 'completed' + : 'needsAction', + 'completed': value == true + ? DateTime.now() + .toUtc() + .toIso8601String() + : null, + }), + ); + } else { + await repository + .patchChecklistSubtask( + taskListId: + original.taskListId, + parentTaskId: original.id, + checklistItemId: subtask.id, + completed: value ?? false, + ); + } + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + } + : null, + ), + title: Text(subtask.title), + onPressed: + busy || subtask.kind != TaskSubtaskKind.task + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardTaskChanges( + context, + )) { + return; + } + nextTask = _scheduleItemForTask( + subtask.task!, + account.provider, + ); + closeDialog(); + }, + trailing: + subtask.kind == TaskSubtaskKind.checklistItem + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + windowsBusyMaxGlyph( + BusyMaxGlyph.edit, + ), + ), + onPressed: enabled + ? () async { + final value = + await _showTextInputDialog( + context, + title: l10n.rename, + initialValue: + subtask.title, + ); + if (value == null || + value == subtask.title) { + return; + } + setState(() => busy = true); + try { + await repository + .patchChecklistSubtask( + taskListId: original + .taskListId, + parentTaskId: + original.id, + checklistItemId: + subtask.id, + title: value, + ); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = + l10n.operationFailed; + }); + } + } + : null, + ), + IconButton( + icon: Icon( + windowsBusyMaxGlyph( + BusyMaxGlyph.delete, + ), + ), + onPressed: enabled + ? () async { + setState(() => busy = true); + try { + await repository + .deleteChecklistSubtask( + taskListId: original + .taskListId, + parentTaskId: + original.id, + checklistItemId: + subtask.id, + ); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = + l10n.operationFailed; + }); + } + } + : null, + ), + ], + ) + : null, + ), + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextBox( + controller: subtaskTitle, + enabled: !busy && capabilities.canCreateTasks, + placeholder: l10n.createSubtask, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + Button( + onPressed: + busy || + !capabilities.canCreateTasks || + subtaskTitle.text.trim().isEmpty + ? null + : () async { + setState(() => busy = true); + try { + await repository.createSubtask( + taskListId: original.taskListId, + parentTaskId: original.id, + title: subtaskTitle.text, + ); + subtaskTitle.clear(); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + }, + child: Text(l10n.createSubtask), + ), + ], + ), + ], + ), + ), + ], + if (capabilities.supportsTaskReparenting || + capabilities.supportsDuplicate || + capabilities.supportsNativeExport) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (capabilities.supportsTaskReparenting) + Button( + onPressed: enabled + ? () async { + setState(() => busy = true); + try { + await repository.moveTask( + TaskMoveInput( + sourceTaskListId: original.taskListId, + taskId: original.id, + ), + ); + hierarchy = await repository + .watchTaskHierarchy( + original.taskListId, + original.id, + ) + .first; + setState(() { + busy = false; + changed = true; + error = null; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + } + : null, + child: Text(l10n.moveToTop), + ), + if (capabilities.supportsDuplicate) + Button( + onPressed: !busy && capabilities.canCreateTasks + ? () async { + setState(() => busy = true); + try { + await repository.duplicateTask( + original.taskListId, + original.id, + ); + setState(() { + busy = false; + changed = true; + error = null; + message = l10n.taskDuplicated; + }); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + } + : null, + child: Text(l10n.duplicateTask), + ), + if (capabilities.supportsNativeExport) + Button( + onPressed: busy + ? null + : () async { + try { + final raw = await repository + .nativeTaskExport( + original.taskListId, + original.id, + ); + if (raw == null) { + throw StateError( + 'Native iCalendar data unavailable.', + ); + } + final file = + await exportICalendarWithSaveDialog( + suggestedName: taskExportFileName( + title: original.title, + dueDate: original.dueUtc, + ), + calendarData: raw, + ); + if (file != null) { + setState(() { + error = null; + message = l10n.exportedFile( + file.path, + ); + }); + } + } on Object catch (_) { + setState(() { + error = l10n.operationFailed; + }); + } + }, + child: Text(l10n.export), + ), + ], + ), + ], + if (message != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(message!), + severity: InfoBarSeverity.success, + ), + ], + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(error!), + severity: InfoBarSeverity.error, + ), + ], + if (capabilities.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.reminder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < alarms.length; index += 1) + ListTile( + title: Text(_alarmLabel(context, alarms[index])), + subtitle: alarms[index].absoluteUtc == null + ? Text(l10n.unsupportedReminder) + : null, + onPressed: + !enabled || alarms[index].absoluteUtc == null + ? null + : () async { + final value = + await _showAbsoluteReminderDialog( + context, + initial: alarms[index].absoluteUtc! + .toLocal(), + ); + if (value != null) { + setState(() { + alarms[index] = alarms[index] + .withAbsoluteTrigger( + value.toUtc(), + ); + }); + } + }, + trailing: IconButton( + icon: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + onPressed: enabled + ? () => + setState(() => alarms.removeAt(index)) + : null, + ), + ), + Button( + onPressed: enabled + ? () async { + final value = + await _showAbsoluteReminderDialog( + context, + initial: + (due ?? start ?? DateTime.now()) + .subtract( + const Duration(minutes: 15), + ), + ); + if (value != null) { + setState(() { + alarms = [ + ...alarms, + IcalTaskAlarm.displayAbsolute( + value.toUtc(), + ), + ]; + }); + } + } + : null, + child: Text(l10n.addReminder), + ), + ], + ), + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: busy || !capabilities.canDeleteTasks + ? null + : () async { + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.deleteTask), + content: Text( + l10n.deleteTaskConfirmation(task.title), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.delete), + ), + ], + ), + ); + if (!(confirmed ?? false)) return; + setState(() => busy = true); + try { + await repository.deleteTask(task.sourceId, task.id); + changed = true; + closeDialog(); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + }, + child: Text(l10n.delete), + ), + Button( + onPressed: busy + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardTaskChanges(context)) { + return; + } + closeDialog(); + }, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: + busy || + !capabilities.canUpdateTasks || + title.text.trim().isEmpty || + !validUrl || + provisionalDraft.scheduleIssue != TaskScheduleIssue.none + ? null + : () async { + setState(() => busy = true); + try { + final updatedDraft = currentDraft(); + if (updatedDraft.scheduleIssue != + TaskScheduleIssue.none) { + setState(() { + busy = false; + error = switch (updatedDraft.scheduleIssue) { + TaskScheduleIssue.dueBeforeStart => + l10n.taskDueBeforeStart, + TaskScheduleIssue.mixedTimeModes => + l10n.taskStartDueTimeModeMismatch, + TaskScheduleIssue.none => null, + }; + }); + return; + } + final patch = updatedDraft.toPatch( + original, + capabilities, + localTimeZone: localTimeZone, + ); + if (!capabilities.supportsTaskStatus && + completed != task.completed) { + patch + ..['status'] = completed + ? 'completed' + : 'needsAction' + ..['completed'] = completed + ? DateTime.now().toUtc().toIso8601String() + : null; + } + if (patch.isNotEmpty) { + await repository.patchTask( + task.sourceId, + task.id, + TaskPatchInput(patch), + ); + } + if (selectedTaskListId != original.taskListId) { + await repository.moveTask( + TaskMoveInput( + sourceTaskListId: original.taskListId, + taskId: original.id, + destinationTaskListId: selectedTaskListId, + ), + ); + } + changed = true; + closeDialog(); + } on Object catch (_) { + setState(() { + busy = false; + error = l10n.operationFailed; + }); + } + }, + child: busy + ? const SizedBox( + width: 16, + height: 16, + child: ProgressRing(), + ) + : Text(l10n.save), + ), + ], + ), + ); + }, + ), + ); + title.dispose(); + notes.dispose(); + categories.dispose(); + location.dispose(); + taskUrl.dispose(); + subtaskTitle.dispose(); + if (nextTask case final taskToOpen?) { + if (!context.mounted) return changed; + return await showWindowsTaskDetailsDialog(context, ref, taskToOpen) || + changed; + } + return changed; +} + +TaskScheduleItem _scheduleItemForTask( + TaskEntity task, + BusyProvider provider, +) => TaskScheduleItem( + id: task.id, + accountId: task.accountId, + provider: provider, + sourceId: task.taskListId, + title: task.title, + completed: task.status == 'completed' || task.status == 'COMPLETED', + // The dialog reloads the provider-native schedule fields before rendering. + allDay: true, +); + +class _TaskListCombo extends StatelessWidget { + const _TaskListCombo({ + required this.taskLists, + required this.selectedTaskListId, + required this.enabled, + required this.onChanged, + }); + + final List taskLists; + final String selectedTaskListId; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: AppLocalizations.of(context).list, + child: ComboBox( + isExpanded: true, + value: selectedTaskListId, + items: [ + for (final taskList in taskLists) + ComboBoxItem(value: taskList.id, child: Text(taskList.title)), + ], + onChanged: enabled + ? (value) { + if (value != null) onChanged(value); + } + : null, + ), + ); +} + +String _dateOnly(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + +String _timeOnly(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}'; + +DateTime? _taskDueDateTime(TaskDetailsDraft draft) { + return _taskDateTime(draft.dueDate, draft.microsoftDueTime); +} + +DateTime? _taskDateTime(String? dateValue, String? timeValue) { + final date = DateTime.tryParse(dateValue ?? ''); + if (date == null) return null; + final parts = timeValue?.split(':'); + return DateTime( + date.year, + date.month, + date.day, + parts == null || parts.isEmpty ? 0 : int.tryParse(parts[0]) ?? 0, + parts == null || parts.length < 2 ? 0 : int.tryParse(parts[1]) ?? 0, + ); +} + +class _TaskDateTimeField extends StatelessWidget { + const _TaskDateTimeField({ + required this.label, + required this.value, + required this.showTime, + required this.timeZone, + required this.enabled, + required this.onChanged, + required this.onChooseTimeZone, + }); + + final String label; + final DateTime? value; + final bool showTime; + final String? timeZone; + final bool enabled; + final ValueChanged onChanged; + final Future Function() onChooseTimeZone; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: enabled + ? (date) => onChanged( + DateTime( + date.year, + date.month, + date.day, + value?.hour ?? 9, + value?.minute ?? 0, + ), + ) + : null, + ), + if (showTime && value != null) ...[ + TimePicker( + selected: value, + onChanged: enabled + ? (time) => onChanged( + DateTime( + value!.year, + value!.month, + value!.day, + time.hour, + time.minute, + ), + ) + : null, + ), + if (timeZone != null) + Button( + onPressed: enabled ? onChooseTimeZone : null, + child: Text(timeZone!), + ), + ], + if (value != null) + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.close)), + onPressed: enabled ? () => onChanged(null) : null, + ), + ], + ), + ); +} + +class _StringCombo extends StatelessWidget { + const _StringCombo({ + required this.label, + required this.value, + required this.values, + required this.enabled, + required this.onChanged, + }); + + final String label; + final String value; + final Map values; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: ComboBox( + isExpanded: true, + value: value, + items: [ + for (final entry in values.entries) + ComboBoxItem(value: entry.key, child: Text(entry.value)), + ], + onChanged: enabled + ? (value) { + if (value != null) onChanged(value); + } + : null, + ), + ); +} + +String _alarmLabel(BuildContext context, IcalTaskAlarm alarm) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final absolute = alarm.absoluteUtc; + if (absolute != null) { + final local = absolute.toLocal(); + return l10n.dateTimeDisplay( + DateFormat.yMMMd(locale).format(local), + DateFormat.jm(locale).format(local), + ); + } + final offset = alarm.relativeOffset; + if (offset == Duration.zero) { + return alarm.isRelatedToDue + ? l10n.reminderAtTaskDue + : l10n.reminderAtTaskStart; + } + if (offset != null && offset.isNegative) { + final minutes = offset.inMinutes.abs(); + const minutesPerDay = Duration.minutesPerHour * Duration.hoursPerDay; + if (minutes % minutesPerDay == 0) { + return l10n.reminderDaysBefore(minutes ~/ minutesPerDay); + } + if (minutes % Duration.minutesPerHour == 0) { + return l10n.reminderHoursBefore(minutes ~/ Duration.minutesPerHour); + } + return l10n.reminderMinutesBefore(minutes); + } + return l10n.unsupportedReminder; +} + +Future _showAbsoluteReminderDialog( + BuildContext context, { + required DateTime initial, +}) { + var value = initial; + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => ContentDialog( + title: Text(AppLocalizations.of(context).reminderTime), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: (date) => setState(() { + value = DateTime( + date.year, + date.month, + date.day, + value.hour, + value.minute, + ); + }), + ), + TimePicker( + selected: value, + onChanged: (time) => setState(() { + value = DateTime( + value.year, + value.month, + value.day, + time.hour, + time.minute, + ); + }), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, value), + child: Text(AppLocalizations.of(context).save), + ), + ], + ), + ), + ); +} + +Future _showTextInputDialog( + BuildContext context, { + required String title, + required String initialValue, +}) async { + final controller = TextEditingController(text: initialValue); + String? result; + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => ContentDialog( + title: Text(title), + content: TextBox( + controller: controller, + autofocus: true, + onChanged: (_) => setState(() {}), + onSubmitted: (value) { + final normalized = value.trim(); + if (normalized.isEmpty) return; + result = normalized; + Navigator.pop(dialogContext); + }, + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: controller.text.trim().isEmpty + ? null + : () { + result = controller.text.trim(); + Navigator.pop(dialogContext); + }, + child: Text(AppLocalizations.of(context).save), + ), + ], + ), + ), + ); + controller.dispose(); + return result; +} + +Future _confirmDiscardTaskChanges(BuildContext context) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.discardChanges), + content: Text(l10n.discardChangesConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; +} + +String _recurrenceType(String? recurrenceJson) { + if (recurrenceJson == null || recurrenceJson.isEmpty) return 'none'; + try { + final decoded = jsonDecode(recurrenceJson); + if (decoded is Map) { + final pattern = decoded['pattern']; + if (pattern is Map) { + return switch (pattern['type']) { + 'daily' => 'daily', + 'weekly' => 'weekly', + 'absoluteMonthly' => 'absoluteMonthly', + 'absoluteYearly' => 'absoluteYearly', + _ => 'unsupported', + }; + } + final rules = decoded['rules']; + if (rules is List && rules.isNotEmpty) { + final rule = rules.first.toString().toUpperCase(); + if (rule.contains('FREQ=DAILY')) return 'daily'; + if (rule.contains('FREQ=WEEKLY')) return 'weekly'; + if (rule.contains('FREQ=MONTHLY')) return 'absoluteMonthly'; + if (rule.contains('FREQ=YEARLY')) return 'absoluteYearly'; + } + } + } on FormatException { + return 'unsupported'; + } + return 'unsupported'; +} + +String? _recurrenceJsonFor( + String type, { + required DateTime? due, + required DateTime? start, + required bool dav, +}) { + if (type == 'none') return null; + final recurrenceStart = start ?? due ?? DateTime.now(); + if (dav) { + final frequency = switch (type) { + 'daily' => 'DAILY', + 'weekly' => 'WEEKLY', + 'absoluteMonthly' => 'MONTHLY', + 'absoluteYearly' => 'YEARLY', + _ => 'DAILY', + }; + final parts = ['FREQ=$frequency', 'INTERVAL=1']; + if (type == 'weekly') { + parts.add('BYDAY=${_weekdayCode(recurrenceStart.weekday)}'); + } else if (type == 'absoluteMonthly') { + parts.add('BYMONTHDAY=${recurrenceStart.day}'); + } else if (type == 'absoluteYearly') { + parts + ..add('BYMONTH=${recurrenceStart.month}') + ..add('BYMONTHDAY=${recurrenceStart.day}'); + } + return jsonEncode({ + 'rules': [parts.join(';')], + 'dates': const [], + 'excludedDates': const [], + }); + } + final pattern = switch (type) { + 'daily' => {'type': 'daily', 'interval': 1}, + 'weekly' => { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': [_weekdayName(recurrenceStart.weekday)], + 'firstDayOfWeek': 'monday', + }, + 'absoluteMonthly' => { + 'type': 'absoluteMonthly', + 'interval': 1, + 'dayOfMonth': recurrenceStart.day, + }, + 'absoluteYearly' => { + 'type': 'absoluteYearly', + 'interval': 1, + 'dayOfMonth': recurrenceStart.day, + 'month': recurrenceStart.month, + }, + _ => {'type': 'daily', 'interval': 1}, + }; + return jsonEncode({ + 'pattern': pattern, + 'range': {'type': 'noEnd', 'startDate': _dateOnly(recurrenceStart)}, + }); +} + +String _weekdayCode(int weekday) => switch (weekday) { + DateTime.monday => 'MO', + DateTime.tuesday => 'TU', + DateTime.wednesday => 'WE', + DateTime.thursday => 'TH', + DateTime.friday => 'FR', + DateTime.saturday => 'SA', + _ => 'SU', +}; + +String _weekdayName(int weekday) => switch (weekday) { + DateTime.monday => 'monday', + DateTime.tuesday => 'tuesday', + DateTime.wednesday => 'wednesday', + DateTime.thursday => 'thursday', + DateTime.friday => 'friday', + DateTime.saturday => 'saturday', + _ => 'sunday', +}; + +String _priorityLabel(AppLocalizations l10n, int priority) => priority == 0 + ? l10n.priorityNone + : priority <= 4 + ? l10n.priorityHighValue(priority) + : priority == 5 + ? l10n.priorityMediumValue(priority) + : l10n.priorityLowValue(priority); + +List _categories(String value) => value + .split(',') + .map((category) => category.trim()) + .where((category) => category.isNotEmpty) + .toSet() + .toList(growable: false); diff --git a/lib/src/ui/windows/windows_task_editor_dialog.dart b/lib/src/ui/windows/windows_task_editor_dialog.dart new file mode 100644 index 00000000..8f6f603f --- /dev/null +++ b/lib/src/ui/windows/windows_task_editor_dialog.dart @@ -0,0 +1,885 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../dav/ical/ical_task_alarm.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../features/tasks/data/tasks_repository.dart'; +import '../../features/tasks/domain/task_capabilities.dart'; +import '../../providers/busy_provider.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_recurrence_dialog.dart'; +import 'windows_task_create_fields.dart'; +import 'windows_time_zone_dialog.dart'; + +Future showWindowsTaskEditorDialog( + BuildContext context, + WidgetRef ref, +) async { + final accounts = await ref + .read(accountsRepositoryProvider) + .watchAccounts() + .first; + final accountsById = {for (final account in accounts) account.id: account}; + final capabilities = {}; + final lists = []; + for (final account in accounts.where((account) => account.isTaskCapable)) { + final accountLists = await ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .listTaskLists(); + for (final list in accountLists) { + final value = account.provider == BusyProvider.nextcloud + ? await ref.read( + davTaskCollectionCapabilitiesProvider(( + accountId: account.id, + taskListId: list.id, + )).future, + ) ?? + noTaskCollectionCapabilities + : adapterDefaultTaskCapabilities(account.provider); + if (value.canCreateTasks) { + lists.add(list); + capabilities[list.id] = value; + } + } + } + if (!context.mounted) return false; + if (lists.isEmpty) { + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(AppLocalizations.of(context).newTask), + content: Text(AppLocalizations.of(context).noTaskListsSynced), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ); + return false; + } + + final title = TextEditingController(); + final notes = TextEditingController(); + final location = TextEditingController(); + final taskUrl = TextEditingController(); + final categories = TextEditingController(); + var selectedList = lists.first; + var selectedTimeZone = ref.read(localTimeZoneProvider); + DateTime? due; + DateTime? start; + var scheduledAllDay = true; + DateTime? reminder; + var alarms = []; + var recurrence = const RecurrenceRule.none(); + var importance = 'normal'; + var status = ''; + var priority = 0; + var progress = 0; + var classification = 'PUBLIC'; + var pinned = false; + var hideSubtasks = false; + var hideCompletedSubtasks = false; + var saving = false; + var saved = false; + var allowPop = false; + String? error; + final initialListId = selectedList.id; + final initialTimeZone = selectedTimeZone; + bool hasPendingEdits() => + selectedList.id != initialListId || + selectedTimeZone != initialTimeZone || + title.text.isNotEmpty || + notes.text.isNotEmpty || + location.text.isNotEmpty || + taskUrl.text.isNotEmpty || + categories.text.isNotEmpty || + due != null || + start != null || + !scheduledAllDay || + reminder != null || + alarms.isNotEmpty || + recurrence.repeats || + importance != 'normal' || + status.isNotEmpty || + priority != 0 || + progress != 0 || + classification != 'PUBLIC' || + pinned || + hideSubtasks || + hideCompletedSubtasks; + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + final capability = + capabilities[selectedList.id] ?? noTaskCollectionCapabilities; + final provider = + accountsById[selectedList.accountId]?.provider ?? + BusyProvider.google; + final validUrl = + taskUrl.text.trim().isEmpty || + (Uri.tryParse(taskUrl.text.trim())?.hasScheme ?? false); + final validSchedule = + due == null || start == null || !due!.isBefore(start!); + void closeDialog() { + setState(() => allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (dialogContext.mounted) Navigator.pop(dialogContext); + }); + } + + return PopScope( + canPop: allowPop || (!saving && !hasPendingEdits()), + onPopInvokedWithResult: (didPop, _) async { + if (didPop || saving || allowPop) return; + if (await _confirmDiscardNewTaskChanges(context) && + context.mounted) { + closeDialog(); + } + }, + child: ContentDialog( + title: Text(l10n.newTask), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 760), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InfoLabel( + label: l10n.title, + child: TextBox( + controller: title, + autofocus: true, + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + InfoLabel( + label: l10n.taskLists, + child: ComboBox( + isExpanded: true, + value: selectedList, + items: [ + for (final list in lists) + ComboBoxItem(value: list, child: Text(list.title)), + ], + onChanged: saving + ? null + : (list) { + if (list != null) { + setState(() { + selectedList = list; + recurrence = const RecurrenceRule.none(); + reminder = null; + alarms = []; + scheduledAllDay = true; + }); + } + }, + ), + ), + if (capability.supportsDueDate) ...[ + const SizedBox(height: 12), + _DateTimeField( + label: l10n.dueDate, + value: due, + showTime: capability.supportsDueTime && !scheduledAllDay, + enabled: !saving, + onChanged: (value) => setState(() => due = value), + ), + ], + if (capability.supportsStartDateTime) ...[ + const SizedBox(height: 12), + _DateTimeField( + label: l10n.startDateTime, + value: start, + showTime: !scheduledAllDay, + enabled: !saving, + onChanged: (value) => setState(() => start = value), + ), + ], + if ((due != null || start != null) && + (capability.supportsDueTime || + capability.supportsStartDateTime)) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: scheduledAllDay, + onChanged: saving + ? null + : (value) => setState(() => scheduledAllDay = value), + content: Text(l10n.allDay), + ), + ], + if (capability.supportsDueTime || + capability.supportsStartDateTime || + capability.supportsReminderDateTime) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.selectTimeZone, + child: Button( + onPressed: saving + ? null + : () async { + final value = await showWindowsTimeZoneDialog( + context, + selectedTimeZone: selectedTimeZone, + ); + if (value != null) { + setState(() => selectedTimeZone = value); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(selectedTimeZone), + ), + ), + ), + ], + if (capability.supportsReminderDateTime && + !capability.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: reminder != null, + onChanged: saving + ? null + : (enabled) => setState( + () => reminder = enabled + ? (due ?? start ?? DateTime.now()).subtract( + const Duration(minutes: 15), + ) + : null, + ), + content: Text(l10n.reminder), + ), + if (reminder != null) ...[ + const SizedBox(height: 8), + _DateTimeField( + label: l10n.reminderTime, + value: reminder, + showTime: true, + enabled: !saving, + onChanged: (value) => setState(() => reminder = value), + ), + ], + ], + if (capability.supportsMultipleReminders) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.reminder, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < alarms.length; index += 1) + ListTile( + title: Text( + _newTaskAlarmLabel(context, alarms[index]), + ), + onPressed: saving + ? null + : () async { + final value = + await _showTaskReminderDialog( + context, + initial: alarms[index].absoluteUtc! + .toLocal(), + ); + if (value != null) { + setState(() { + alarms[index] = alarms[index] + .withAbsoluteTrigger( + value.toUtc(), + ); + }); + } + }, + trailing: IconButton( + icon: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.delete), + ), + onPressed: saving + ? null + : () => setState( + () => alarms.removeAt(index), + ), + ), + ), + Button( + onPressed: saving + ? null + : () async { + final value = await _showTaskReminderDialog( + context, + initial: (due ?? start ?? DateTime.now()) + .subtract( + const Duration(minutes: 15), + ), + ); + if (value != null) { + setState(() { + alarms = [ + ...alarms, + IcalTaskAlarm.displayAbsolute( + value.toUtc(), + ), + ]; + }); + } + }, + child: Text(l10n.addReminder), + ), + ], + ), + ), + ], + if (capability.supportsRecurrence && + (due != null || start != null)) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.repeat, + child: capability.supportsAdvancedRecurrence + ? Button( + onPressed: saving + ? null + : () async { + final value = + await showWindowsRecurrenceDialog( + context, + initial: recurrence, + baseDate: start ?? due!, + allDay: scheduledAllDay, + timeZone: selectedTimeZone, + providerLabel: provider.displayName, + limits: + RecurrenceRuleLimits.rfc5545, + ); + if (value != null) { + setState(() => recurrence = value); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + _recurrenceLabel(l10n, recurrence.frequency), + ), + ), + ) + : ComboBox( + isExpanded: true, + value: recurrence.frequency, + items: [ + for (final frequency + in RecurrenceFrequency.values) + ComboBoxItem( + value: frequency, + child: Text( + _recurrenceLabel(l10n, frequency), + ), + ), + ], + onChanged: saving + ? null + : (frequency) { + if (frequency != null) { + setState( + () => recurrence = + _simpleRecurrenceRule( + frequency, + start ?? due!, + ), + ); + } + }, + ), + ), + ], + if (capability.supportsImportance && + !capability.supportsIcalPriority) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.importance, + value: importance, + values: { + 'low': l10n.importanceLow, + 'normal': l10n.importanceNormal, + 'high': l10n.importanceHigh, + }, + enabled: !saving, + onChanged: (value) => setState(() => importance = value), + ), + ], + if (capability.supportsCategories) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.categories, + child: TextBox(controller: categories), + ), + ], + if (capability.supportsTaskStatus) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.taskStatus, + value: status, + values: { + '': l10n.taskStatusNone, + 'NEEDS-ACTION': l10n.taskStatusNeedsAction, + 'IN-PROCESS': l10n.taskStatusInProcess, + 'COMPLETED': l10n.taskStatusCompleted, + 'CANCELLED': l10n.taskStatusCancelled, + }, + enabled: !saving, + onChanged: (value) => setState(() => status = value), + ), + ], + if (capability.supportsPercentComplete) ...[ + const SizedBox(height: 12), + Text(l10n.completionPercent(progress)), + Slider( + value: progress.toDouble(), + max: 100, + divisions: 100, + onChanged: saving + ? null + : (value) => setState(() => progress = value.round()), + ), + ], + if (capability.supportsIcalPriority) ...[ + const SizedBox(height: 12), + Text(_priorityLabel(l10n, priority)), + Slider( + value: priority.toDouble(), + max: 9, + divisions: 9, + onChanged: saving + ? null + : (value) => setState(() => priority = value.round()), + ), + ], + if (capability.supportsLocation) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.location, + child: TextBox(controller: location), + ), + ], + if (capability.supportsUrl) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.taskUrl, + child: TextBox( + controller: taskUrl, + onChanged: (_) => setState(() {}), + ), + ), + if (!validUrl) + InfoBar( + title: Text(l10n.invalidTaskUrl), + severity: InfoBarSeverity.warning, + ), + if (!validSchedule) ...[ + const SizedBox(height: 8), + InfoBar( + title: Text(l10n.taskDueBeforeStart), + severity: InfoBarSeverity.warning, + ), + ], + ], + if (capability.supportsClassification) ...[ + const SizedBox(height: 12), + _StringCombo( + label: l10n.classification, + value: classification, + values: { + 'PUBLIC': l10n.classificationPublic, + 'CONFIDENTIAL': l10n.classificationConfidential, + 'PRIVATE': l10n.classificationPrivate, + }, + enabled: !saving, + onChanged: (value) => + setState(() => classification = value), + ), + ], + if (capability.supportsPinning) ...[ + const SizedBox(height: 12), + ToggleSwitch( + checked: pinned, + onChanged: saving + ? null + : (value) => setState(() => pinned = value), + content: Text(l10n.pinTask), + ), + ], + if (capability.supportsSubtaskVisibility) ...[ + const SizedBox(height: 8), + ToggleSwitch( + checked: hideSubtasks, + onChanged: saving + ? null + : (value) => setState(() => hideSubtasks = value), + content: Text(l10n.hideSubtasks), + ), + const SizedBox(height: 8), + ToggleSwitch( + checked: hideCompletedSubtasks, + onChanged: saving + ? null + : (value) => + setState(() => hideCompletedSubtasks = value), + content: Text(l10n.hideClosedSubtasks), + ), + ], + const SizedBox(height: 12), + InfoLabel( + label: l10n.notes, + child: TextBox(controller: notes, maxLines: 4), + ), + if (error != null) ...[ + const SizedBox(height: 12), + InfoBar( + title: Text(error!), + severity: InfoBarSeverity.error, + ), + ], + ], + ), + ), + actions: [ + Button( + onPressed: saving + ? null + : () async { + if (hasPendingEdits() && + !await _confirmDiscardNewTaskChanges(context)) { + return; + } + closeDialog(); + }, + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: + saving || + title.text.trim().isEmpty || + !validUrl || + !validSchedule + ? null + : () async { + setState(() { + saving = true; + error = null; + }); + try { + final fields = buildWindowsTaskCreateFields( + capability: capability, + provider: provider, + due: due, + start: start, + reminder: reminder, + alarms: alarms, + scheduledAllDay: scheduledAllDay, + timeZone: selectedTimeZone, + recurrence: recurrence, + importance: importance, + status: status, + priority: priority, + progress: progress, + location: location.text, + taskUrl: taskUrl.text, + classification: classification, + pinned: pinned, + hideSubtasks: hideSubtasks, + hideCompletedSubtasks: hideCompletedSubtasks, + ); + await ref + .read( + tasksRepositoryForAccountProvider( + selectedList.accountId, + ), + ) + .createTask( + selectedList.id, + TaskCreateInput( + title: title.text.trim(), + notes: notes.text.trim(), + dueUtc: due?.toUtc(), + categories: _categories(categories.text), + fields: fields, + ), + ); + saved = true; + closeDialog(); + } on Object catch (_) { + setState(() { + saving = false; + error = l10n.operationFailed; + }); + } + }, + child: saving + ? const SizedBox( + width: 16, + height: 16, + child: ProgressRing(), + ) + : Text(l10n.create), + ), + ], + ), + ); + }, + ), + ); + title.dispose(); + notes.dispose(); + location.dispose(); + taskUrl.dispose(); + categories.dispose(); + return saved; +} + +class _DateTimeField extends StatelessWidget { + const _DateTimeField({ + required this.label, + required this.value, + required this.showTime, + required this.enabled, + required this.onChanged, + }); + + final String label; + final DateTime? value; + final bool showTime; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: enabled + ? (date) => onChanged( + DateTime( + date.year, + date.month, + date.day, + value?.hour ?? 9, + value?.minute ?? 0, + ), + ) + : null, + ), + if (showTime && value != null) + TimePicker( + selected: value, + onChanged: enabled + ? (time) => onChanged( + DateTime( + value!.year, + value!.month, + value!.day, + time.hour, + time.minute, + ), + ) + : null, + ), + if (value != null) + IconButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.close)), + onPressed: enabled ? () => onChanged(null) : null, + ), + ], + ), + ); +} + +class _StringCombo extends StatelessWidget { + const _StringCombo({ + required this.label, + required this.value, + required this.values, + required this.enabled, + required this.onChanged, + }); + + final String label; + final String value; + final Map values; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => InfoLabel( + label: label, + child: ComboBox( + isExpanded: true, + value: value, + items: [ + for (final entry in values.entries) + ComboBoxItem(value: entry.key, child: Text(entry.value)), + ], + onChanged: enabled + ? (value) { + if (value != null) onChanged(value); + } + : null, + ), + ); +} + +String _newTaskAlarmLabel(BuildContext context, IcalTaskAlarm alarm) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final local = alarm.absoluteUtc!.toLocal(); + return l10n.dateTimeDisplay( + DateFormat.yMMMd(locale).format(local), + DateFormat.jm(locale).format(local), + ); +} + +Future _showTaskReminderDialog( + BuildContext context, { + required DateTime initial, +}) { + var value = initial; + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => ContentDialog( + title: Text(AppLocalizations.of(context).reminderTime), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DatePicker( + selected: value, + onChanged: (date) => setState(() { + value = DateTime( + date.year, + date.month, + date.day, + value.hour, + value.minute, + ); + }), + ), + TimePicker( + selected: value, + onChanged: (time) => setState(() { + value = DateTime( + value.year, + value.month, + value.day, + time.hour, + time.minute, + ); + }), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(context).cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, value), + child: Text(AppLocalizations.of(context).save), + ), + ], + ), + ), + ); +} + +Future _confirmDiscardNewTaskChanges(BuildContext context) async { + final l10n = AppLocalizations.of(context); + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.discardChanges), + content: Text(l10n.discardChangesConfirmation), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; +} + +RecurrenceRule _simpleRecurrenceRule( + RecurrenceFrequency frequency, + DateTime baseDate, +) { + if (frequency == RecurrenceFrequency.none) { + return const RecurrenceRule.none(); + } + return RecurrenceRule( + frequency: frequency, + interval: 1, + byDay: frequency == RecurrenceFrequency.weekly + ? [rfcWeekdays[baseDate.weekday - 1]] + : const [], + byMonth: frequency == RecurrenceFrequency.yearly + ? [baseDate.month] + : const [], + byMonthDay: + frequency == RecurrenceFrequency.monthly || + frequency == RecurrenceFrequency.yearly + ? [baseDate.day] + : const [], + bySetPosition: null, + count: null, + untilRaw: null, + recurrenceDates: const [], + exceptionDates: const [], + rawRules: const [], + isSupported: true, + ); +} + +String _recurrenceLabel(AppLocalizations l10n, RecurrenceFrequency frequency) => + switch (frequency) { + RecurrenceFrequency.none => l10n.repeatNone, + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + }; + +String _priorityLabel(AppLocalizations l10n, int priority) => priority == 0 + ? l10n.priorityNone + : priority <= 4 + ? l10n.priorityHighValue(priority) + : priority == 5 + ? l10n.priorityMediumValue(priority) + : l10n.priorityLowValue(priority); + +List _categories(String value) => [ + for (final category in value.split(',')) + if (category.trim().isNotEmpty) category.trim(), +]; diff --git a/lib/src/ui/windows/windows_tasks_page.dart b/lib/src/ui/windows/windows_tasks_page.dart new file mode 100644 index 00000000..c44dfe11 --- /dev/null +++ b/lib/src/ui/windows/windows_tasks_page.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/app_bootstrap.dart'; +import '../../app/busymax_shortcuts.dart'; +import '../../schedule/schedule_filters.dart'; +import '../../schedule/schedule_item.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_task_details_dialog.dart'; +import 'windows_task_editor_dialog.dart'; + +class WindowsTasksPage extends ConsumerStatefulWidget { + const WindowsTasksPage({super.key}); + + @override + ConsumerState createState() => _WindowsTasksPageState(); +} + +class _WindowsTasksPageState extends ConsumerState { + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + late Future> _tasks = _load(); + var _query = ''; + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + Future> _load() async { + final repository = ref.read(scheduleRepositoryProvider); + final filters = ScheduleFilters( + query: _query, + includeCalendarEvents: false, + includeTasks: true, + showCompletedTasks: true, + showNoDateTasks: true, + ); + return repository.listAllTasks(filters: filters); + } + + void _reload() => setState(() => _tasks = _load()); + + Future _createTask() async { + final changed = await showWindowsTaskEditorDialog(context, ref); + if (changed && mounted) _reload(); + } + + void _dismissSearch() { + if (!_searchFocusNode.hasFocus && _query.isEmpty) return; + _searchController.clear(); + _query = ''; + _searchFocusNode.unfocus(); + _reload(); + } + + void _createTaskFromShortcut() { + final focusContext = FocusManager.instance.primaryFocus?.context; + if (focusContext != null && + (focusContext.widget is EditableText || + focusContext.findAncestorWidgetOfExactType() != + null)) { + return; + } + unawaited(_createTask()); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + return CallbackShortcuts( + bindings: { + BusyMaxShortcutActivators.search: _searchFocusNode.requestFocus, + BusyMaxShortcutActivators.dismiss: _dismissSearch, + const SingleActivator(LogicalKeyboardKey.keyT): _createTaskFromShortcut, + }, + child: ScaffoldPage( + header: PageHeader( + title: Text(l10n.tasks), + commandBar: CommandBar( + primaryItems: [ + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + label: Text(l10n.newTask), + onPressed: () => unawaited(_createTask()), + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), + label: Text(l10n.refresh), + onPressed: _reload, + ), + ], + ), + ), + content: Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(24, 0, 24, 12), + child: TextBox( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: l10n.windowsSearch, + prefix: Padding( + padding: const EdgeInsetsDirectional.only(start: 10), + child: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.search)), + ), + onChanged: (value) { + _query = value; + _reload(); + }, + ), + ), + Expanded( + child: FutureBuilder>( + future: _tasks, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: ProgressRing()); + } + if (snapshot.hasError) { + return Center( + child: InfoBar( + title: Text(l10n.scheduleUnavailable), + severity: InfoBarSeverity.error, + action: Button( + onPressed: _reload, + child: Text(l10n.retry), + ), + ), + ); + } + final tasks = snapshot.data ?? const []; + if (tasks.isEmpty) { + return Center( + child: Text( + _query.isEmpty + ? l10n.signInToViewTasks + : l10n.scheduleNoSearchResults, + ), + ); + } + return ListView.builder( + padding: const EdgeInsetsDirectional.fromSTEB( + 24, + 0, + 24, + 24, + ), + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + final due = task.start == null + ? '' + : DateFormat.yMMMd(locale).format(task.start!); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + child: ListTile( + leading: Icon( + windowsBusyMaxGlyph( + task.completed + ? BusyMaxGlyph.check + : BusyMaxGlyph.task, + ), + ), + title: Text( + task.title, + style: task.completed + ? const TextStyle( + decoration: TextDecoration.lineThrough, + ) + : null, + ), + subtitle: Text( + [ + due, + ?task.sourceName, + ].where((value) => value.isNotEmpty).join(' · '), + ), + onPressed: () => unawaited( + showWindowsTaskDetailsDialog( + context, + ref, + task, + ).then((changed) { + if (changed) _reload(); + }), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/ui/windows/windows_time_zone_dialog.dart b/lib/src/ui/windows/windows_time_zone_dialog.dart new file mode 100644 index 00000000..c18eb0a9 --- /dev/null +++ b/lib/src/ui/windows/windows_time_zone_dialog.dart @@ -0,0 +1,86 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../core/time/time_zone_catalog.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; + +Future showWindowsTimeZoneDialog( + BuildContext context, { + required String selectedTimeZone, +}) async { + final search = TextEditingController(); + var selected = selectedTimeZone; + var results = []; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = AppLocalizations.of(context); + return ContentDialog( + title: Text(l10n.selectTimeZone), + constraints: const BoxConstraints(maxWidth: 620, maxHeight: 640), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextBox( + controller: search, + autofocus: true, + placeholder: l10n.searchLocations, + prefix: Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.search)), + ), + onChanged: (query) => setState( + () => results = BusyMaxTimeZoneCatalog.search( + query, + ).take(100).toList(), + ), + ), + const SizedBox(height: 12), + Text( + BusyMaxTimeZoneCatalog.location(selected).displayLabel, + style: FluentTheme.of(context).typography.bodyStrong, + ), + const SizedBox(height: 8), + Flexible( + child: results.isEmpty + ? Center(child: Text(l10n.noLocationsFound)) + : ListView.builder( + shrinkWrap: true, + itemCount: results.length, + itemBuilder: (context, index) { + final location = results[index]; + return ListTile.selectable( + selected: location.id == selected, + title: Text(location.displayLabel), + subtitle: Text( + '${location.region} · ${location.name}', + ), + onPressed: () => + setState(() => selected = location.id), + ); + }, + ), + ), + ], + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, selected), + child: Text(l10n.save), + ), + ], + ); + }, + ), + ); + search.dispose(); + return result; +} diff --git a/lib/src/ui/windows/windows_workspace_shell.dart b/lib/src/ui/windows/windows_workspace_shell.dart new file mode 100644 index 00000000..e61bd24d --- /dev/null +++ b/lib/src/ui/windows/windows_workspace_shell.dart @@ -0,0 +1,74 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../app/busymax_shortcuts.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; +import 'windows_keyboard_shortcuts_dialog.dart'; + +enum WindowsWorkspaceDestination { schedule, tasks, settings } + +class WindowsWorkspaceShell extends StatelessWidget { + const WindowsWorkspaceShell({ + required this.destination, + required this.child, + super.key, + }); + + final WindowsWorkspaceDestination destination; + final Widget child; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final selected = destination.index; + return CallbackShortcuts( + bindings: { + BusyMaxShortcutActivators.settings: () => context.go('/settings'), + BusyMaxShortcutActivators.keyboardShortcuts: () => + showWindowsKeyboardShortcutsDialog(context), + }, + child: Focus( + autofocus: true, + child: NavigationView( + pane: NavigationPane( + selected: selected, + displayMode: PaneDisplayMode.compact, + size: const NavigationPaneSize(compactWidth: 48, openWidth: 240), + header: const Padding( + padding: EdgeInsetsDirectional.only(start: 12), + child: Text('BusyMax'), + ), + onChanged: (index) { + context.go(switch (index) { + 0 => '/schedule', + 1 => '/tasks', + _ => '/settings', + }); + }, + items: [ + PaneItem( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + title: Text(l10n.calendar), + body: selected == 0 ? child : const SizedBox.shrink(), + ), + PaneItem( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), + title: Text(l10n.tasks), + body: selected == 1 ? child : const SizedBox.shrink(), + ), + ], + footerItems: [ + PaneItem( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.settings)), + title: Text(l10n.settings), + body: selected == 2 ? child : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index a246036a..7228addc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,10 +9,8 @@ #include #include #include -#include #include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -25,18 +23,12 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); - screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) window_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); - window_manager_plugin_register_with_registrar(window_manager_registrar); g_autoptr(FlPluginRegistrar) yaru_window_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "YaruWindowLinuxPlugin"); yaru_window_linux_plugin_register_with_registrar(yaru_window_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a8c1e404..e8e9770f 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,10 +6,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux gtk - screen_retriever_linux system_theme url_launcher_linux - window_manager yaru_window_linux ) diff --git a/pubspec.lock b/pubspec.lock index 5f21ce71..71815bf9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + console: + dependency: transitive + description: + name: console + sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a + url: "https://pub.dev" + source: hosted + version: "4.1.0" convert: dependency: transitive description: @@ -273,6 +281,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.33.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -377,6 +393,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fluent_ui: + dependency: "direct main" + description: + name: fluent_ui + sha256: "64c2b9dd208f8e3134d3e38db107235ee30c1dd9b792843274687a7ecc063590" + url: "https://pub.dev" + source: hosted + version: "4.16.1" + fluentui_system_icons: + dependency: "direct main" + description: + name: fluentui_system_icons + sha256: "1592ae32dae75fb66741ab2c0607bc6554ef086f1c457e27ef937fb4901d33da" + url: "https://pub.dev" + source: hosted + version: "1.1.273" flutter: dependency: "direct main" description: flutter @@ -395,6 +427,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter @@ -461,6 +533,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + path: "third_party/flutter_timezone" + relative: true + source: path + version: "5.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -471,6 +550,14 @@ packages: description: flutter source: sdk version: "0.0.0" + get_it: + dependency: transitive + description: + name: get_it + sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" + url: "https://pub.dev" + source: hosted + version: "9.2.1" glob: dependency: transitive description: @@ -544,7 +631,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct dev" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce @@ -660,6 +747,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + math_expressions: + dependency: transitive + description: + name: math_expressions + sha256: "22aca3a24e445be5f42ecf186cbded1f9951bf9ec5eb48fcddbd55e102bb11f5" + url: "https://pub.dev" + source: hosted + version: "3.2.0" matrix4_transform: dependency: transitive description: @@ -668,6 +763,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -692,6 +795,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + msix: + dependency: "direct dev" + description: + name: msix + sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7" + url: "https://pub.dev" + source: hosted + version: "3.18.0" native_toolchain_c: dependency: transitive description: @@ -900,46 +1011,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" - screen_retriever: - dependency: transitive - description: - name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_linux: - dependency: transitive - description: - name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_macos: - dependency: transitive - description: - name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_platform_interface: + scroll_pos: dependency: transitive description: - name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + name: scroll_pos + sha256: cebf602b2dd939de6832bb902ffefb574608d1b84f420b82b381a4007d3c1e1b url: "https://pub.dev" source: hosted - version: "0.2.0" - screen_retriever_windows: - dependency: transitive - description: - name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" - url: "https://pub.dev" - source: hosted - version: "0.2.0" + version: "0.5.0" shelf: dependency: transitive description: @@ -956,6 +1035,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sky_engine: dependency: transitive description: flutter @@ -1089,6 +1176,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + tray_manager: + dependency: "direct main" + description: + path: "third_party/tray_manager" + relative: true + source: path + version: "0.5.3" typed_data: dependency: transitive description: @@ -1249,14 +1343,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.0" - window_manager: - dependency: transitive - description: - name: window_manager - sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" - url: "https://pub.dev" - source: hosted - version: "0.5.1" xdg_directories: dependency: transitive description: @@ -1297,12 +1383,11 @@ packages: source: hosted version: "10.2.0" yaru_window: - dependency: transitive + dependency: "direct overridden" description: - name: yaru_window - sha256: "58539a9abe9901891dadce142c7a5d303920b780dd0f7bd21f076a80adeeb744" - url: "https://pub.dev" - source: hosted + path: "third_party/yaru_window" + relative: true + source: path version: "0.2.2" yaru_window_linux: dependency: transitive @@ -1312,14 +1397,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" - yaru_window_manager: - dependency: transitive - description: - name: yaru_window_manager - sha256: "6288fd6ccd8bb9d5be56073c6f277e2da08dd38e346507bf88bc9332b08dd180" - url: "https://pub.dev" - source: hosted - version: "0.1.3" yaru_window_platform_interface: dependency: transitive description: @@ -1328,14 +1405,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" - yaru_window_web: - dependency: transitive - description: - name: yaru_window_web - sha256: cf4d79e0760fbdcb78d4bbca3b5563f99518629224b9e5611f0ebd592befe1d9 - url: "https://pub.dev" - source: hosted - version: "0.0.4" sdks: dart: ">=3.12.0 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 73483f82..8a679155 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,8 +19,12 @@ dependencies: desktop_notifications: ^0.6.3 drift: ^2.33.0 file_selector: ^1.0.3 + fluent_ui: 4.16.1 + fluentui_system_icons: 1.1.273 flutter_riverpod: ^2.6.0 + flutter_local_notifications: 22.3.0 flutter_secure_storage: ^10.3.0 + flutter_timezone: 5.1.0 go_router: ^17.3.0 http: ^1.6.0 infinite_calendar_view: ^2.11.5 @@ -34,6 +38,7 @@ dependencies: sqlite3_flutter_libs: ^0.6.0 system_theme: ^3.3.0 timezone: ^0.11.1 + tray_manager: 0.5.3 ubuntu_localizations: ^0.5.2+3 url_launcher: ^6.3.0 uuid: ^4.5.0 @@ -46,13 +51,21 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter + image: 4.8.0 build_runner: ^2.15.0 drift_dev: ^2.33.0 flutter_lints: ^6.0.0 mocktail: ^1.0.0 + msix: 3.18.0 very_good_analysis: ^10.1.0 dependency_overrides: + flutter_timezone: + path: third_party/flutter_timezone + tray_manager: + path: third_party/tray_manager + yaru_window: + path: third_party/yaru_window xdg_status_notifier_item: path: third_party/xdg_status_notifier_item @@ -63,6 +76,8 @@ flutter: - assets/branding/busymax-logo.svg - assets/branding/busymax-logo-offline.svg - assets/branding/busymax-logo.png + - assets/windows/busymax_tray.ico + - assets/windows/busymax_tray_offline.ico # Do not add google_sign_in for Linux OAuth. It does not support Linux. # Do not add extension_google_sign_in_as_googleapis_auth for Linux OAuth. diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 83034ac0..b8320410 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -327,7 +327,7 @@ void main() { }); test('startup defers native presentation until the app frame is ready', () { - final source = File('lib/main.dart').readAsStringSync(); + final source = File('lib/main_linux.dart').readAsStringSync(); final deferFrame = source.indexOf('binding.deferFirstFrame();'); final firstRunApp = source.indexOf('runApp('); final firstAllowFrame = source.indexOf('binding.allowFirstFrame();'); @@ -340,9 +340,7 @@ void main() { test('snap uses portal-backed secret storage without keyring plug', () { final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - final bootstrap = File( - 'lib/src/app/app_bootstrap.dart', - ).readAsStringSync(); + final linuxMain = File('lib/main_linux.dart').readAsStringSync(); final portalStore = File( 'lib/src/core/secrets/portal_encrypted_secret_store.dart', ).readAsStringSync(); @@ -352,7 +350,7 @@ void main() { expect(snapcraft, contains('GDK_BACKEND: wayland,x11')); expect(snapcraft, contains('SECRET_BACKEND: file')); expect(snapcraft, isNot(contains('password-manager-service'))); - expect(bootstrap, contains('PortalEncryptedSecretStore')); + expect(linuxMain, contains('PortalEncryptedSecretStore')); expect(portalStore, contains('org.freedesktop.portal.Secret')); expect(portalStore, contains('RetrieveSecret')); expect(portalStore, contains('AesGcm.with256bits')); @@ -2187,7 +2185,7 @@ void main() { 'lib/src/platform/gtk_font_service.dart', ).readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); - final main = File('lib/main.dart').readAsStringSync(); + final main = File('lib/main_linux.dart').readAsStringSync(); expect(source, contains('kGtkThemeColorsEventChannel')); expect(source, contains('io.busystack.busymax/gtk_theme_colors')); @@ -2322,7 +2320,10 @@ void main() { final line = lines[index]; final location = '${file.path}:${index + 1}'; if (line.contains('fontFamily:') && - !file.path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { + !file.path.endsWith('lib/src/app/busymax_yaru_theme.dart') && + !file.path.endsWith( + 'lib/src/app/windows/windows_busymax_app.dart', + )) { matches.add('$location: $line'); } if (line.contains('fontSize:') && diff --git a/test/architecture/platform_boundary_test.dart b/test/architecture/platform_boundary_test.dart new file mode 100644 index 00000000..ce61382c --- /dev/null +++ b/test/architecture/platform_boundary_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../tool/check_platform_boundaries.dart' as boundary_check; + +void main() { + test( + 'platform import boundaries remain valid', + () { + final failures = boundary_check.findPlatformBoundaryViolations( + Directory.current, + ); + expect(failures, isEmpty, reason: failures.join('\n')); + }, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/test/config/build_config_test.dart b/test/config/build_config_test.dart index a688c9ac..8323ac0d 100644 --- a/test/config/build_config_test.dart +++ b/test/config/build_config_test.dart @@ -22,7 +22,7 @@ void main() { ); }); - test('missing client id has developer run command', () { + test('missing client id has platform-neutral developer guidance', () { const config = BuildConfig( googleOAuthClientId: '', googleOAuthClientSecret: '', @@ -34,7 +34,8 @@ void main() { ); expect(config.hasGoogleOAuthClientId, isFalse); - expect(config.missingClientIdMessage, contains('flutter run -d linux')); + expect(config.missingClientIdMessage, contains('platform entrypoint')); + expect(config.missingClientIdMessage, isNot(contains('linux'))); }); test('demo mode is never enabled in release builds', () { diff --git a/test/demo/demo_profile_test.dart b/test/demo/demo_profile_test.dart index bbd22184..6948847d 100644 --- a/test/demo/demo_profile_test.dart +++ b/test/demo/demo_profile_test.dart @@ -140,6 +140,7 @@ const _demoConfig = BuildConfig( ); const _feedback = FeedbackSubmission( + platform: 'linux', submissionId: 'demo-submission', appVersion: '1.0.0', buildNumber: '1', diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index 536c2ce6..fae5e266 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -23,6 +23,7 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -657,7 +658,7 @@ Future _disposeApp(WidgetTester tester) async { Future _openSettings(WidgetTester tester) async { final schedule = find.byType(ScheduleWorkspace); expect(schedule, findsOneWidget); - GoRouter.of(tester.element(schedule)).go('/settings'); + GoRouter.of(tester.element(schedule)).go('/settings?page=accounts'); await tester.pumpAndSettle(); expect(find.byType(SettingsScreen), findsOneWidget); } diff --git a/test/features/feedback/data/feedback_api_client_test.dart b/test/features/feedback/data/feedback_api_client_test.dart index 01131cb9..b3db4f47 100644 --- a/test/features/feedback/data/feedback_api_client_test.dart +++ b/test/features/feedback/data/feedback_api_client_test.dart @@ -78,4 +78,5 @@ const _submission = FeedbackSubmission( subject: 'Calendar issue', message: 'The calendar view did not update.', replyEmail: null, + platform: 'linux', ); diff --git a/test/features/feedback/data/feedback_submission_test.dart b/test/features/feedback/data/feedback_submission_test.dart index ca5698b1..9aaf10cd 100644 --- a/test/features/feedback/data/feedback_submission_test.dart +++ b/test/features/feedback/data/feedback_submission_test.dart @@ -84,6 +84,7 @@ void main() { subject: ' Privacy question ', message: ' Please explain this behavior. ', replyEmail: ' person@example.com ', + platform: 'linux', ); expect(submission.toJson(), { @@ -108,6 +109,7 @@ void main() { subject: 'Calendar issue', message: 'The calendar view did not update.', replyEmail: ' ', + platform: 'linux', technicalDetails: FeedbackTechnicalDetails( osVersion: 'Linux 6.8.0', locale: 'en-CA', diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 7c3213c1..278df310 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -5,7 +5,6 @@ import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_error.dart'; -import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -337,10 +336,7 @@ void main() { await service.notifyEventReminder('Standup', 'Starts at 9:00 AM'); - expect( - backend.notifications.single.hints.map((hint) => hint.key), - isNot(contains('transient')), - ); + expect(backend.notifications.single.transient, isFalse); }); test('reminder notification default action activates callback', () async { @@ -356,9 +352,12 @@ void main() { 'Starts at 9:00', onActivated: () async => activated = true, ); - await backend.notifications.single.onAction?.call('default'); + await backend.notifications.single.onAction?.call( + 'default', + backend.notifications.single.payload, + ); - expect(backend.notifications.single.actions.single.key, 'default'); + expect(backend.notifications.single.actions.single.id, 'default'); expect(activated, isTrue); }); @@ -439,33 +438,28 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { @override Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], + BusyMaxNotificationRequest request, { DesktopNotificationActionHandler? onAction, }) async { - notifications.add( - _NotificationRecord(summary, body, hints, actions, onAction), - ); + notifications.add(_NotificationRecord(request, onAction)); } + @override + Future cancel(String stableId) async {} + @override Future close() async {} } class _NotificationRecord { - const _NotificationRecord( - this.summary, - this.body, - this.hints, - this.actions, - this.onAction, - ); + const _NotificationRecord(this.request, this.onAction); - final String summary; - final String body; - final List hints; - final List actions; + final BusyMaxNotificationRequest request; final DesktopNotificationActionHandler? onAction; + + String get summary => request.title; + String get body => request.body; + bool get transient => request.transient; + Map? get payload => request.payload; + List get actions => request.actions; } diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart index 87bbd6cc..16f4c34b 100644 --- a/test/features/notifications/notification_scheduler_test.dart +++ b/test/features/notifications/notification_scheduler_test.dart @@ -3,7 +3,6 @@ import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/notifications/notification_scheduler.dart'; import 'package:busymax/src/providers/busy_provider.dart'; -import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:drift/drift.dart' hide isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -143,7 +142,7 @@ void main() { ); await _waitUntil(() => backend.notifications.isNotEmpty); - await backend.notifications.single.onAction?.call('default'); + await backend.notifications.single.invoke('default'); expect(activatedRow?.id, 'event|event-1|5'); expect(activatedRow?.sourceType, 'event'); @@ -263,7 +262,7 @@ void main() { await _insertDueTaskNotification(database, now); await scheduler.checkNow(); - await backend.notifications.single.onAction?.call('snooze'); + await backend.notifications.single.invoke('snooze'); var row = await database.select(database.notificationSchedule).getSingle(); expect(row.sentAtUtc, null); @@ -308,12 +307,12 @@ void main() { await scheduler.checkNow(); final notification = backend.notifications.single; - expect(notification.actions.map((action) => action.key), [ + expect(notification.actions.map((action) => action.id), [ 'default', 'snooze', 'dismiss', ]); - await notification.onAction?.call('dismiss'); + await notification.invoke('dismiss'); final row = await database .select(database.notificationSchedule) @@ -403,36 +402,37 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { @override Future notify( - String summary, { - String body = '', - List hints = const [], - List actions = const [], + BusyMaxNotificationRequest request, { DesktopNotificationActionHandler? onAction, }) async { final failure = error; if (failure != null) throw failure; - notifications.add(_NotificationRecord(summary, body, actions, onAction)); + notifications.add(_NotificationRecord(request, onAction)); final immediateAction = actionBeforeReturn; if (immediateAction != null) { actionBeforeReturn = null; - await onAction?.call(immediateAction); + await onAction?.call(immediateAction, request.payload); } } + @override + Future cancel(String stableId) async {} + @override Future close() async {} } class _NotificationRecord { - const _NotificationRecord( - this.summary, - this.body, - this.actions, - this.onAction, - ); + const _NotificationRecord(this.request, this.onAction); - final String summary; - final String body; - final List actions; + final BusyMaxNotificationRequest request; final DesktopNotificationActionHandler? onAction; + + String get summary => request.title; + String get body => request.body; + List get actions => request.actions; + + Future invoke(String action) async { + await onAction?.call(action, request.payload); + } } diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index 2b51685c..d9261a0e 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -9,6 +9,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_empty_states import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; diff --git a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart index 60eb9ee6..eb1e950b 100644 --- a/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_task_mutations_test.dart @@ -6,6 +6,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_workspace.da import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 3661804f..dd5f292e 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -83,6 +83,45 @@ void main() { }, ); + test('all-task query is not limited by year or an arbitrary page', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: BusyProvider.google); + await _insertTaskList(database); + await _insertTask( + database, + id: 'past', + title: 'Past task', + dueUtc: '2020-01-02', + ); + await _insertTask( + database, + id: 'future', + title: 'Future task', + dueUtc: '2040-03-04', + ); + for (var index = 0; index < 501; index += 1) { + await _insertTask( + database, + id: 'undated-$index', + title: 'Undated $index', + ); + } + + final tasks = await ScheduleRepository(database).listAllTasks( + filters: const ScheduleFilters( + accountIds: {'account'}, + includeCalendarEvents: false, + showCompletedTasks: true, + showNoDateTasks: true, + ), + ); + + expect(tasks, hasLength(503)); + expect(tasks.map((item) => item.id), containsAll(['past', 'future'])); + expect(tasks.where((item) => item.start == null), hasLength(501)); + }); + test( 'repository ensures calendar projection coverage before reading', () async { diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 37270f1c..3eb205e7 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -24,6 +24,7 @@ import 'package:busymax/src/features/settings/presentation/settings_screen.dart' import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/providers/busy_provider.dart'; diff --git a/test/platform/common/desktop_activation_test.dart b/test/platform/common/desktop_activation_test.dart new file mode 100644 index 00000000..644741fb --- /dev/null +++ b/test/platform/common/desktop_activation_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:busymax/src/platform/common/desktop_services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DesktopActivation', () { + test('round-trips every supported activation shape', () { + const values = [ + DesktopActivation(kind: DesktopActivationKind.normalLaunch), + DesktopActivation(kind: DesktopActivationKind.startMinimized), + DesktopActivation( + kind: DesktopActivationKind.icsFile, + value: r'C:\Users\Renée\Calendar\予定.ics', + ), + DesktopActivation( + kind: DesktopActivationKind.webCal, + value: 'webcal://calendar.example.test/public.ics?token=opaque', + ), + DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'snooze', + payload: { + 'notificationScheduleId': 'schedule-42', + 'itemKind': 'event', + 'accountId': 'account-7', + 'itemId': 'event-9', + }, + ), + ]; + + for (final value in values) { + final decoded = DesktopActivation.tryDecode(value.encode()); + expect(decoded, isNotNull); + expect(decoded!.toJson(), value.toJson()); + } + }); + + test('rejects malformed, unknown, and oversized input', () { + expect(DesktopActivation.tryDecode('{'), isNull); + expect( + DesktopActivation.tryDecode( + jsonEncode({'version': 1, 'kind': 'shellCommand', 'value': 'calc'}), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + jsonEncode({ + 'version': 1, + 'kind': 'normalLaunch', + 'unexpected': 'ignored fields must not relax the model', + }), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + jsonEncode({ + 'version': 1, + 'kind': 'icsFile', + 'value': r'C:\Temp\notes.txt', + }), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + jsonEncode({ + 'version': 1, + 'kind': 'webCal', + 'value': 'https://calendar.example.test/feed.ics', + }), + ), + isNull, + ); + expect( + DesktopActivation.tryDecode( + 'x' * (DesktopActivation.maximumEncodedBytes + 1), + ), + isNull, + ); + }); + + test('requires safe notification action and stable schedule identity', () { + for (final action in const ['default', 'open', 'snooze', 'dismiss']) { + expect( + DesktopActivation( + kind: DesktopActivationKind.notification, + action: action, + payload: const {'notificationScheduleId': 'schedule-42'}, + ).isValid, + isTrue, + ); + } + expect( + const DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'run', + payload: {'notificationScheduleId': 'schedule-42'}, + ).isValid, + isFalse, + ); + expect( + const DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'open', + payload: {'itemId': 'event-9'}, + ).isValid, + isFalse, + ); + expect( + const DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'open', + payload: { + 'notificationScheduleId': 'schedule-42', + 'command': 'arbitrary commands are not activation data', + }, + ).isValid, + isFalse, + ); + }); + + test('only notification body and Open require a visible window', () { + DesktopActivation notification(String action) => DesktopActivation( + kind: DesktopActivationKind.notification, + action: action, + payload: const {'notificationScheduleId': 'schedule-42'}, + ); + + expect(notification('default').requiresVisibleWindow, isTrue); + expect(notification('open').requiresVisibleWindow, isTrue); + expect(notification('snooze').requiresVisibleWindow, isFalse); + expect(notification('dismiss').requiresVisibleWindow, isFalse); + }); + }); +} diff --git a/test/platform/windows/windows_activation_service_test.dart b/test/platform/windows/windows_activation_service_test.dart new file mode 100644 index 00000000..657fadf9 --- /dev/null +++ b/test/platform/windows/windows_activation_service_test.dart @@ -0,0 +1,72 @@ +import 'package:busymax/src/platform/common/desktop_services.dart'; +import 'package:busymax/src/platform/windows/windows_activation_service.dart'; +import 'package:busymax/src/platform/windows/windows_window_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'notification forwarder sends one validated encoded activation', + () async { + const channel = MethodChannel(windowsDesktopChannelName); + MethodCall? received; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + received = call; + return true; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const activation = DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'snooze', + payload: { + 'notificationScheduleId': 'schedule-42', + 'itemKind': 'event', + 'accountId': 'account-7', + 'sourceId': 'calendar-3', + 'itemId': 'event-9', + }, + ); + + expect(await forwardWindowsActivationToPrimary(activation), isTrue); + expect(received?.method, 'forwardActivation'); + expect( + DesktopActivation.tryDecode(received?.arguments as String)?.toJson(), + activation.toJson(), + ); + }, + ); + + test( + 'notification forwarder rejects invalid activation before IPC', + () async { + const channel = MethodChannel(windowsDesktopChannelName); + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls += 1; + return true; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const activation = DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'open', + payload: { + 'notificationScheduleId': 'schedule-42', + 'command': 'not permitted', + }, + ); + + expect(await forwardWindowsActivationToPrimary(activation), isFalse); + expect(calls, 0); + }, + ); +} diff --git a/test/platform/windows/windows_assets_test.dart b/test/platform/windows/windows_assets_test.dart new file mode 100644 index 00000000..4b1da3b2 --- /dev/null +++ b/test/platform/windows/windows_assets_test.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as image; + +void main() { + test('Windows icons contain all committed resolutions', () { + for (final path in const [ + 'windows/runner/resources/app_icon.ico', + 'assets/windows/busymax_tray.ico', + 'assets/windows/busymax_tray_offline.ico', + ]) { + final decoder = image.IcoDecoder(); + final info = decoder.startDecode(File(path).readAsBytesSync()); + expect(info, isNotNull, reason: path); + expect(info!.numFrames, 9, reason: path); + expect( + { + for (var index = 0; index < info.numFrames; index++) + decoder.decodeFrame(index)!.width, + }, + {16, 20, 24, 32, 40, 48, 64, 128, 256}, + reason: path, + ); + } + }); + + test( + 'MSIX visual assets have every declared scale and transparent padding', + () { + const logicalSizes = { + 'StoreLogo': 50, + 'Square44x44Logo': 44, + 'Square150x150Logo': 150, + 'FileAssociation': 44, + }; + const scales = [100, 125, 150, 200, 400]; + for (final MapEntry(key: name, value: logicalSize) + in logicalSizes.entries) { + for (final scale in scales) { + final path = 'windows/runner/resources/msix/$name.scale-$scale.png'; + final asset = image.decodePng(File(path).readAsBytesSync()); + expect(asset, isNotNull, reason: path); + final expectedSize = (logicalSize * scale / 100).round(); + expect(asset!.width, expectedSize, reason: path); + expect(asset.height, expectedSize, reason: path); + expect(asset.getPixel(0, 0).a, 0, reason: '$path needs safe padding'); + } + } + }, + ); +} diff --git a/test/platform/windows/windows_event_reminders_test.dart b/test/platform/windows/windows_event_reminders_test.dart new file mode 100644 index 00000000..01150bf9 --- /dev/null +++ b/test/platform/windows/windows_event_reminders_test.dart @@ -0,0 +1,55 @@ +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_event_reminders.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'decodes multiple provider reminders without losing start-time zero', + () { + expect( + decodeWindowsEventReminderMinutes({ + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + {'method': 'popup', 'minutes': 0}, + {'method': 'popup', 'minutes': 30}, + ], + }), + [30, 0], + ); + expect( + decodeWindowsEventReminderMinutes({ + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 15, + }), + [15], + ); + }, + ); + + test('encodes all Google reminders and one Microsoft reminder', () { + expect( + encodeWindowsEventReminderPayload(BusyProvider.google, const [30, 5, 30]), + { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + {'method': 'popup', 'minutes': 5}, + ], + }, + ); + expect( + encodeWindowsEventReminderPayload(BusyProvider.microsoft, const [30, 5]), + {'isReminderOn': true, 'reminderMinutesBeforeStart': 30}, + ); + expect( + encodeWindowsEventReminderPayload(BusyProvider.microsoft, const []), + {'isReminderOn': false}, + ); + }); + + test('keeps custom values selectable and chooses an unused default', () { + expect(windowsEventReminderValuesFor(15), contains(15)); + expect(nextWindowsEventReminderMinute(const [0, 5, 10]), 30); + }); +} diff --git a/test/platform/windows/windows_guest_update_dialog_test.dart b/test/platform/windows/windows_guest_update_dialog_test.dart new file mode 100644 index 00000000..3626d55d --- /dev/null +++ b/test/platform/windows/windows_guest_update_dialog_test.dart @@ -0,0 +1,79 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_guest_update_dialog.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Google guest update dialog returns do-not-send', (tester) async { + CalendarGuestUpdatePolicy? result; + await tester.pumpWidget( + _Harness( + onOpen: (context) async { + result = await showWindowsGuestUpdateDialog( + context, + provider: BusyProvider.google, + action: WindowsGuestUpdateAction.save, + ); + }, + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Notify guests?'), findsOneWidget); + + await tester.tap(find.text('Don’t send')); + await tester.pumpAndSettle(); + expect(result, CalendarGuestUpdatePolicy.doNotSend); + }); + + testWidgets('Microsoft guest deletion cannot suppress cancellation', ( + tester, + ) async { + CalendarGuestUpdatePolicy? result; + await tester.pumpWidget( + _Harness( + onOpen: (context) async { + result = await showWindowsGuestUpdateDialog( + context, + provider: BusyProvider.microsoft, + action: WindowsGuestUpdateAction.delete, + ); + }, + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Delete meeting?'), findsOneWidget); + expect(find.text('Don’t send'), findsNothing); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(result, CalendarGuestUpdatePolicy.send); + }); +} + +class _Harness extends StatelessWidget { + const _Harness({required this.onOpen}); + + final Future Function(BuildContext context) onOpen; + + @override + Widget build(BuildContext context) { + return FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Center( + child: Button( + onPressed: () => onOpen(context), + child: const Text('Open'), + ), + ), + ), + ); + } +} diff --git a/test/platform/windows/windows_packaging_contract_test.dart b/test/platform/windows/windows_packaging_contract_test.dart new file mode 100644 index 00000000..ced3e12c --- /dev/null +++ b/test/platform/windows/windows_packaging_contract_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:busymax/src/platform/windows/windows_notification_backend.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; + +void main() { + final manifestSource = File( + 'tools/windows/AppxManifest.xml.template', + ).readAsStringSync(); + final manifest = XmlDocument.parse(manifestSource); + + Iterable elements(String localName) => manifest.descendants + .whereType() + .where((element) => element.name.local == localName); + + test('Store manifest stays x64-only and targets Windows 11 24H2', () { + final identity = elements('Identity').single; + final target = elements('TargetDeviceFamily').single; + final application = elements('Application').single; + + expect(identity.getAttribute('ProcessorArchitecture'), 'x64'); + expect(target.getAttribute('Name'), 'Windows.Desktop'); + expect(target.getAttribute('MinVersion'), '10.0.26100.0'); + expect(application.getAttribute('Id'), 'BusyMax'); + expect(application.getAttribute('Executable'), 'busymax.exe'); + expect( + application.getAttribute('EntryPoint'), + 'Windows.FullTrustApplication', + ); + }); + + test('Store manifest registers only required activation surfaces', () { + final categories = elements( + 'Extension', + ).map((element) => element.getAttribute('Category')).toSet(); + expect(categories, { + 'windows.fileTypeAssociation', + 'windows.protocol', + 'windows.startupTask', + 'windows.toastNotificationActivation', + 'windows.comServer', + }); + expect(elements('FileType').map((element) => element.innerText).toList(), [ + '.ics', + ]); + expect( + elements('Protocol').map((element) => element.getAttribute('Name')), + ['webcal'], + ); + + final startup = elements('StartupTask').single; + expect(startup.getAttribute('TaskId'), 'BusyMaxStartupTask'); + expect(startup.getAttribute('Enabled'), 'false'); + expect( + startup.parentElement!.getAttribute( + 'Parameters', + namespace: + 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10', + ), + '--start-minimized', + ); + + final toastClsid = elements( + 'ToastNotificationActivation', + ).single.getAttribute('ToastActivatorCLSID'); + final comClsid = elements('Class').single.getAttribute('Id'); + expect(toastClsid, '{$busyMaxToastActivatorClsid}'); + expect(comClsid, toastClsid); + }); + + test('Store manifest capability set remains minimal', () { + final capabilities = elements('Capabilities').single.childElements + .map((element) => element.getAttribute('Name')) + .toSet(); + expect(capabilities, {'internetClient', 'runFullTrust'}); + }); + + test('runner preserves native DPI and acknowledged per-user IPC', () { + final executableManifest = File( + 'windows/runner/runner.exe.manifest', + ).readAsStringSync(); + final runner = File( + 'windows/runner/single_instance.cpp', + ).readAsStringSync(); + + expect(executableManifest, contains('PerMonitorV2')); + expect( + executableManifest, + contains(' const [ConnectivityResult.wifi], + connectivityChanges: const Stream.empty(), + ); + addTearDown(monitor.dispose); + const account = AccountEntity( + id: 'account-1', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'owner@example.test', + authState: accountAuthStateSignedIn, + displayName: 'Personal', + ); + const source = CalendarSourceEntity( + id: 'calendar-1', + accountId: 'account-1', + provider: BusyProvider.google, + providerCalendarId: 'work@example.test', + summary: 'Work calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + dataOwner: 'owner@example.test', + authenticatedAccountEmail: 'owner@example.test', + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + networkConnectivityMonitorProvider.overrideWithValue(monitor), + ], + child: FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: SizedBox( + width: 360, + height: 720, + child: WindowsScheduleSourcePane( + selectedDate: DateTime(2026, 8, 31), + accounts: const [account], + calendarSources: const [source], + taskLists: const [], + visibleCalendarSourceIds: const {'calendar-1'}, + visibleTaskListKeys: const {}, + onDateSelected: (_) {}, + onCalendarVisibilityChanged: (_, _) {}, + onTaskListVisibilityChanged: (_, _) {}, + onSourcesChanged: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Work calendar'), findsOneWidget); + expect(find.byType(DropDownButton), findsNWidgets(2)); + + await tester.tap(find.byType(DropDownButton).last); + await tester.pumpAndSettle(); + + expect(find.text('Event reminders — On'), findsOneWidget); + expect(find.text('Calendar color'), findsOneWidget); + expect(find.text('Rename'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/platform/windows/windows_task_create_fields_test.dart b/test/platform/windows/windows_task_create_fields_test.dart new file mode 100644 index 00000000..89c0ab11 --- /dev/null +++ b/test/platform/windows/windows_task_create_fields_test.dart @@ -0,0 +1,79 @@ +import 'package:busymax/src/dav/ical/ical_task_alarm.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_task_create_fields.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('all-day DAV tasks omit timed provider fields and keep every alarm', () { + final fields = _fields( + capability: nextcloudTaskCollectionCapabilities, + provider: BusyProvider.nextcloud, + scheduledAllDay: true, + alarms: [ + IcalTaskAlarm.displayAbsolute(DateTime.utc(2026, 9, 1, 8)), + IcalTaskAlarm.displayAbsolute(DateTime.utc(2026, 9, 1, 9)), + ], + ); + + expect(fields, isNot(contains('microsoftDueDateTime'))); + expect(fields, isNot(contains('microsoftStartDateTime'))); + expect( + fields['taskAlarms'], + isA>().having((value) => value.length, 'length', 2), + ); + }); + + test('timed Microsoft tasks include zones and one provider reminder', () { + final fields = _fields( + capability: microsoftTaskCollectionCapabilities, + provider: BusyProvider.microsoft, + scheduledAllDay: false, + reminder: DateTime(2026, 9, 1, 8, 45), + ); + + expect(fields['microsoftDueTimeZone'], 'America/Vancouver'); + expect(fields['microsoftStartTimeZone'], 'America/Vancouver'); + expect(fields['microsoftIsReminderOn'], isTrue); + expect(fields, isNot(contains('taskAlarms'))); + }); + + test('an empty Microsoft reminder emits the explicit disabled state', () { + final fields = _fields( + capability: microsoftTaskCollectionCapabilities, + provider: BusyProvider.microsoft, + scheduledAllDay: true, + ); + + expect(fields['microsoftIsReminderOn'], isFalse); + }); +} + +Map _fields({ + required TaskCollectionCapabilities capability, + required BusyProvider provider, + required bool scheduledAllDay, + DateTime? reminder, + List alarms = const [], +}) => buildWindowsTaskCreateFields( + capability: capability, + provider: provider, + due: DateTime(2026, 9, 1, 10), + start: DateTime(2026, 9, 1, 9), + reminder: reminder, + alarms: alarms, + scheduledAllDay: scheduledAllDay, + timeZone: 'America/Vancouver', + recurrence: const RecurrenceRule.none(), + importance: 'normal', + status: '', + priority: 0, + progress: 0, + location: '', + taskUrl: '', + classification: 'PUBLIC', + pinned: false, + hideSubtasks: false, + hideCompletedSubtasks: false, +); diff --git a/test/platform/windows/windows_task_details_dialog_test.dart b/test/platform/windows/windows_task_details_dialog_test.dart new file mode 100644 index 00000000..9610a86a --- /dev/null +++ b/test/platform/windows/windows_task_details_dialog_test.dart @@ -0,0 +1,254 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/ui/windows/windows_task_details_dialog.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Fluent task details exposes hierarchy and list workflows', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account-1', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'owner@example.test', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + for (final (id, title) in [('list-1', 'Inbox'), ('list-2', 'Work')]) { + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'account-1', + id: id, + title: title, + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + } + final tasks = _TestTasksRepository(database); + final lists = TaskListsRepository( + database: database, + accountId: 'account-1', + nowUtc: () => DateTime.utc(2026, 8, 31), + ); + Object? openError; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + accountsRepositoryProvider.overrideWithValue( + _TestAccountsRepository(database), + ), + localTimeZoneProvider.overrideWithValue('Etc/UTC'), + tasksRepositoryForAccountProvider.overrideWith( + (ref, accountId) => tasks, + ), + taskListsRepositoryForAccountProvider.overrideWith( + (ref, accountId) => lists, + ), + ], + child: FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Consumer( + builder: (context, ref, _) => Button( + onPressed: () async { + try { + await showWindowsTaskDetailsDialog( + context, + ref, + const TaskScheduleItem( + id: 'parent', + accountId: 'account-1', + provider: BusyProvider.google, + sourceId: 'list-1', + title: 'Parent', + completed: false, + allDay: true, + ), + ); + } on Object catch (error) { + openError = error; + } + }, + child: const Text('Open details'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + expect(openError, isNull); + expect(find.byType(ContentDialog), findsOneWidget); + expect(find.text('Subtasks'), findsOneWidget); + expect(find.text('Existing child'), findsOneWidget); + expect(find.text('Move to top'), findsOneWidget); + expect(find.byType(ComboBox), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextBox, 'Create subtask'), + 'New child', + ); + await tester.pump(); + final createButton = find.widgetWithText(Button, 'Create subtask'); + await tester.ensureVisible(createButton); + await tester.pumpAndSettle(); + await tester.tap(createButton); + await tester.pumpAndSettle(); + + expect(find.text('New child'), findsOneWidget); + expect(tasks.createdSubtaskTitles, ['New child']); + final existingChild = find.text('Existing child'); + await tester.ensureVisible(existingChild); + await tester.tap(existingChild); + await tester.pumpAndSettle(); + expect(tasks.watchedTaskIds.last, 'child'); + + final titleField = find.byWidgetPredicate( + (widget) => + widget is TextBox && widget.controller?.text == 'Existing child', + ); + await tester.enterText(titleField, 'Edited child'); + await tester.pump(); + final parentRow = find.byWidgetPredicate( + (widget) => + widget is ListTile && + widget.title is Text && + (widget.title! as Text).data == 'Parent', + ); + await tester.tap(parentRow); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsOneWidget); + expect(tasks.watchedTaskIds.last, 'child'); + + await tester.tap(find.widgetWithText(Button, 'Cancel').last); + await tester.pumpAndSettle(); + expect(find.text('Discard changes?'), findsNothing); + await tester.tap(parentRow); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Discard').last); + await tester.pumpAndSettle(); + expect(tasks.watchedTaskIds.last, 'parent'); + expect(tester.takeException(), isNull); + }); +} + +class _TestAccountsRepository extends AccountsRepository { + _TestAccountsRepository(AppDatabase database) : super(database: database); + + @override + Stream> watchAccounts() => Stream.value(const [ + AccountEntity( + id: 'account-1', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'owner@example.test', + authState: accountAuthStateSignedIn, + ), + ]); +} + +class _TestTasksRepository extends TasksRepository { + _TestTasksRepository(AppDatabase database) + : super(database: database, accountId: 'account-1'); + + final createdSubtaskTitles = []; + final watchedTaskIds = []; + final _parent = const TaskEntity( + accountId: 'account-1', + taskListId: 'list-1', + id: 'parent', + title: 'Parent', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: _now, + ); + final _children = [ + TaskSubtaskEntity.task( + const TaskEntity( + accountId: 'account-1', + taskListId: 'list-1', + id: 'child', + title: 'Existing child', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: _now, + ), + hasChildren: false, + ), + ]; + + @override + Stream watchTask(String taskListId, String taskId) { + watchedTaskIds.add(taskId); + if (taskId == 'parent') return Stream.value(_parent); + return Stream.value( + _children.where((subtask) => subtask.id == taskId).firstOrNull?.task, + ); + } + + @override + Stream watchTaskHierarchy( + String taskListId, + String taskId, + ) => Stream.value( + taskId == 'parent' + ? TaskHierarchySnapshot(parent: null, subtasks: List.of(_children)) + : TaskHierarchySnapshot(parent: _parent, subtasks: const []), + ); + + @override + Future createSubtask({ + required String taskListId, + required String parentTaskId, + required String title, + }) async { + createdSubtaskTitles.add(title); + _children.add( + TaskSubtaskEntity.task( + TaskEntity( + accountId: 'account-1', + taskListId: taskListId, + id: 'new-child', + title: title, + localDirty: true, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: _now, + ), + hasChildren: false, + ), + ); + } +} + +const _now = '2026-08-31T00:00:00.000Z'; diff --git a/test/platform/windows/windows_time_zone_source_test.dart b/test/platform/windows/windows_time_zone_source_test.dart new file mode 100644 index 00000000..0c9bc713 --- /dev/null +++ b/test/platform/windows/windows_time_zone_source_test.dart @@ -0,0 +1,69 @@ +import 'package:busymax/src/platform/windows/windows_time_zone_source.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:timezone/data/latest_all.dart' as time_zone_data; +import 'package:timezone/timezone.dart' as time_zone; + +void main() { + setUpAll(time_zone_data.initializeTimeZones); + + test('accepts IANA and normalizes UTC', () async { + final iana = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'Asia/Kathmandu'), + ); + final utc = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'UTC'), + ); + + expect(await iana.currentIanaTimeZone(), 'Asia/Kathmandu'); + expect(await utc.currentIanaTimeZone(), 'Etc/UTC'); + }); + + test('maps Windows identifiers and reports unknown values', () async { + final mapped = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'India Standard Time'), + ); + final unknown = WindowsLocalTimeZoneSource( + load: () async => TimezoneInfo(identifier: 'Etc/Unknown'), + ); + + expect(await mapped.currentIanaTimeZone(), 'Asia/Kolkata'); + expect(await unknown.currentIanaTimeZone(), 'Etc/UTC'); + expect(unknown.lastUnrecognizedIdentifier, 'Etc/Unknown'); + expect( + unknown.diagnostic, + 'local-time-zone/unrecognized=Etc/Unknown; fallback=Etc/UTC', + ); + }); + + test('mapped zones retain DST, skipped, ambiguous, and offset behavior', () { + final pacific = time_zone.getLocation( + windowsToIanaTimeZones['Pacific Standard Time']!, + ); + final newfoundland = time_zone.getLocation( + windowsToIanaTimeZones['Newfoundland Standard Time']!, + ); + final kathmandu = time_zone.getLocation( + windowsToIanaTimeZones['Nepal Standard Time']!, + ); + + final winter = time_zone.TZDateTime(pacific, 2026, 1, 15, 12); + final summer = time_zone.TZDateTime(pacific, 2026, 7, 15, 12); + expect(winter.timeZoneOffset, const Duration(hours: -8)); + expect(summer.timeZoneOffset, const Duration(hours: -7)); + + final skipped = time_zone.TZDateTime(pacific, 2026, 3, 8, 2, 30); + expect(skipped.hour, 3); + final ambiguous = time_zone.TZDateTime(pacific, 2026, 11, 1, 1, 30); + expect(ambiguous.hour, 1); + + expect( + time_zone.TZDateTime(newfoundland, 2026, 1, 15).timeZoneOffset, + const Duration(hours: -3, minutes: -30), + ); + expect( + time_zone.TZDateTime(kathmandu, 2026, 1, 15).timeZoneOffset, + const Duration(hours: 5, minutes: 45), + ); + }); +} diff --git a/third_party/flutter_timezone/LICENSE b/third_party/flutter_timezone/LICENSE new file mode 100644 index 00000000..8dada3ed --- /dev/null +++ b/third_party/flutter_timezone/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/flutter_timezone/README.busymax.md b/third_party/flutter_timezone/README.busymax.md new file mode 100644 index 00000000..355622db --- /dev/null +++ b/third_party/flutter_timezone/README.busymax.md @@ -0,0 +1,6 @@ +# BusyMax flutter_timezone packaging + +This directory contains the Dart API and Windows implementation from upstream +`flutter_timezone` 5.1.0 under its original license. Other platform plugin +registrations are intentionally omitted because BusyMax Linux retains its +existing `/etc/timezone`, `/etc/localtime`, and GWeather behavior. diff --git a/third_party/flutter_timezone/lib/flutter_timezone.dart b/third_party/flutter_timezone/lib/flutter_timezone.dart new file mode 100644 index 00000000..63cfb823 --- /dev/null +++ b/third_party/flutter_timezone/lib/flutter_timezone.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_timezone/timezone_info.dart'; + +export 'package:flutter_timezone/timezone_info.dart'; + +/// +/// Class for getting the native timezone. +/// +class FlutterTimezone { + static const _channel = MethodChannel('flutter_timezone'); + + /// + /// Returns local timezone from the native layer. + /// + /// On supported platforms (see README), you can optionally provide a locale code (e.g. "en_US", "de") to get the + /// localized name of the timezone in that locale, if provided by the underlying platform. + static Future getLocalTimezone([String? locale]) async { + final localTimezone = + await _channel.invokeMethod('getLocalTimezone', locale); + if (localTimezone == null) { + throw ArgumentError('Invalid return from platform getLocalTimezone()'); + } + if (localTimezone is String) { + return TimezoneInfo(identifier: localTimezone); + } + return TimezoneInfo.fromJson(localTimezone); + } + + /// + /// Gets the list of available timezones from the native layer. + /// + /// On supported platforms (see README), you can optionally provide a locale code (e.g. "en_US", "de") to get the + /// localized names of the timezones in that locale, if provided by the underlying platform. + static Future> getAvailableTimezones([ + String? locale, + ]) async { + final availableTimezones = + await _channel.invokeListMethod('getAvailableTimezones', locale); + if (availableTimezones == null) { + throw ArgumentError( + 'Invalid return from platform getAvailableTimezones()', + ); + } + return availableTimezones.map((timezone) { + if (timezone is String) { + return TimezoneInfo(identifier: timezone); + } + return TimezoneInfo.fromJson(timezone); + }).toList(); + } +} diff --git a/third_party/flutter_timezone/lib/timezone_info.dart b/third_party/flutter_timezone/lib/timezone_info.dart new file mode 100644 index 00000000..4981f9a8 --- /dev/null +++ b/third_party/flutter_timezone/lib/timezone_info.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +@immutable +// Upstream 5.1.0 uses this mixin; keep its public equality behavior intact. +// ignore: deprecated_member_use +class TimezoneInfo with EquatableMixin { + TimezoneInfo({required this.identifier, this.localizedName}); + + factory TimezoneInfo.fromJson(Map json) { + final localizedName = json['localizedName']; + return TimezoneInfo( + identifier: json['identifier'] as String, + localizedName: localizedName == null + ? null + : (name: localizedName, locale: json['locale']), + ); + } + + /// The standardized IANA identifier for this timezone, e.g. "America/Los_Angeles", as reported by the underlying + /// platform. + final String identifier; + + /// The localized name for this timezone, e.g. "Central European Standard Time", as reported by the + /// underlying platform. + /// + /// This is available if: + /// - flutter_timezone supports localized names on the current platform (see README for details). + /// - The requested locale exists on the current platform. + /// - The current platform provides a localized name for the current timezone in the requested locale. + final ({String name, String locale})? localizedName; + + @override + List get props => [identifier, localizedName]; +} diff --git a/third_party/flutter_timezone/pubspec.yaml b/third_party/flutter_timezone/pubspec.yaml new file mode 100644 index 00000000..3409b085 --- /dev/null +++ b/third_party/flutter_timezone/pubspec.yaml @@ -0,0 +1,24 @@ +name: flutter_timezone +description: Windows-only BusyMax packaging of the upstream timezone plugin. +homepage: https://github.com/tjarvstrand/flutter_timezone +version: 5.1.0 +publish_to: none + +platforms: + windows: + +environment: + sdk: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + equatable: ^2.0.7 + flutter: + sdk: flutter + meta: ^1.16.0 + +flutter: + plugin: + platforms: + windows: + pluginClass: FlutterTimezonePluginCApi diff --git a/third_party/flutter_timezone/windows/CMakeLists.txt b/third_party/flutter_timezone/windows/CMakeLists.txt new file mode 100644 index 00000000..0c30f7e8 --- /dev/null +++ b/third_party/flutter_timezone/windows/CMakeLists.txt @@ -0,0 +1,102 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "flutter_timezone") +project(${PROJECT_NAME} LANGUAGES CXX) + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "flutter_timezone_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "flutter_timezone_plugin.cpp" + "flutter_timezone_plugin.h" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + "include/flutter_timezone/flutter_timezone_plugin_c_api.h" + "flutter_timezone_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) +target_link_libraries(${PLUGIN_NAME} PRIVATE icu) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(flutter_timezone_bundled_libraries + "" + PARENT_SCOPE +) + +# === Tests === +# These unit tests can be run from a terminal after building the example, or +# from Visual Studio after opening the generated solution file. + +# Only enable test builds when building the example (which sets this variable) +# so that plugin clients aren't building the tests. +if (${include_${PROJECT_NAME}_tests}) +set(TEST_RUNNER "${PROJECT_NAME}_test") +enable_testing() + +# Add the Google Test dependency. +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip +) +# Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +# Disable install commands for gtest so it doesn't end up in the bundle. +set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) +FetchContent_MakeAvailable(googletest) + +# The plugin's C API is not very useful for unit testing, so build the sources +# directly into the test binary rather than using the DLL. +add_executable(${TEST_RUNNER} + test/flutter_timezone_plugin_test.cpp + ${PLUGIN_SOURCES} +) +apply_standard_settings(${TEST_RUNNER}) +target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) +target_link_libraries(${TEST_RUNNER} PRIVATE icu) +target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) +# flutter_wrapper_plugin has link dependencies on the Flutter DLL. +add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${FLUTTER_LIBRARY}" $ +) + +# Enable automatic test discovery. +include(GoogleTest) +gtest_discover_tests(${TEST_RUNNER}) +endif() diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp b/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp new file mode 100644 index 00000000..7031e3e3 --- /dev/null +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp @@ -0,0 +1,146 @@ +#include "flutter_timezone_plugin.h" + +// This must be included before many other Windows headers. +#include + +// By default, the icu.h header uses char16_t to represent UTF-16 code units. Windows, however, uses +// wchar_t, so we define UCHAR_TYPE to get the type we want. +#define UCHAR_TYPE wchar_t +#include + +#include +#include +#include + +#include +#include + +namespace flutter_timezone { + + // static + void FlutterTimezonePlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "flutter_timezone", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); + } + + FlutterTimezonePlugin::FlutterTimezonePlugin() {} + + FlutterTimezonePlugin::~FlutterTimezonePlugin() {} + + void FlutterTimezonePlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + + if (method_call.method_name().compare(kGetLocalTimezone) == 0) { + GetLocalTimezone(result); + } + else if (method_call.method_name().compare(kGetAvailableTimezones) == 0) { + GetAvailableTimezones(result); + } + else { + result->NotImplemented(); + } + } + + ///

+ /// Gets the local time zone as an IANA identifier. + /// + /// + /// If the local windows time zone cannot be matched with a valid IANA one, "Etc/Unknown" is + /// returned. + /// + void FlutterTimezonePlugin::GetLocalTimezone( + std::unique_ptr>& result) { + // This entire function body could be replaced with a call to `ucal_getHostTimeZone`. + // However, that function as only added in ICU 65, which is only available on Windows 11. + // Once we drop support for older Windows versions, this should be replaced. + + // Get the current Windows time zone + DYNAMIC_TIME_ZONE_INFORMATION tzInfo; + GetDynamicTimeZoneInformation(&tzInfo); + + // Get the user's region + wchar_t geoBuffer[4]; + GetUserDefaultGeoName(geoBuffer, ARRAYSIZE(geoBuffer)); + + // Convert the `geoBuffer` to a single byte string, which is fine since the contents are + // supposed to be basic ASCII. + std::wstring geoW(geoBuffer); +#pragma warning(suppress : 4244) + std::string geo(geoW.begin(), geoW.end()); + + // Map the (Windows Time Zone, Region) pair to an IANA time zone ID + UErrorCode status = U_ZERO_ERROR; + UChar buffer[128]; + auto length = ucal_getTimeZoneIDForWindowsID( + tzInfo.TimeZoneKeyName, + -1, + geo.c_str(), + buffer, + ARRAYSIZE(buffer), + &status); + + if (length == 0) { + // No mapping found between Windows and IANA time zone ids + result->Success(flutter::EncodableValue(std::string(UCAL_UNKNOWN_ZONE_ID))); + } + + std::wstring tz(buffer); +#pragma warning(suppress : 4244) + result->Success(flutter::EncodableValue(std::string(tz.begin(), tz.end()))); + } + + /// + /// Gets all the available canonical IANA time zones. + /// + /// A vector of timezones as EncodableValue's. + void FlutterTimezonePlugin::GetAvailableTimezones( + std::unique_ptr>& result) { + UErrorCode status = U_ZERO_ERROR; + + // open an enumeration for any kind of available timezone without country/offset filtering. + auto tzEnumeration = ucal_openTimeZoneIDEnumeration( + USystemTimeZoneType::UCAL_ZONE_TYPE_CANONICAL, + nullptr, + nullptr, + &status); + + if (U_FAILURE(status)) { + result->Error(std::string(u_errorName(status)), "Could not fetch available timezones."); + } + + auto count = uenum_count(tzEnumeration, &status); + + std::vector timezones{}; + timezones.reserve(count); + + for (auto i = 0; i < count; i++) { + auto buffer = uenum_next(tzEnumeration, nullptr, &status); + + if (U_FAILURE(status)) { + // Failed to read the current value, try the next one. + continue; + } + + timezones.push_back(flutter::EncodableValue(std::string(buffer))); + } + + // close the enumeration + uenum_close(tzEnumeration); + + result->Success(flutter::EncodableList(timezones)); + } + +} // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin.h b/third_party/flutter_timezone/windows/flutter_timezone_plugin.h new file mode 100644 index 00000000..8bcd32a4 --- /dev/null +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin.h @@ -0,0 +1,37 @@ +#ifndef FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_H_ +#define FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_H_ + +#include +#include + +#include + +namespace flutter_timezone { + constexpr auto kGetLocalTimezone = "getLocalTimezone"; + constexpr auto kGetAvailableTimezones = "getAvailableTimezones"; + + class FlutterTimezonePlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + FlutterTimezonePlugin(); + + virtual ~FlutterTimezonePlugin(); + + // Disallow copy and assign. + FlutterTimezonePlugin(const FlutterTimezonePlugin&) = delete; + FlutterTimezonePlugin& operator=(const FlutterTimezonePlugin&) = delete; + + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + private: + void GetLocalTimezone(std::unique_ptr>& result); + void GetAvailableTimezones(std::unique_ptr>& result); + }; + +} // namespace flutter_timezone + +#endif // FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_H_ diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin_c_api.cpp b/third_party/flutter_timezone/windows/flutter_timezone_plugin_c_api.cpp new file mode 100644 index 00000000..1bad969e --- /dev/null +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/flutter_timezone/flutter_timezone_plugin_c_api.h" + +#include + +#include "flutter_timezone_plugin.h" + +void FlutterTimezonePluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + flutter_timezone::FlutterTimezonePlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/third_party/flutter_timezone/windows/include/flutter_timezone/flutter_timezone_plugin_c_api.h b/third_party/flutter_timezone/windows/include/flutter_timezone/flutter_timezone_plugin_c_api.h new file mode 100644 index 00000000..3067f40b --- /dev/null +++ b/third_party/flutter_timezone/windows/include/flutter_timezone/flutter_timezone_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void FlutterTimezonePluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_FLUTTER_TIMEZONE_PLUGIN_C_API_H_ diff --git a/third_party/tray_manager/LICENSE b/third_party/tray_manager/LICENSE new file mode 100644 index 00000000..46e9d56a --- /dev/null +++ b/third_party/tray_manager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-present LiJianying + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/tray_manager/README.busymax.md b/third_party/tray_manager/README.busymax.md new file mode 100644 index 00000000..cc4eeea9 --- /dev/null +++ b/third_party/tray_manager/README.busymax.md @@ -0,0 +1,7 @@ +# BusyMax tray_manager packaging + +This directory contains the API and Windows implementation from upstream +`tray_manager` 0.5.3 under its original license. The Linux and macOS plugin +registrations are intentionally omitted: BusyMax Linux retains its existing +DBus/XDG tray implementation, while this package is used only by the Windows +composition. diff --git a/third_party/tray_manager/lib/src/helpers/sandbox.dart b/third_party/tray_manager/lib/src/helpers/sandbox.dart new file mode 100644 index 00000000..602c42d5 --- /dev/null +++ b/third_party/tray_manager/lib/src/helpers/sandbox.dart @@ -0,0 +1,9 @@ +import 'dart:io'; + +/// Returns `true` if the app is running in a sandbox, eg. Flatpak, Snap, Docker, Podman. +bool runningInSandbox() { + return Platform.environment.containsKey('FLATPAK_ID') || + Platform.environment.containsKey('SNAP') || + (Platform.environment['container']?.isNotEmpty == true) || + FileSystemEntity.isFileSync('/.dockerenv'); +} diff --git a/third_party/tray_manager/lib/src/tray_listener.dart b/third_party/tray_manager/lib/src/tray_listener.dart new file mode 100644 index 00000000..add190f0 --- /dev/null +++ b/third_party/tray_manager/lib/src/tray_listener.dart @@ -0,0 +1,15 @@ +import 'package:menu_base/menu_base.dart'; + +abstract mixin class TrayListener { + /// Emitted when the mouse clicks the tray icon. + void onTrayIconMouseDown() {} + + /// Emitted when the mouse is released from clicking the tray icon. + void onTrayIconMouseUp() {} + + void onTrayIconRightMouseDown() {} + + void onTrayIconRightMouseUp() {} + + void onTrayMenuItemClick(MenuItem menuItem) {} +} diff --git a/third_party/tray_manager/lib/src/tray_manager.dart b/third_party/tray_manager/lib/src/tray_manager.dart new file mode 100644 index 00000000..f23b61c6 --- /dev/null +++ b/third_party/tray_manager/lib/src/tray_manager.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:menu_base/menu_base.dart'; +import 'package:path/path.dart' as path; +import 'package:shortid/shortid.dart'; +import 'package:tray_manager/src/helpers/sandbox.dart'; +import 'package:tray_manager/src/tray_listener.dart'; + +const kEventOnTrayIconMouseDown = 'onTrayIconMouseDown'; +const kEventOnTrayIconMouseUp = 'onTrayIconMouseUp'; +const kEventOnTrayIconRightMouseDown = 'onTrayIconRightMouseDown'; +const kEventOnTrayIconRightMouseUp = 'onTrayIconRightMouseUp'; +const kEventOnTrayMenuItemClick = 'onTrayMenuItemClick'; + +enum TrayIconPosition { left, right } + +class TrayManager { + TrayManager._() { + _channel.setMethodCallHandler(_methodCallHandler); + } + + /// The shared instance of [TrayManager]. + static final TrayManager instance = TrayManager._(); + + final MethodChannel _channel = const MethodChannel('tray_manager'); + + final ObserverList _listeners = ObserverList(); + + double get _devicePixelRatio { + final flutterView = WidgetsBinding.instance.platformDispatcher.views.single; + return MediaQueryData.fromView(flutterView).devicePixelRatio; + } + + Menu? _menu; + + Future _methodCallHandler(MethodCall call) async { + for (final TrayListener listener in _listeners) { + switch (call.method) { + case kEventOnTrayIconMouseDown: + listener.onTrayIconMouseDown(); + break; + case kEventOnTrayIconMouseUp: + listener.onTrayIconMouseUp(); + break; + case kEventOnTrayIconRightMouseDown: + listener.onTrayIconRightMouseDown(); + break; + case kEventOnTrayIconRightMouseUp: + listener.onTrayIconRightMouseUp(); + break; + case kEventOnTrayMenuItemClick: + int id = call.arguments['id']; + MenuItem? menuItem = _menu?.getMenuItemById(id); + if (menuItem != null) { + bool? oldChecked = menuItem.checked; + if (menuItem.onClick != null) { + menuItem.onClick?.call(menuItem); + } + listener.onTrayMenuItemClick(menuItem); + + bool? newChecked = menuItem.checked; + if (oldChecked != newChecked) { + await setContextMenu(_menu!); + } + } + break; + } + } + } + + /// Whether any listeners are currently registered. + bool get hasListeners { + return _listeners.isNotEmpty; + } + + /// Register a closure to be called when the tray events. + void addListener(TrayListener listener) { + _listeners.add(listener); + } + + /// Remove a previously registered closure from the list of closures that are + /// notified when the tray events. + void removeListener(TrayListener listener) { + _listeners.remove(listener); + } + + // Destroys the tray icon immediately. + Future destroy() async { + await _channel.invokeMethod('destroy'); + } + + /// Sets the image associated with this tray icon. + /// + /// [iconPath] is the path to the image file. + /// + /// However, if the app is running in a sandbox like Flatpak or Snap, + /// [iconPath] should be the name of the icon as specified in the app's + /// manifest file, without the path or file extension. For example, if the + /// icon is specified as `org.example.app` in the Flatpak manifest file, then + /// the icon should be passed as `org.example.app`. + Future setIcon( + String iconPath, { + bool isTemplate = false, // macOS only + TrayIconPosition iconPosition = TrayIconPosition.left, // macOS only + int iconSize = 18, // macOS only + }) async { + final Map arguments = { + 'id': shortid.generate(), + 'iconPath': path.joinAll([ + path.dirname(Platform.resolvedExecutable), + 'data/flutter_assets', + iconPath, + ]), + 'isTemplate': isTemplate, + 'iconPosition': iconPosition.name, + 'iconSize': iconSize, + }; + + switch (defaultTargetPlatform) { + case TargetPlatform.linux: + if (runningInSandbox()) { + // Pass the icon name as specified if running in a sandbox. + // + // This is required because when running in a sandbox, paths are not + // the same as seen by the app and the host system. + arguments['iconPath'] = iconPath; + } + break; + case TargetPlatform.macOS: + // Add the icon as base64 string + ByteData imageData = await rootBundle.load(iconPath); + String base64Icon = base64Encode(imageData.buffer.asUint8List()); + arguments['base64Icon'] = base64Icon; + break; + default: + break; + } + + await _channel.invokeMethod('setIcon', arguments); + } + + /// Sets the icon position of the tray icon. + /// + /// @platforms macos + Future setIconPosition(TrayIconPosition trayIconPosition) async { + final arguments = { + 'iconPosition': trayIconPosition.name, + }; + await _channel.invokeMethod('setIconPosition', arguments); + } + + /// Sets the hover text for this tray icon. + /// + /// Must be called after the icon is set. + /// ```dart + /// await trayManager.setIcon(...); + /// await trayManager.setToolTip(...); + /// ``` + Future setToolTip(String toolTip) async { + final Map arguments = { + 'toolTip': toolTip, + }; + await _channel.invokeMethod('setToolTip', arguments); + } + + /// Sets the title for this tray icon. + Future setTitle(String title) async { + final Map arguments = { + 'title': title, + }; + await _channel.invokeMethod('setTitle', arguments); + } + + /// Sets the context menu for this icon. + Future setContextMenu(Menu menu) async { + _menu = menu; + final Map arguments = { + 'menu': menu.toJson(), + }; + await _channel.invokeMethod('setContextMenu', arguments); + } + + /// Pops up the context menu of the tray icon. + /// + /// [bringAppToFront] If true, the app will be brought to the front when the + /// context menu is shown. Only works on Windows. + Future popUpContextMenu({ + @Deprecated( + 'This parameter is only supported on Windows and will be removed in the future.', + ) + bool bringAppToFront = false, + }) async { + final Map arguments = { + 'bringAppToFront': bringAppToFront, + }; + await _channel.invokeMethod('popUpContextMenu', arguments); + } + + /// The bounds of this tray icon. + Future getBounds() async { + final Map arguments = { + 'devicePixelRatio': _devicePixelRatio, + }; + final Map? resultData = await _channel.invokeMethod( + 'getBounds', + arguments, + ); + if (resultData == null) { + return null; + } + return Rect.fromLTWH( + resultData['x'], + resultData['y'], + resultData['width'], + resultData['height'], + ); + } +} + +final trayManager = TrayManager.instance; diff --git a/third_party/tray_manager/lib/tray_manager.dart b/third_party/tray_manager/lib/tray_manager.dart new file mode 100644 index 00000000..ae4fd5ab --- /dev/null +++ b/third_party/tray_manager/lib/tray_manager.dart @@ -0,0 +1,4 @@ +export 'package:menu_base/menu_base.dart'; + +export 'src/tray_listener.dart'; +export 'src/tray_manager.dart'; diff --git a/third_party/tray_manager/pubspec.yaml b/third_party/tray_manager/pubspec.yaml new file mode 100644 index 00000000..49c1877a --- /dev/null +++ b/third_party/tray_manager/pubspec.yaml @@ -0,0 +1,25 @@ +name: tray_manager +description: Windows-only BusyMax packaging of the upstream tray_manager API. +homepage: https://github.com/leanflutter/tray_manager +version: 0.5.3 +publish_to: none + +platforms: + windows: + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + menu_base: ^0.1.0 + path: ^1.8.0 + shortid: ^0.1.2 + +flutter: + plugin: + platforms: + windows: + pluginClass: TrayManagerPlugin diff --git a/third_party/tray_manager/windows/CMakeLists.txt b/third_party/tray_manager/windows/CMakeLists.txt new file mode 100644 index 00000000..0ba71115 --- /dev/null +++ b/third_party/tray_manager/windows/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.15) +set(PROJECT_NAME "tray_manager") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "tray_manager_plugin") + +add_library(${PLUGIN_NAME} SHARED + "tray_manager_plugin.cpp" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_compile_definitions(${PLUGIN_NAME} PRIVATE _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin +set(tray_manager_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/third_party/tray_manager/windows/include/tray_manager/tray_manager_plugin.h b/third_party/tray_manager/windows/include/tray_manager/tray_manager_plugin.h new file mode 100644 index 00000000..036416aa --- /dev/null +++ b/third_party/tray_manager/windows/include/tray_manager/tray_manager_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_TRAY_MANAGER_PLUGIN_H_ +#define FLUTTER_PLUGIN_TRAY_MANAGER_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void TrayManagerPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_TRAY_MANAGER_PLUGIN_H_ diff --git a/third_party/tray_manager/windows/tray_manager_plugin.cpp b/third_party/tray_manager/windows/tray_manager_plugin.cpp new file mode 100644 index 00000000..59e98d69 --- /dev/null +++ b/third_party/tray_manager/windows/tray_manager_plugin.cpp @@ -0,0 +1,426 @@ +#include "include/tray_manager/tray_manager_plugin.h" + +// This must be included before many other Windows headers. +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#define WM_MYMESSAGE (WM_USER + 1) + +namespace { + +const flutter::EncodableValue* ValueOrNull(const flutter::EncodableMap& map, + const char* key) { + auto it = map.find(flutter::EncodableValue(key)); + if (it == map.end()) { + return nullptr; + } + return &(it->second); +} +std::unique_ptr< + flutter::MethodChannel, + std::default_delete>> + channel = nullptr; + +class TrayManagerPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + TrayManagerPlugin(flutter::PluginRegistrarWindows* registrar); + + virtual ~TrayManagerPlugin(); + + private: + std::wstring_convert> g_converter; + + flutter::PluginRegistrarWindows* registrar; + NOTIFYICONDATA nid; + NOTIFYICONIDENTIFIER niif; + // do create pop-up menu only once. + HMENU hMenu = CreatePopupMenu(); + bool tray_icon_setted = false; + UINT windows_taskbar_created_message_id = 0; + + // The ID of the WindowProc delegate registration. + int window_proc_id = -1; + + void TrayManagerPlugin::_CreateMenu(HMENU menu, flutter::EncodableMap args); + void TrayManagerPlugin::_ApplyIcon(); + + // Called for top-level WindowProc delegation. + std::optional TrayManagerPlugin::HandleWindowProc(HWND hwnd, + UINT message, + WPARAM wparam, + LPARAM lparam); + HWND TrayManagerPlugin::GetMainWindow(); + void TrayManagerPlugin::Destroy( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void TrayManagerPlugin::SetIcon( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void TrayManagerPlugin::SetToolTip( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void TrayManagerPlugin::SetContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void TrayManagerPlugin::PopUpContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + void TrayManagerPlugin::GetBounds( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); +}; + +static bool plugin_already_registered = false; + +// static +void TrayManagerPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + if (plugin_already_registered) { + // Skip registration in subwindow + return; + } + + plugin_already_registered = true; + + channel = std::make_unique>( + registrar->messenger(), "tray_manager", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(registrar); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); +} + +TrayManagerPlugin::TrayManagerPlugin(flutter::PluginRegistrarWindows* registrar) + : registrar(registrar) { + window_proc_id = registrar->RegisterTopLevelWindowProcDelegate( + [this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + return HandleWindowProc(hwnd, message, wparam, lparam); + }); + windows_taskbar_created_message_id = RegisterWindowMessage(L"TaskbarCreated"); +} + +TrayManagerPlugin::~TrayManagerPlugin() { + registrar->UnregisterTopLevelWindowProcDelegate(window_proc_id); +} + +void TrayManagerPlugin::_CreateMenu(HMENU menu, flutter::EncodableMap args) { + flutter::EncodableList items = std::get( + args.at(flutter::EncodableValue("items"))); + + int count = GetMenuItemCount(menu); + for (int i = 0; i < count; i++) { + // always remove at 0 because they shift every time + RemoveMenu(menu, 0, MF_BYPOSITION); + } + + for (flutter::EncodableValue item_value : items) { + flutter::EncodableMap item_map = + std::get(item_value); + int id = std::get(item_map.at(flutter::EncodableValue("id"))); + std::string type = + std::get(item_map.at(flutter::EncodableValue("type"))); + std::string label = + std::get(item_map.at(flutter::EncodableValue("label"))); + auto* checked = std::get_if(ValueOrNull(item_map, "checked")); + bool disabled = + std::get(item_map.at(flutter::EncodableValue("disabled"))); + + UINT_PTR item_id = id; + UINT uFlags = MF_STRING; + + if (disabled) { + uFlags |= MF_GRAYED; + } + + if (type.compare("separator") == 0) { + AppendMenuW(menu, MF_SEPARATOR, item_id, NULL); + } else { + if (type.compare("checkbox") == 0) { + if (checked == nullptr) { + // skip + } else { + uFlags |= (*checked == true ? MF_CHECKED : MF_UNCHECKED); + } + } else if (type.compare("submenu") == 0) { + uFlags |= MF_POPUP; + HMENU sub_menu = ::CreatePopupMenu(); + _CreateMenu(sub_menu, std::get(item_map.at( + flutter::EncodableValue("submenu")))); + item_id = reinterpret_cast(sub_menu); + } + AppendMenuW(menu, uFlags, item_id, g_converter.from_bytes(label).c_str()); + } + } +} + +std::optional TrayManagerPlugin::HandleWindowProc(HWND hWnd, + UINT message, + WPARAM wParam, + LPARAM lParam) { + std::optional result; + if (message == WM_DESTROY) { + if (tray_icon_setted) { + Shell_NotifyIcon(NIM_DELETE, &nid); + DestroyIcon(nid.hIcon); + } + } else if (message == WM_COMMAND) { + flutter::EncodableMap eventData = flutter::EncodableMap(); + eventData[flutter::EncodableValue("id")] = + flutter::EncodableValue((int)wParam); + + channel->InvokeMethod("onTrayMenuItemClick", + std::make_unique(eventData)); + } else if (message == WM_MYMESSAGE) { + switch (lParam) { + case WM_LBUTTONUP: + channel->InvokeMethod("onTrayIconMouseDown", + std::make_unique()); + break; + case WM_RBUTTONUP: + channel->InvokeMethod("onTrayIconRightMouseDown", + std::make_unique()); + break; + default: + return DefWindowProc(hWnd, message, wParam, lParam); + }; + } else if (message == windows_taskbar_created_message_id) { + if (windows_taskbar_created_message_id != 0 && tray_icon_setted) { + // restore the icon with the existing resource. + tray_icon_setted = false; + _ApplyIcon(); + } + } else if (message == WM_POWERBROADCAST) { + // Handle power management events (sleep/wake) + switch (wParam) { + case PBT_APMRESUMEAUTOMATIC: + case PBT_APMRESUMESUSPEND: + // System is resuming from sleep/hibernation + if (tray_icon_setted) { + // Restore the tray icon after system wakes up + tray_icon_setted = false; + _ApplyIcon(); + } + break; + default: + break; + } + } + return result; +} + +HWND TrayManagerPlugin::GetMainWindow() { + return ::GetAncestor(registrar->GetView()->GetNativeWindow(), GA_ROOT); +} + +void TrayManagerPlugin::Destroy( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + Shell_NotifyIcon(NIM_DELETE, &nid); + DestroyIcon(nid.hIcon); + tray_icon_setted = false; + + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::SetIcon( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const flutter::EncodableMap& args = + std::get(*method_call.arguments()); + + std::string iconPath = + std::get(args.at(flutter::EncodableValue("iconPath"))); + + std::wstring_convert> converter; + + if (nid.hIcon != nullptr) { + DestroyIcon(nid.hIcon); + } + + nid.hIcon = static_cast( + LoadImage(nullptr, (LPCWSTR)(converter.from_bytes(iconPath).c_str()), + IMAGE_ICON, GetSystemMetrics(SM_CXSMICON), + GetSystemMetrics(SM_CYSMICON), LR_LOADFROMFILE)); + + _ApplyIcon(); + + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::_ApplyIcon() { + if (tray_icon_setted) { + Shell_NotifyIcon(NIM_MODIFY, &nid); + } else { + HICON hIconBackup = nid.hIcon; + WCHAR szTipBackup[128]; + StringCchCopy(szTipBackup, _countof(szTipBackup), nid.szTip); + + ZeroMemory(&nid, sizeof(NOTIFYICONDATA)); + nid.cbSize = sizeof(NOTIFYICONDATA); + nid.hWnd = GetMainWindow(); + nid.uID = 1; + nid.hIcon = hIconBackup; + StringCchCopy(nid.szTip, _countof(nid.szTip), szTipBackup); + nid.uCallbackMessage = WM_MYMESSAGE; + nid.uFlags = NIF_MESSAGE | NIF_ICON; + if (nid.szTip[0] != '\0') { + nid.uFlags |= NIF_TIP; + } + Shell_NotifyIcon(NIM_ADD, &nid); + } + + niif.cbSize = sizeof(NOTIFYICONIDENTIFIER); + niif.hWnd = nid.hWnd; + niif.uID = nid.uID; + niif.guidItem = GUID_NULL; + + tray_icon_setted = true; +} + +void TrayManagerPlugin::SetToolTip( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const flutter::EncodableMap& args = + std::get(*method_call.arguments()); + + std::string toolTip = + std::get(args.at(flutter::EncodableValue("toolTip"))); + + std::wstring_convert> converter; + nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + StringCchCopy(nid.szTip, _countof(nid.szTip), + converter.from_bytes(toolTip).c_str()); + Shell_NotifyIcon(NIM_MODIFY, &nid); + + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::SetContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const flutter::EncodableMap& args = + std::get(*method_call.arguments()); + + _CreateMenu(hMenu, std::get( + args.at(flutter::EncodableValue("menu")))); + + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::PopUpContextMenu( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const flutter::EncodableMap& args = + std::get(*method_call.arguments()); + + bool bringAppToFront = + std::get(args.at(flutter::EncodableValue("bringAppToFront"))); + + HWND hWnd = GetMainWindow(); + + double x, y; + + // RECT rect; + // Shell_NotifyIconGetRect(&niif, &rect); + + // x = rect.left + ((rect.right - rect.left) / 2); + // y = rect.top + ((rect.bottom - rect.top) / 2); + + POINT cursorPos; + GetCursorPos(&cursorPos); + x = cursorPos.x; + y = cursorPos.y; + + if (bringAppToFront) { + SetForegroundWindow(hWnd); + } + TrackPopupMenu(hMenu, TPM_BOTTOMALIGN | TPM_LEFTALIGN, static_cast(x), + static_cast(y), 0, hWnd, NULL); + result->Success(flutter::EncodableValue(true)); +} + +void TrayManagerPlugin::GetBounds( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const flutter::EncodableMap& args = + std::get(*method_call.arguments()); + + if (!tray_icon_setted) { + result->Success(); + return; + } + + double devicePixelRatio = + std::get(args.at(flutter::EncodableValue("devicePixelRatio"))); + + RECT rect; + Shell_NotifyIconGetRect(&niif, &rect); + flutter::EncodableMap resultMap = flutter::EncodableMap(); + + double x = rect.left / devicePixelRatio * 1.0f; + double y = rect.top / devicePixelRatio * 1.0f; + double width = (rect.right - rect.left) / devicePixelRatio * 1.0f; + double height = (rect.bottom - rect.top) / devicePixelRatio * 1.0f; + + resultMap[flutter::EncodableValue("x")] = flutter::EncodableValue(x); + resultMap[flutter::EncodableValue("y")] = flutter::EncodableValue(y); + resultMap[flutter::EncodableValue("width")] = flutter::EncodableValue(width); + resultMap[flutter::EncodableValue("height")] = + flutter::EncodableValue(height); + + result->Success(flutter::EncodableValue(resultMap)); +} + +void TrayManagerPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + if (method_call.method_name().compare("destroy") == 0) { + Destroy(method_call, std::move(result)); + } else if (method_call.method_name().compare("setIcon") == 0) { + SetIcon(method_call, std::move(result)); + } else if (method_call.method_name().compare("setToolTip") == 0) { + SetToolTip(method_call, std::move(result)); + } else if (method_call.method_name().compare("setContextMenu") == 0) { + SetContextMenu(method_call, std::move(result)); + } else if (method_call.method_name().compare("popUpContextMenu") == 0) { + PopUpContextMenu(method_call, std::move(result)); + } else if (method_call.method_name().compare("getBounds") == 0) { + GetBounds(method_call, std::move(result)); + } else { + result->NotImplemented(); + } +} + +} // namespace + +void TrayManagerPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + TrayManagerPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/third_party/yaru_window/LICENSE b/third_party/yaru_window/LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/third_party/yaru_window/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third_party/yaru_window/README.busymax.md b/third_party/yaru_window/README.busymax.md new file mode 100644 index 00000000..751f8484 --- /dev/null +++ b/third_party/yaru_window/README.busymax.md @@ -0,0 +1,7 @@ +This is the public Dart API from upstream `yaru_window` 0.2.2, licensed under +the included LGPL-3.0 license. BusyMax narrows the plugin declaration to Linux. + +The upstream package selects a `window_manager` implementation on Windows. +BusyMax has a separate native Windows runner bridge, so registering that second +window lifecycle owner would violate the Windows architecture. Linux continues +to use the upstream `yaru_window_linux` implementation unchanged. diff --git a/third_party/yaru_window/lib/src/window.dart b/third_party/yaru_window/lib/src/window.dart new file mode 100644 index 00000000..cde4c238 --- /dev/null +++ b/third_party/yaru_window/lib/src/window.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:yaru_window_platform_interface/yaru_window_platform_interface.dart'; + +/// Provides access to the closest top-level window instance that encloses the +/// given context. +class YaruWindow { + static final Map _windows = {}; + + static YaruWindowInstance _instance([int id = 0]) { + return _windows[id] ??= YaruWindowInstance._(id); + } + + /// Returns the closest top-level window instance that encloses the given + /// context. + static YaruWindowInstance of(BuildContext context) { + const id = 0; // View.of(context).windowId; + // ignore: avoid_redundant_argument_values + return _instance(id); + } + + /// Closes the window. + static Future close(BuildContext context) { + return YaruWindow.of(context).close(); + } + + /// Starts dragging the window. + static Future drag(BuildContext context) { + return YaruWindow.of(context).drag(); + } + + /// Enters fullscreen mode. + static Future fullscreen(BuildContext context) { + return YaruWindow.of(context).fullscreen(); + } + + /// Hides the window. + static Future hide(BuildContext context) { + return YaruWindow.of(context).hide(); + } + + /// Hides the title bar. + static Future hideTitle(BuildContext context) { + return YaruWindow.of(context).hideTitle(); + } + + /// Maximizes the window. + static Future maximize(BuildContext context) { + return YaruWindow.of(context).maximize(); + } + + /// Minimizes the window. + static Future minimize(BuildContext context) { + return YaruWindow.of(context).minimize(); + } + + /// Restores the window to its normal state. + static Future restore(BuildContext context) { + return YaruWindow.of(context).restore(); + } + + /// Shows the window. + static Future show(BuildContext context) { + return YaruWindow.of(context).show(); + } + + /// Shows the window menu. + static Future showMenu(BuildContext context) { + return YaruWindow.of(context).showMenu(); + } + + /// Shows the title bar. + static Future showTitle(BuildContext context) { + return YaruWindow.of(context).showTitle(); + } + + /// Sets the background color of the window. + static Future setBackground(BuildContext context, Color color) { + return YaruWindow.of(context).setBackground(color); + } + + /// Sets the brightness of the window. + static Future setBrightness( + BuildContext context, + Brightness brightness, + ) { + return YaruWindow.of(context).setBrightness(brightness); + } + + /// Sets the title of the window. + static Future setTitle(BuildContext context, String title) { + return YaruWindow.of(context).setTitle(title); + } + + /// Sets whether the window can be minimized. + static Future setMinimizable(BuildContext context, bool minimizable) { + return YaruWindow.of(context).setMinimizable(minimizable); + } + + /// Sets whether the window can be maximized. + static Future setMaximizable(BuildContext context, bool maximizable) { + return YaruWindow.of(context).setMaximizable(maximizable); + } + + /// Sets whether the window can be closed. + static Future setClosable(BuildContext context, bool closable) { + return YaruWindow.of(context).setClosable(closable); + } + + /// Returns the current state of the window. + static Future state(BuildContext context) { + return YaruWindow.of(context).state(); + } + + /// Returns a stream of window state changes. + static Stream states(BuildContext context) { + return YaruWindow.of(context).states(); + } + + /// Installs a close handler for the window. + static Future onClose( + BuildContext context, + FutureOr Function() handler, + ) { + return YaruWindow.of(context).onClose(handler); + } + + /// Ensures that the window is initialized and returns the primary instance. + static Future ensureInitialized() async { + WidgetsFlutterBinding.ensureInitialized(); + final window = YaruWindow._instance(); + await window.init(); + return window; + } +} + +/// A top-level window instance. +class YaruWindowInstance { + YaruWindowInstance._(this._id); + + final int _id; // ignore: unused_field + + YaruWindowPlatform get _platform => YaruWindowPlatform.instance; + + /// Initializes the window. + Future init() => _platform.init(_id); + + /// Closes the window. + Future close() => _platform.close(_id); + + /// Starts dragging the window. + Future drag() => _platform.drag(_id); + + /// Enters fullscreen mode. + Future fullscreen() => _platform.fullscreen(_id); + + /// Hides the window. + Future hide() => _platform.hide(_id); + + /// Hides the title bar. + Future hideTitle() => _platform.hideTitle(_id); + + /// Maximizes the window. + Future maximize() => _platform.maximize(_id); + + /// Minimizes the window. + Future minimize() => _platform.minimize(_id); + + /// Restores the window to its normal state. + Future restore() => _platform.restore(_id); + + /// Shows the window. + Future show() => _platform.show(_id); + + /// Shows the window menu. + Future showMenu() => _platform.showMenu(_id); + + /// Shows the title bar. + Future showTitle() => _platform.showTitle(_id); + + /// Sets the background color of the window. + Future setBackground(Color color) { + return _platform.setBackground(_id, color); + } + + /// Sets the brightness of the window. + Future setBrightness(Brightness brightness) { + return _platform.setBrightness(_id, brightness); + } + + /// Sets the title of the window. + Future setTitle(String title) => _platform.setTitle(_id, title); + + /// Sets whether the window can be minimized. + Future setMinimizable(bool minimizable) { + return _platform.setMinimizable(_id, minimizable); + } + + /// Sets whether the window can be maximized. + Future setMaximizable(bool maximizable) { + return _platform.setMaximizable(_id, maximizable); + } + + /// Sets whether the window can be closed. + Future setClosable(bool closable) { + return _platform.setClosable(_id, closable); + } + + /// Returns the current state of the window. + Future state() => _platform.state(_id); + + /// Returns a stream of window state changes. + Stream states() async* { + yield await _platform.state(_id); + yield* _platform.states(_id); + } + + /// Installs a close handler for the window. + Future onClose(FutureOr Function() handler) { + return _platform.onClose(_id, handler); + } +} diff --git a/third_party/yaru_window/lib/yaru_window.dart b/third_party/yaru_window/lib/yaru_window.dart new file mode 100644 index 00000000..e4e50651 --- /dev/null +++ b/third_party/yaru_window/lib/yaru_window.dart @@ -0,0 +1,7 @@ +/// Provides API for interacting with top-level windows. +library; + +export 'package:yaru_window_platform_interface/yaru_window_platform_interface.dart' + show YaruWindowState; + +export 'src/window.dart'; diff --git a/third_party/yaru_window/pubspec.yaml b/third_party/yaru_window/pubspec.yaml new file mode 100644 index 00000000..60a82719 --- /dev/null +++ b/third_party/yaru_window/pubspec.yaml @@ -0,0 +1,21 @@ +name: yaru_window +description: Linux-only BusyMax packaging of the upstream yaru_window API. +repository: https://github.com/ubuntu/yaru_window.dart +version: 0.2.2 +publish_to: none + +environment: + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.32.0" + +dependencies: + flutter: + sdk: flutter + yaru_window_linux: 0.2.2 + yaru_window_platform_interface: 0.1.3 + +flutter: + plugin: + platforms: + linux: + default_package: yaru_window_linux diff --git a/tool/check_platform_boundaries.dart b/tool/check_platform_boundaries.dart new file mode 100644 index 00000000..763ed7d3 --- /dev/null +++ b/tool/check_platform_boundaries.dart @@ -0,0 +1,169 @@ +import 'dart:io'; + +const _linuxOnlyPackages = [ + 'package:yaru/', + 'package:ubuntu_localizations/', + 'package:dbus/', + 'package:desktop_notifications/', + 'package:xdg_status_notifier_item/', + 'package:posix/', +]; + +const _fluentPackages = [ + 'package:fluent_ui/', + 'package:fluentui_system_icons/', +]; + +final _importPattern = RegExp( + r'''^\s*(?:import|export)\s+['"]([^'"]+)['"]''', + multiLine: true, +); + +void main() { + final root = Directory.current; + final failures = findPlatformBoundaryViolations(root); + if (failures.isNotEmpty) { + stderr.writeln('BusyMax platform boundary violations:'); + for (final failure in failures) { + stderr.writeln(' - $failure'); + } + exitCode = 1; + return; + } + stdout.writeln('BusyMax platform boundaries are valid.'); +} + +List findPlatformBoundaryViolations(Directory root) { + final lib = Directory('${root.path}/lib'); + if (!lib.existsSync()) { + return ['BusyMax lib directory not found at ${lib.path}.']; + } + final failures = []; + final dartFiles = lib + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); + for (final file in dartFiles) { + final relative = _relative(root.path, file.path); + final source = file.readAsStringSync(); + final isWindows = + relative == 'lib/main_windows.dart' || relative.contains('/windows/'); + final isCommonPlatform = + relative.contains('/app/common/') || + relative.contains('/ui/common/') || + relative.contains('/platform/common/'); + final isLinux = + relative == 'lib/main_linux.dart' || + relative.contains('/linux/') || + _isLegacyLinuxPresentation(relative); + final isSharedBusiness = + relative.startsWith('lib/src/') && + !isWindows && + !isLinux && + !relative.contains('/presentation/'); + + for (final match in _importPattern.allMatches(source)) { + final import = match.group(1)!; + if ((isWindows || isCommonPlatform) && + _linuxOnlyPackages.any(import.startsWith)) { + failures.add('$relative imports Linux-only $import'); + } + if ((isCommonPlatform || isSharedBusiness || isLinux) && + _fluentPackages.any(import.startsWith)) { + failures.add('$relative imports Windows-only $import'); + } + if (isWindows && _isLinuxSourceImport(import)) { + failures.add('$relative imports Linux source $import'); + } + } + } + + _checkWindowsReachability(root, failures); + _checkNativePluginIsolation(root, failures); + failures.sort(); + return failures; +} + +void _checkNativePluginIsolation(Directory root, List failures) { + final linuxPlugins = File( + '${root.path}/linux/flutter/generated_plugins.cmake', + ); + if (linuxPlugins.existsSync()) { + final source = linuxPlugins.readAsStringSync(); + if (source.contains('tray_manager')) { + failures.add( + 'Linux plugin graph registers tray_manager instead of the existing ' + 'DBus/XDG tray adapter.', + ); + } + if (source.contains('flutter_timezone')) { + failures.add( + 'Linux plugin graph registers flutter_timezone instead of the ' + 'existing Linux timezone source.', + ); + } + } + final windowsPlugins = File( + '${root.path}/windows/flutter/generated_plugins.cmake', + ); + if (!windowsPlugins.existsSync()) { + failures.add('Windows generated plugin graph is missing.'); + return; + } + final windowsSource = windowsPlugins.readAsStringSync(); + for (final plugin in const ['tray_manager', 'flutter_timezone']) { + if (!windowsSource.contains(plugin)) { + failures.add('Windows plugin graph does not register $plugin.'); + } + } +} + +void _checkWindowsReachability(Directory root, List failures) { + final entrypoint = File('${root.path}/lib/main_windows.dart'); + final queue = [entrypoint]; + final visited = {}; + while (queue.isNotEmpty) { + final file = queue.removeLast(); + if (!visited.add(file.path) || !file.existsSync()) continue; + final relative = _relative(root.path, file.path); + for (final match in _importPattern.allMatches(file.readAsStringSync())) { + final import = match.group(1)!; + if (_linuxOnlyPackages.any(import.startsWith) || + _isLinuxSourceImport(import)) { + failures.add('Windows graph: $relative reaches $import'); + } + final resolved = _resolveLocalImport(root, file, import); + if (resolved != null) queue.add(resolved); + } + } +} + +File? _resolveLocalImport(Directory root, File source, String import) { + if (import.startsWith('package:busymax/')) { + return File('${root.path}/${import.substring('package:busymax/'.length)}'); + } + if (import.startsWith('dart:') || import.startsWith('package:')) return null; + return File(Uri.file(source.path).resolve(import).toFilePath()); +} + +bool _isLinuxSourceImport(String import) { + final normalized = import.replaceAll('\\', '/').toLowerCase(); + return normalized.contains('/linux/') || + normalized.contains('linux_') || + normalized.contains('/gtk_') || + normalized.contains('gtk_'); +} + +bool _isLegacyLinuxPresentation(String relative) { + // Existing Yaru feature widgets remain the Linux presentation while the + // Windows entrypoint uses lib/src/ui/windows exclusively. + return relative.contains('/presentation/') && + !relative.contains('/ui/windows/'); +} + +String _relative(String root, String path) { + final prefix = root.endsWith(Platform.pathSeparator) + ? root + : '$root${Platform.pathSeparator}'; + return path.startsWith(prefix) ? path.substring(prefix.length) : path; +} diff --git a/tool/generate_windows_assets.dart b/tool/generate_windows_assets.dart new file mode 100644 index 00000000..b642660e --- /dev/null +++ b/tool/generate_windows_assets.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:image/image.dart' as image; + +void main() { + final sourceFile = File('assets/branding/busymax-logo.png'); + final source = image.decodePng(sourceFile.readAsBytesSync()); + if (source == null || source.width < 512 || source.height < 512) { + stderr.writeln('BusyMax source logo must be a 512x512 or larger PNG.'); + exitCode = 1; + return; + } + + final runnerResources = Directory('windows/runner/resources') + ..createSync(recursive: true); + final packageAssets = Directory('${runnerResources.path}/msix') + ..createSync(recursive: true); + final flutterAssets = Directory('assets/windows') + ..createSync(recursive: true); + final iconSource = _withTransparentPadding(source, fractionPerSide: 0.06); + final packageSource = _withTransparentPadding(source, fractionPerSide: 0.10); + + final ico = _encodeIco(iconSource); + File('${runnerResources.path}/app_icon.ico').writeAsBytesSync(ico); + File('${flutterAssets.path}/busymax_tray.ico').writeAsBytesSync(ico); + File( + '${flutterAssets.path}/busymax_tray_offline.ico', + ).writeAsBytesSync(_encodeIco(_offlineVariant(iconSource))); + + _writeScaleSet(packageAssets, packageSource, 'Square44x44Logo', const { + 100: 44, + 125: 55, + 150: 66, + 200: 88, + 400: 176, + }); + _writeScaleSet(packageAssets, packageSource, 'Square150x150Logo', const { + 100: 150, + 125: 188, + 150: 225, + 200: 300, + 400: 600, + }); + _writeScaleSet(packageAssets, packageSource, 'StoreLogo', const { + 100: 50, + 125: 63, + 150: 75, + 200: 100, + 400: 200, + }); + _writePng(packageAssets, packageSource, 'FileAssociation.png', 44); + _writeScaleSet(packageAssets, packageSource, 'FileAssociation', const { + 100: 44, + 125: 55, + 150: 66, + 200: 88, + 400: 176, + }); + stdout.writeln('Generated BusyMax Windows assets from ${sourceFile.path}.'); +} + +void _writeScaleSet( + Directory directory, + image.Image source, + String name, + Map sizes, +) { + for (final MapEntry(key: scale, value: size) in sizes.entries) { + _writePng(directory, source, '$name.scale-$scale.png', size); + } +} + +void _writePng(Directory directory, image.Image source, String name, int size) { + File( + '${directory.path}/$name', + ).writeAsBytesSync(image.encodePng(_resize(source, size), level: 9)); +} + +image.Image _resize(image.Image source, int size) => image.copyResize( + source, + width: size, + height: size, + interpolation: image.Interpolation.cubic, +); + +image.Image _withTransparentPadding( + image.Image source, { + required double fractionPerSide, +}) { + final canvasSize = source.width > source.height + ? source.width + : source.height; + final contentSize = (canvasSize * (1 - 2 * fractionPerSide)).round(); + final content = image.copyResize( + source, + width: contentSize, + height: contentSize, + interpolation: image.Interpolation.cubic, + ); + final canvas = image.Image( + width: canvasSize, + height: canvasSize, + numChannels: 4, + ); + image.compositeImage( + canvas, + content, + dstX: (canvasSize - contentSize) ~/ 2, + dstY: (canvasSize - contentSize) ~/ 2, + ); + return canvas; +} + +List _encodeIco(image.Image source) { + final frames = [ + for (final size in const [256, 128, 64, 48, 40, 32, 24, 20, 16]) + _resize(source, size), + ]; + final icon = frames.first; + for (final frame in frames.skip(1)) { + icon.addFrame(frame); + } + return image.encodeIco(icon); +} + +image.Image _offlineVariant(image.Image source) { + final offline = image.grayscale(source.clone()); + final unit = offline.width / 512; + int scaled(double value) => (value * unit).round(); + final center = scaled(396); + image.fillCircle( + offline, + x: center, + y: center, + radius: scaled(56), + color: image.ColorRgb8(250, 250, 250), + antialias: true, + ); + image.fillCircle( + offline, + x: center, + y: center, + radius: scaled(47), + color: image.ColorRgb8(39, 39, 42), + antialias: true, + ); + image.drawLine( + offline, + x1: scaled(358), + y1: scaled(358), + x2: scaled(434), + y2: scaled(434), + color: image.ColorRgb8(250, 250, 250), + thickness: scaled(11), + antialias: true, + ); + return offline; +} diff --git a/tools/windows/AppxManifest.xml.template b/tools/windows/AppxManifest.xml.template new file mode 100644 index 00000000..3c7032a4 --- /dev/null +++ b/tools/windows/AppxManifest.xml.template @@ -0,0 +1,95 @@ + + + + + BusyMax + @@PUBLISHER_DISPLAY_NAME@@ + BusyMax calendar and task manager + Assets\StoreLogo.png + + + + + +@@RESOURCE_LANGUAGES@@ + + + + + + + + iCalendar file + Assets\FileAssociation.png + + .ics + + + + + + Web calendar subscription + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/windows/build_release.ps1 b/tools/windows/build_release.ps1 new file mode 100644 index 00000000..2e50410f --- /dev/null +++ b/tools/windows/build_release.ps1 @@ -0,0 +1,52 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [switch]$Ci +) + +. "$PSScriptRoot/common.ps1" +& "$PSScriptRoot/check_prerequisites.ps1" | Out-Host +$identity = & "$PSScriptRoot/validate_release_config.ps1" ` + -ConfigPath $ConfigPath -Ci:$Ci +$config = Get-BusyMaxStoreConfig -Path $ConfigPath + +& flutter pub get +if ($LASTEXITCODE -ne 0) { throw 'Dependency resolution failed.' } +& flutter gen-l10n +if ($LASTEXITCODE -ne 0) { throw 'Localization generation failed.' } +& dart run build_runner build --delete-conflicting-outputs --force-jit +if ($LASTEXITCODE -ne 0) { throw 'Code generation failed.' } +& git diff --exit-code -- lib/l10n/generated lib/src/db +if ($LASTEXITCODE -ne 0) { throw 'Generated files are not committed.' } +& dart format --output=none --set-exit-if-changed . +if ($LASTEXITCODE -ne 0) { throw 'Formatting check failed.' } +& flutter analyze +if ($LASTEXITCODE -ne 0) { throw 'Flutter analysis failed.' } +New-Item -ItemType Directory -Force -Path 'build\windows\test-results' | Out-Null +& flutter test --machine | Tee-Object ` + -FilePath 'build\windows\test-results\flutter-tests.jsonl' +if ($LASTEXITCODE -ne 0) { throw 'Flutter tests failed.' } +& dart run tool/check_platform_boundaries.dart +if ($LASTEXITCODE -ne 0) { throw 'Platform boundary validation failed.' } + +$defines = @( + "--dart-define=BUSYMAX_WINDOWS_STORE_MODE=true", + "--dart-define=BUSYMAX_WINDOWS_AUMID=$($identity.AppUserModelId)", + "--dart-define=BUSYMAX_MSIX_VERSION=$($config.msixVersion)", + "--dart-define=BUSYMAX_PRIVACY_POLICY_URL=$($config.privacyPolicyUrl)", + "--dart-define=BUSYMAX_SUPPORT_URL=$($config.supportUrl)", + "--dart-define=BUSYMAX_HOMEPAGE_URL=$($config.homepageUrl)", + "--dart-define=GOOGLE_OAUTH_CLIENT_ID=$($config.googleOAuthClientId)", + "--dart-define=MICROSOFT_OAUTH_CLIENT_ID=$($config.microsoftOAuthClientId)", + "--dart-define=MICROSOFT_OAUTH_AUTHORITY_TENANT=$($config.microsoftOAuthAuthorityTenant)", + '--dart-define=BUSYMAX_FAKE_DATA=false' +) +if (-not [string]::IsNullOrWhiteSpace($config.googleOAuthClientSecret)) { + $defines += "--dart-define=GOOGLE_OAUTH_CLIENT_SECRET=$($config.googleOAuthClientSecret)" +} +& flutter build windows --release -t lib/main_windows.dart @defines +if ($LASTEXITCODE -ne 0) { throw 'Flutter Windows release build failed.' } + +$metadata = & "$PSScriptRoot/package_store.ps1" ` + -ConfigPath $ConfigPath -Ci:$Ci +$metadata | Format-List | Out-Host +$metadata diff --git a/tools/windows/check_prerequisites.ps1 b/tools/windows/check_prerequisites.ps1 new file mode 100644 index 00000000..d9634bb3 --- /dev/null +++ b/tools/windows/check_prerequisites.ps1 @@ -0,0 +1,48 @@ +param([switch]$RequireWack) + +. "$PSScriptRoot/common.ps1" + +if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitOperatingSystem) { + throw 'BusyMax Windows builds require 64-bit Windows 11.' +} +if ([Environment]::OSVersion.Version.Build -lt 26100) { + throw 'BusyMax Windows builds require Windows 11 24H2/build 26100 or newer.' +} +$flutterVersion = (& flutter --version --machine | ConvertFrom-Json).frameworkVersion +if ($flutterVersion -ne '3.44.4') { + throw "BusyMax requires Flutter 3.44.4; found $flutterVersion." +} +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path -LiteralPath $vswhere)) { + throw 'Visual Studio 2022 with Desktop development with C++ is required.' +} +$visualStudio = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath +if ([string]::IsNullOrWhiteSpace($visualStudio)) { + throw 'Visual Studio C++ x64 desktop tools were not found.' +} +$kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' +$sdk = Get-ChildItem -LiteralPath $kitsBin -Directory | + Where-Object { [version]$_.Name -ge [version]'10.0.26100.0' } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 +if ($null -eq $sdk) { throw 'Windows SDK 10.0.26100.0 or newer is required.' } +foreach ($tool in @('makeappx.exe', 'signtool.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $sdk.FullName "x64\$tool"))) { + throw "$tool was not found in Windows SDK $($sdk.Name)." + } +} +if ($RequireWack) { + $appCert = Join-Path ${env:ProgramFiles(x86)} ` + 'Windows Kits\10\App Certification Kit\appcert.exe' + if (-not (Test-Path -LiteralPath $appCert)) { + throw 'The Windows App Certification Kit is required.' + } +} +[pscustomobject]@{ + Flutter = $flutterVersion + VisualStudio = $visualStudio + WindowsSdk = $sdk.Name + WindowsSdkBin = Join-Path $sdk.FullName 'x64' +} diff --git a/tools/windows/common.ps1 b/tools/windows/common.ps1 new file mode 100644 index 00000000..91aeeea4 --- /dev/null +++ b/tools/windows/common.ps1 @@ -0,0 +1,185 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-BusyMaxStoreConfig { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Windows Store configuration does not exist: $Path" + } + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Test-BusyMaxPlaceholder { + param([AllowNull()][string]$Value) + return [string]::IsNullOrWhiteSpace($Value) -or + $Value -match '(?i)(replace_with|placeholder|your[-_]|example\.com|todo)' +} + +function Assert-BusyMaxMsixVersion { + param( + [Parameter(Mandatory)][string]$Version, + [string]$PreviousVersion = '' + ) + if ($Version -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$') { + throw 'BUSYMAX_MSIX_VERSION must have exactly four numeric components.' + } + $parts = @($Matches[1], $Matches[2], $Matches[3], $Matches[4]) | + ForEach-Object { [uint64]$_ } + if ($parts[0] -lt 1) { throw 'The first MSIX version component must be at least 1.' } + if ($parts[3] -ne 0) { throw 'The fourth MSIX version component is reserved and must be 0.' } + if ($parts | Where-Object { $_ -gt 65535 }) { + throw 'Each MSIX version component must be no greater than 65535.' + } + if (-not [string]::IsNullOrWhiteSpace($PreviousVersion)) { + Assert-BusyMaxMsixVersion -Version $PreviousVersion + if ([version]$Version -le [version]$PreviousVersion) { + throw "MSIX version $Version must be greater than $PreviousVersion." + } + } +} + +function Assert-BusyMaxHttpsUrl { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Value) + $uri = $null + if (-not [uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -ne 'https' -or [string]::IsNullOrWhiteSpace($uri.Host)) { + throw "$Name must be a valid HTTPS URL." + } +} + +function Assert-BusyMaxStoreConfig { + param([Parameter(Mandatory)]$Config, [switch]$Ci) + foreach ($field in @( + 'identityName', 'publisher', 'publisherDisplayName', 'msixVersion', + 'privacyPolicyUrl', 'supportUrl', 'googleOAuthClientId', + 'microsoftOAuthClientId', 'microsoftOAuthAuthorityTenant', + 'homepageUrl')) { + if (Test-BusyMaxPlaceholder -Value ([string]$Config.$field)) { + throw "Store configuration field '$field' is missing or still a placeholder." + } + } + if ($Ci) { + if ($Config.production -ne $false) { + throw 'A CI package must use an explicitly non-production identity.' + } + } elseif ($Config.production -ne $true) { + throw 'A production Store build requires production=true.' + } + if ($Config.fakeData -eq $true) { throw 'Fake-data mode is prohibited for Store packages.' } + if ($Config.developmentBackend -eq $true) { + throw 'A development backend is prohibited for Store packages.' + } + if ($Config.identityName -notmatch '^[A-Za-z0-9.-]{3,50}$') { + throw 'identityName must be a valid Partner Center package identity name.' + } + if ($Config.publisher -notmatch '^CN=') { + throw 'publisher must be the exact Partner Center Publisher CN.' + } + if (-not [string]::IsNullOrWhiteSpace($Config.googleOAuthClientSecret) -and + (Test-BusyMaxPlaceholder -Value ([string]$Config.googleOAuthClientSecret))) { + throw 'googleOAuthClientSecret is still a placeholder.' + } + Assert-BusyMaxMsixVersion -Version ([string]$Config.msixVersion) ` + -PreviousVersion ([string]$Config.previousMsixVersion) + Assert-BusyMaxHttpsUrl -Name 'privacyPolicyUrl' -Value $Config.privacyPolicyUrl + Assert-BusyMaxHttpsUrl -Name 'supportUrl' -Value $Config.supportUrl + Assert-BusyMaxHttpsUrl -Name 'homepageUrl' -Value $Config.homepageUrl +} + +function Get-BusyMaxPackageFamilyName { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Publisher) + if (-not ('BusyMax.PackageIdentity' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +namespace BusyMax { + public static class PackageIdentity { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct PACKAGE_ID { + public uint reserved; + public uint processorArchitecture; + public ulong version; + [MarshalAs(UnmanagedType.LPWStr)] public string name; + [MarshalAs(UnmanagedType.LPWStr)] public string publisher; + [MarshalAs(UnmanagedType.LPWStr)] public string resourceId; + [MarshalAs(UnmanagedType.LPWStr)] public string publisherId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int PackageFamilyNameFromId( + ref PACKAGE_ID packageId, ref uint packageFamilyNameLength, + System.Text.StringBuilder packageFamilyName); + public static string FamilyName(string name, string publisher) { + var id = new PACKAGE_ID { name = name, publisher = publisher, + resourceId = "", publisherId = "" }; + uint length = 0; + PackageFamilyNameFromId(ref id, ref length, null); + var value = new System.Text.StringBuilder((int)length); + int result = PackageFamilyNameFromId(ref id, ref length, value); + if (result != 0) throw new InvalidOperationException( + "PackageFamilyNameFromId failed: " + result); + return value.ToString(); + } + } +} +'@ + } + return [BusyMax.PackageIdentity]::FamilyName($Name, $Publisher) +} + +function Get-BusyMaxResourceXml { + $locales = Get-ChildItem -LiteralPath 'lib/l10n' -Filter 'app_*.arb' | + ForEach-Object { + $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + $translationKeys = @($json.PSObject.Properties.Name | + Where-Object { -not $_.StartsWith('@') }) + # Flutter requires an empty generic `pt` catalog to generate pt-PT, but + # BusyMax does not expose generic Portuguese as a supported locale. + if ($translationKeys.Count -gt 0) { + $locale = ([string]$json.'@@locale').Replace('_', '-') + if ($locale -notmatch '^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-[A-Za-z]{2}|-[0-9]{3})?$') { + throw "ARB locale is not a valid BCP-47 language tag: $locale" + } + try { + [Globalization.CultureInfo]::GetCultureInfo($locale) | Out-Null + } catch { + throw "ARB locale is not recognized as a BCP-47 language tag: $locale" + } + $locale + } + } | Sort-Object -Unique + if (-not $locales) { throw 'No BusyMax ARB locales were found.' } + return ($locales | ForEach-Object { " " }) -join "`r`n" +} + +function Copy-BusyMaxVCRuntime { + param( + [Parameter(Mandatory)][string]$VisualStudioPath, + [Parameter(Mandatory)][string]$Destination + ) + $redistRoot = Join-Path $VisualStudioPath 'VC\Redist\MSVC' + $crt = Get-ChildItem -LiteralPath $redistRoot -Directory | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { + Get-ChildItem -LiteralPath (Join-Path $_.FullName 'x64') ` + -Directory -Filter 'Microsoft.VC*.CRT' -ErrorAction SilentlyContinue + } | Select-Object -First 1 + if ($null -eq $crt) { + throw 'The Visual Studio x64 VC runtime redistributable was not found.' + } + $runtimeNames = @( + 'concrt140.dll', 'msvcp140.dll', 'msvcp140_1.dll', 'msvcp140_2.dll', + 'msvcp140_atomic_wait.dll', 'msvcp140_codecvt_ids.dll', 'vccorlib140.dll', + 'vcruntime140.dll', 'vcruntime140_1.dll', 'vcruntime140_threads.dll' + ) + foreach ($name in $runtimeNames) { + $source = Join-Path $crt.FullName $name + if (Test-Path -LiteralPath $source) { + Copy-Item -LiteralPath $source -Destination $Destination + } + } + foreach ($required in @('msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll')) { + if (-not (Test-Path -LiteralPath (Join-Path $Destination $required))) { + throw "Required Visual C++ runtime file was not staged: $required" + } + } +} diff --git a/tools/windows/create_test_certificate.ps1 b/tools/windows/create_test_certificate.ps1 new file mode 100644 index 00000000..4dbbb334 --- /dev/null +++ b/tools/windows/create_test_certificate.ps1 @@ -0,0 +1,26 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [string]$OutputDirectory = 'build\windows\test-signing' +) + +. "$PSScriptRoot/common.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +Assert-BusyMaxStoreConfig -Config $config -Ci +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$certificate = New-SelfSignedCertificate ` + -Type Custom ` + -Subject $config.publisher ` + -KeyUsage DigitalSignature ` + -FriendlyName 'BusyMax local MSIX test certificate' ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') +$password = Read-Host 'Choose a local PFX password' -AsSecureString +$pfx = Join-Path $OutputDirectory 'busymax-test-only.pfx' +$cer = Join-Path $OutputDirectory 'busymax-test-only.cer' +Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null +Export-Certificate -Cert $certificate -FilePath $cer | Out-Null +Write-Host "Created local-only files under $OutputDirectory. Never commit them." +Write-Host "Thumbprint: $($certificate.Thumbprint)" +Write-Host "Certificate cleanup: Remove-Item -LiteralPath 'Cert:\CurrentUser\My\$($certificate.Thumbprint)'" +Write-Host "File cleanup: Remove-Item -LiteralPath '$OutputDirectory' -Recurse" +[pscustomobject]@{ Pfx = $pfx; Cer = $cer; Thumbprint = $certificate.Thumbprint } diff --git a/tools/windows/install_test_package.ps1 b/tools/windows/install_test_package.ps1 new file mode 100644 index 00000000..0e883d08 --- /dev/null +++ b/tools/windows/install_test_package.ps1 @@ -0,0 +1,26 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [Parameter(Mandatory)][string]$PackagePath, + [Parameter(Mandatory)][string]$PfxPath +) + +. "$PSScriptRoot/common.ps1" +$prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +Assert-BusyMaxStoreConfig -Config $config -Ci +$password = Read-Host 'Local PFX password' -AsSecureString +$certificate = Import-PfxCertificate -FilePath $PfxPath ` + -CertStoreLocation 'Cert:\CurrentUser\My' -Password $password +$testPackage = Join-Path (Split-Path -Parent $PackagePath) 'BusyMax-test-signed.msix' +Copy-Item -LiteralPath $PackagePath -Destination $testPackage -Force +$signTool = Join-Path $prerequisites.WindowsSdkBin 'signtool.exe' +& $signTool sign /fd SHA256 /sha1 $certificate.Thumbprint /s My $testPackage +if ($LASTEXITCODE -ne 0) { throw 'Local test signing failed.' } +$trustedCertificate = Import-Certificate ` + -FilePath ($PfxPath -replace '\.pfx$', '.cer') ` + -CertStoreLocation 'Cert:\CurrentUser\TrustedPeople' +Add-AppxPackage -Path $testPackage +Write-Host 'Installed local test package. It is not a Store artifact.' +Write-Host "Cleanup: Get-AppxPackage -Name '$($config.identityName)' | Remove-AppxPackage" +Write-Host "Certificate cleanup (My): Remove-Item -LiteralPath 'Cert:\CurrentUser\My\$($certificate.Thumbprint)'" +Write-Host "Certificate cleanup (TrustedPeople): Remove-Item -LiteralPath 'Cert:\CurrentUser\TrustedPeople\$($trustedCertificate.Thumbprint)'" diff --git a/tools/windows/package_store.ps1 b/tools/windows/package_store.ps1 new file mode 100644 index 00000000..77f148d1 --- /dev/null +++ b/tools/windows/package_store.ps1 @@ -0,0 +1,65 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [string]$ReleasePath = 'build\windows\x64\runner\Release', + [string]$OutputDirectory = 'build\windows\store', + [switch]$Ci +) + +. "$PSScriptRoot/common.ps1" +$prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +Assert-BusyMaxStoreConfig -Config $config -Ci:$Ci +if (-not (Test-Path -LiteralPath "$ReleasePath/busymax.exe")) { + throw "Windows release output was not found: $ReleasePath" +} +$workspace = [IO.Path]::GetFullPath((Get-Location).Path) +$allowedOutputRoot = [IO.Path]::GetFullPath( + (Join-Path $workspace 'build\windows')) +$resolvedOutput = [IO.Path]::GetFullPath( + (Join-Path $workspace $OutputDirectory)) +$allowedPrefix = $allowedOutputRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar +if (-not $resolvedOutput.StartsWith( + $allowedPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Windows package output must stay below build\windows.' +} +New-Item -ItemType Directory -Force -Path $resolvedOutput | Out-Null +$ownerMarker = Join-Path $resolvedOutput '.busymax-store-output' +$staging = Join-Path $OutputDirectory 'staging' +if ((Test-Path -LiteralPath $staging) -and + -not (Test-Path -LiteralPath $ownerMarker -PathType Leaf)) { + throw "Refusing to replace unowned staging directory: $staging" +} +if (Test-Path -LiteralPath $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force +} +Set-Content -LiteralPath $ownerMarker -Value 'busymax-windows-store-output-v1' ` + -Encoding ascii +New-Item -ItemType Directory -Force -Path $staging | Out-Null +Copy-Item -LiteralPath "$ReleasePath/busymax.exe" -Destination $staging +Get-ChildItem -LiteralPath $ReleasePath -File | + Where-Object { $_.Extension -in @('.dll', '.dat') } | + Copy-Item -Destination $staging +Copy-BusyMaxVCRuntime -VisualStudioPath $prerequisites.VisualStudio ` + -Destination $staging +Copy-Item -LiteralPath "$ReleasePath/data" -Destination $staging -Recurse +$assets = Join-Path $staging 'Assets' +New-Item -ItemType Directory -Force -Path $assets | Out-Null +Copy-Item -Path 'windows\runner\resources\msix\*' -Destination $assets +& "$PSScriptRoot/render_manifest.ps1" -ConfigPath $ConfigPath ` + -OutputPath (Join-Path $staging 'AppxManifest.xml') ` + -MaximumTestedVersion $prerequisites.WindowsSdk -Ci:$Ci +& "$PSScriptRoot/validate_package_contents.ps1" -StagingPath $staging +$package = Join-Path $OutputDirectory "BusyMax-$($config.msixVersion)-x64.msix" +if (Test-Path -LiteralPath $package) { Remove-Item -LiteralPath $package -Force } +$makeAppx = Join-Path $prerequisites.WindowsSdkBin 'makeappx.exe' +& $makeAppx pack /d $staging /p $package /o +if ($LASTEXITCODE -ne 0) { throw 'MakeAppx failed.' } +$hash = Get-FileHash -LiteralPath $package -Algorithm SHA256 +[pscustomobject]@{ + Package = (Resolve-Path $package).Path + Version = $config.msixVersion + Architecture = 'x64' + MinimumOS = '10.0.26100.0' + MaximumTestedOS = $prerequisites.WindowsSdk + SHA256 = $hash.Hash +} diff --git a/tools/windows/render_manifest.ps1 b/tools/windows/render_manifest.ps1 new file mode 100644 index 00000000..77d0187f --- /dev/null +++ b/tools/windows/render_manifest.ps1 @@ -0,0 +1,25 @@ +param( + [Parameter(Mandatory)][string]$ConfigPath, + [Parameter(Mandatory)][string]$OutputPath, + [Parameter(Mandatory)][string]$MaximumTestedVersion, + [switch]$Ci +) + +. "$PSScriptRoot/common.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +Assert-BusyMaxStoreConfig -Config $config -Ci:$Ci +if ([version]$MaximumTestedVersion -lt [version]'10.0.26100.0') { + throw 'MaximumTestedVersion must be the Windows 11 24H2 SDK or newer.' +} +$template = Get-Content -LiteralPath "$PSScriptRoot/AppxManifest.xml.template" -Raw +$rendered = $template ` + .Replace('@@IDENTITY_NAME@@', [Security.SecurityElement]::Escape($config.identityName)) ` + .Replace('@@PUBLISHER@@', [Security.SecurityElement]::Escape($config.publisher)) ` + .Replace('@@PUBLISHER_DISPLAY_NAME@@', [Security.SecurityElement]::Escape($config.publisherDisplayName)) ` + .Replace('@@PACKAGE_VERSION@@', $config.msixVersion) ` + .Replace('@@MAX_TESTED_VERSION@@', $MaximumTestedVersion) ` + .Replace('@@RESOURCE_LANGUAGES@@', (Get-BusyMaxResourceXml)) +$directory = Split-Path -Parent $OutputPath +New-Item -ItemType Directory -Force -Path $directory | Out-Null +[IO.File]::WriteAllText($OutputPath, $rendered, [Text.UTF8Encoding]::new($false)) +& "$PSScriptRoot/validate_manifest.ps1" -ManifestPath $OutputPath diff --git a/tools/windows/run_wack.ps1 b/tools/windows/run_wack.ps1 new file mode 100644 index 00000000..d2a95c34 --- /dev/null +++ b/tools/windows/run_wack.ps1 @@ -0,0 +1,16 @@ +param( + [Parameter(Mandatory)][string]$PackagePath, + [string]$ReportPath = 'build\windows\store\wack-report.xml' +) + +& "$PSScriptRoot/check_prerequisites.ps1" -RequireWack | Out-Host +$appCert = Join-Path ${env:ProgramFiles(x86)} ` + 'Windows Kits\10\App Certification Kit\appcert.exe' +$directory = Split-Path -Parent $ReportPath +New-Item -ItemType Directory -Force -Path $directory | Out-Null +& $appCert reset +if ($LASTEXITCODE -ne 0) { throw 'Windows App Certification Kit reset failed.' } +& $appCert test -appxpackagepath $PackagePath -reportoutputpath $ReportPath +if ($LASTEXITCODE -ne 0) { throw 'Windows App Certification Kit failed.' } +if (-not (Test-Path -LiteralPath $ReportPath)) { throw 'WACK did not emit a report.' } +Write-Host "WACK report: $ReportPath" diff --git a/tools/windows/validate_manifest.ps1 b/tools/windows/validate_manifest.ps1 new file mode 100644 index 00000000..3c7aae82 --- /dev/null +++ b/tools/windows/validate_manifest.ps1 @@ -0,0 +1,97 @@ +param([Parameter(Mandatory)][string]$ManifestPath) + +if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { + throw "Manifest not found: $ManifestPath" +} +[xml]$manifest = Get-Content -LiteralPath $ManifestPath -Raw +$manager = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) +$manager.AddNamespace('f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') +$manager.AddNamespace('uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10') +$manager.AddNamespace('uap10', 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10') +$manager.AddNamespace('desktop', 'http://schemas.microsoft.com/appx/manifest/desktop/windows10') +$manager.AddNamespace('desktop4', 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/4') +$manager.AddNamespace('com', 'http://schemas.microsoft.com/appx/manifest/com/windows10') +$manager.AddNamespace('rescap', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities') + +$identity = $manifest.SelectSingleNode('/f:Package/f:Identity', $manager) +if ($null -eq $identity -or [string]::IsNullOrWhiteSpace($identity.Name) -or + [string]::IsNullOrWhiteSpace($identity.Publisher) -or + [string]::IsNullOrWhiteSpace($identity.Version)) { + throw 'MSIX identity values are incomplete.' +} +if ($identity.ProcessorArchitecture -ne 'x64') { throw 'MSIX architecture must be x64.' } +$target = $manifest.SelectSingleNode('/f:Package/f:Dependencies/f:TargetDeviceFamily', $manager) +if ($target.Name -ne 'Windows.Desktop' -or $target.MinVersion -ne '10.0.26100.0') { + throw 'MSIX must target Windows.Desktop 10.0.26100.0 or newer.' +} +if ([version]$target.MaxVersionTested -lt [version]$target.MinVersion) { + throw 'MaxVersionTested must be at least the minimum Windows version.' +} +$applications = @($manifest.SelectNodes('/f:Package/f:Applications/f:Application', $manager)) +if ($applications.Count -ne 1) { throw 'MSIX must declare exactly one application.' } +$application = $applications[0] +if ($application.Id -cne 'BusyMax' -or $application.Executable -cne 'busymax.exe' -or + $application.EntryPoint -cne 'Windows.FullTrustApplication') { + throw 'The packaged application identity or executable is not the stable BusyMax value.' +} +$visualElements = $manifest.SelectSingleNode( + '/f:Package/f:Applications/f:Application/uap:VisualElements', $manager) +if ($null -eq $visualElements -or + $visualElements.Square44x44Logo -cne 'Assets\Square44x44Logo.png' -or + $visualElements.Square150x150Logo -cne 'Assets\Square150x150Logo.png') { + throw 'BusyMax package visual elements are incomplete.' +} +$fileType = $manifest.SelectSingleNode('//uap:FileType[text()=".ics"]', $manager) +$protocol = $manifest.SelectSingleNode('//uap:Protocol[@Name="webcal"]', $manager) +$startup = $manifest.SelectSingleNode('//desktop:StartupTask[@TaskId="BusyMaxStartupTask"]', $manager) +$toast = $manifest.SelectSingleNode('//desktop4:ToastNotificationActivation', $manager) +$toastServer = $manifest.SelectSingleNode('//com:ExeServer/com:Class', $manager) +if ($null -eq $fileType -or $null -eq $protocol -or $null -eq $startup -or + $null -eq $toast -or $null -eq $toastServer) { + throw 'Manifest activation declarations are incomplete.' +} +$extensions = @($manifest.SelectNodes( + '/f:Package/f:Applications/f:Application/f:Extensions/*', $manager)) +$extensionCategories = @($extensions | ForEach-Object { $_.Category }) +$expectedExtensionCategories = @( + 'windows.fileTypeAssociation', 'windows.protocol', 'windows.startupTask', + 'windows.toastNotificationActivation', 'windows.comServer' +) +if ($extensions.Count -ne $expectedExtensionCategories.Count -or + @($extensionCategories | Where-Object { + $_ -notin $expectedExtensionCategories + }).Count -ne 0 -or + @($expectedExtensionCategories | Where-Object { + $_ -notin $extensionCategories + }).Count -ne 0) { + throw "Manifest extensions differ from the BusyMax allowlist: $($extensionCategories -join ', ')" +} +$registeredFileTypes = @($manifest.SelectNodes('//uap:SupportedFileTypes/uap:FileType', $manager)) +$registeredProtocols = @($manifest.SelectNodes('//uap:Protocol', $manager)) +if ($registeredFileTypes.Count -ne 1 -or $registeredFileTypes[0].InnerText -cne '.ics' -or + $registeredProtocols.Count -ne 1 -or $registeredProtocols[0].Name -cne 'webcal') { + throw 'BusyMax may register only the .ics file type and webcal protocol.' +} +$startupExtension = $startup.ParentNode +$startupArguments = $startupExtension.GetAttribute( + 'Parameters', 'http://schemas.microsoft.com/appx/manifest/uap/windows10/10') +if ($startup.Enabled -ne 'false' -or $startupArguments -ne '--start-minimized') { + throw 'StartupTask must be disabled by default and start minimized.' +} +if ($toast.ToastActivatorCLSID -ne '{7B854A6D-8B2A-45A5-B998-1F51EC5A81D7}') { + throw 'Toast activator CLSID does not match the committed notification backend.' +} +if ($toastServer.Id -ne $toast.ToastActivatorCLSID -or + $toastServer.ParentNode.Arguments -ne '----AppNotificationActivationServer') { + throw 'Toast COM server does not match the activation declaration.' +} +$capabilities = @($manifest.SelectNodes('/f:Package/f:Capabilities/*', $manager) | + ForEach-Object { $_.Name }) +if (@($capabilities | Where-Object { $_ -notin @('internetClient', 'runFullTrust') }).Count -ne 0) { + throw "Manifest contains an unexpected capability: $($capabilities -join ', ')" +} +foreach ($required in @('internetClient', 'runFullTrust')) { + if ($required -notin $capabilities) { throw "Missing capability: $required" } +} +if ($manifest.InnerText -match '@@[A-Z_]+@@') { throw 'Manifest placeholders remain.' } +Write-Host "Validated manifest: $ManifestPath" diff --git a/tools/windows/validate_package_contents.ps1 b/tools/windows/validate_package_contents.ps1 new file mode 100644 index 00000000..3f511605 --- /dev/null +++ b/tools/windows/validate_package_contents.ps1 @@ -0,0 +1,41 @@ +param([Parameter(Mandatory)][string]$StagingPath) + +if (-not (Test-Path -LiteralPath "$StagingPath/busymax.exe")) { + throw 'Staging directory does not contain busymax.exe.' +} +foreach ($required in @( + 'flutter_windows.dll', 'sqlite3.dll', 'msvcp140.dll', 'vcruntime140.dll', + 'vcruntime140_1.dll', 'data/flutter_assets/AssetManifest.bin', + 'AppxManifest.xml')) { + if (-not (Test-Path -LiteralPath (Join-Path $StagingPath $required))) { + throw "Required package file is missing: $required" + } +} +foreach ($asset in @( + 'StoreLogo', 'Square44x44Logo', 'Square150x150Logo', + 'FileAssociation')) { + foreach ($scale in @(100, 125, 150, 200, 400)) { + $candidate = "Assets/$asset.scale-$scale.png" + if (-not (Test-Path -LiteralPath (Join-Path $StagingPath $candidate))) { + throw "Required package visual asset is missing: $candidate" + } + } +} +$prohibited = Get-ChildItem -LiteralPath $StagingPath -Recurse -File | + Where-Object { + $relative = [IO.Path]::GetRelativePath($StagingPath, $_.FullName).Replace('\', '/') + $_.Extension -in @( + '.pfx', '.cer', '.log', '.db', '.sqlite', '.dart', '.cc', '.h', '.pdb', + '.so', '.snap', '.AppImage' + ) -or + $_.Name -match '(?i)(^|[._-])(test|fake)([._-]|$)|flutter_logo' -or + $relative -match '(?i)(^|/)(linux|snap|snapcraft)(/|$)' + } +if ($prohibited) { + throw "Prohibited package content: $($prohibited.FullName -join ', ')" +} +$executables = @(Get-ChildItem -LiteralPath $StagingPath -Recurse -File -Filter '*.exe') +if ($executables.Count -ne 1 -or $executables[0].Name -cne 'busymax.exe') { + throw "The package must contain only busymax.exe: $($executables.FullName -join ', ')" +} +Write-Host "Validated package contents: $StagingPath" diff --git a/tools/windows/validate_release_config.ps1 b/tools/windows/validate_release_config.ps1 new file mode 100644 index 00000000..52207efb --- /dev/null +++ b/tools/windows/validate_release_config.ps1 @@ -0,0 +1,15 @@ +param([Parameter(Mandatory)][string]$ConfigPath, [switch]$Ci) + +. "$PSScriptRoot/common.ps1" +$config = Get-BusyMaxStoreConfig -Path $ConfigPath +Assert-BusyMaxStoreConfig -Config $config -Ci:$Ci +$familyName = Get-BusyMaxPackageFamilyName ` + -Name $config.identityName -Publisher $config.publisher +[pscustomobject]@{ + IdentityName = $config.identityName + Publisher = $config.publisher + Version = $config.msixVersion + PackageFamilyName = $familyName + AppUserModelId = "$familyName!BusyMax" + Production = $config.production +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 00000000..e91a7f52 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,113 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(busymax LANGUAGES CXX) + +if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8 OR CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64") + message(FATAL_ERROR "BusyMax for Windows supports x64 only.") +endif() + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "busymax") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) +add_definitions(-DWINVER=0x0A00 -D_WIN32_WINNT=0x0A00) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..d75d682d --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); + SystemThemePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SystemThemePlugin")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..9179dc1e --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus + file_selector_windows + flutter_secure_storage_windows + flutter_timezone + system_theme + tray_manager + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..a3ac398b --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "single_instance.cpp" + "utils.cpp" + "windows_startup_task.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_link_libraries(${BINARY_NAME} PRIVATE "advapi32.lib" "windowsapp.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 00000000..2f87e6a4 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#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 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "BusyStack" "\0" + VALUE "FileDescription", "BusyMax calendar and task manager" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "busymax" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 BusyStack. All rights reserved." "\0" + VALUE "OriginalFilename", "busymax.exe" "\0" + VALUE "ProductName", "BusyMax" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..547620a3 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,272 @@ +#include "flutter_window.h" + +#include +#include +#include +#include + +#include "flutter/generated_plugin_registrant.h" +#include "windows_startup_task.h" + +namespace { + +constexpr char kDesktopChannel[] = + "org.busystack.busymax/windows_desktop"; +constexpr UINT kActivationMessage = WM_APP + 42; +constexpr UINT kUiTaskMessage = WM_APP + 43; +constexpr LONG kMinimumWidth = 900; +constexpr LONG kMinimumHeight = 600; + +void PostUiTask(HWND window, std::function task) { + auto* owned = new std::function(std::move(task)); + if (!PostMessage(window, kUiTaskMessage, 0, + reinterpret_cast(owned))) { + delete owned; + } +} + +} // namespace + +FlutterWindow::FlutterWindow(const flutter::DartProject& project, + std::vector initial_activations, + bool start_hidden, + std::function + forward_activation) + : project_(project), + pending_activations_(std::move(initial_activations)), + start_hidden_(start_hidden), + forward_activation_(std::move(forward_activation)) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + desktop_channel_ = + std::make_unique>( + flutter_controller_->engine()->messenger(), kDesktopChannel, + &flutter::StandardMethodCodec::GetInstance()); + desktop_channel_->SetMethodCallHandler( + [this](const auto& call, auto result) { + const auto& method = call.method_name(); + if (method == "hide") { + ShowWindow(GetHandle(), SW_HIDE); + result->Success(); + return; + } + if (method == "showAndFocus") { + ShowWindow(GetHandle(), IsIconic(GetHandle()) ? SW_RESTORE : SW_SHOW); + SetForegroundWindow(GetHandle()); + if (flutter_controller_ && flutter_controller_->view()) { + SetFocus(flutter_controller_->view()->GetNativeWindow()); + } + result->Success(); + return; + } + if (method == "isVisible") { + result->Success(flutter::EncodableValue( + static_cast(IsWindowVisible(GetHandle())))); + return; + } + if (method == "setHideOnClose") { + const auto* enabled = + std::get_if(call.arguments()); + if (enabled == nullptr) { + result->Error("invalid_argument", "Expected a Boolean value."); + return; + } + hide_on_close_ = *enabled; + result->Success(); + return; + } + if (method == "quit") { + quit_requested_ = true; + result->Success(); + PostMessage(GetHandle(), WM_CLOSE, 0, 0); + return; + } + if (method == "takeInitialActivations") { + flutter::EncodableList encoded; + { + std::scoped_lock lock(activation_mutex_); + for (const auto& activation : pending_activations_) { + encoded.emplace_back(activation); + } + pending_activations_.clear(); + } + result->Success(flutter::EncodableValue(encoded)); + return; + } + if (method == "activationReady") { + activation_ready_ = true; + std::vector queued; + { + std::scoped_lock lock(activation_mutex_); + queued.swap(pending_activations_); + } + for (const auto& activation : queued) { + desktop_channel_->InvokeMethod( + "activation", + std::make_unique(activation)); + } + result->Success(); + return; + } + if (method == "forwardActivation") { + const auto* activation = + std::get_if(call.arguments()); + if (activation == nullptr || !forward_activation_) { + result->Error("invalid_argument", + "Expected an encoded BusyMax activation."); + return; + } + result->Success( + flutter::EncodableValue(forward_activation_(*activation))); + return; + } + if (method == "getStartupTaskState") { + const auto window = GetHandle(); + auto shared_result = + std::shared_ptr>( + std::move(result)); + GetBusyMaxStartupTaskStateAsync( + [window, shared_result](std::string state) { + PostUiTask( + window, + [shared_result, state = std::move(state)]() { + shared_result->Success(flutter::EncodableValue(state)); + }); + }); + return; + } + if (method == "setStartupTaskEnabled") { + const auto* enabled = std::get_if(call.arguments()); + if (enabled == nullptr) { + result->Error("invalid_argument", "Expected a Boolean value."); + return; + } + const auto window = GetHandle(); + auto shared_result = + std::shared_ptr>( + std::move(result)); + SetBusyMaxStartupTaskEnabledAsync( + *enabled, + [window, shared_result](bool succeeded, std::string error) { + PostUiTask( + window, + [shared_result, succeeded, error = std::move(error)]() { + if (succeeded) { + shared_result->Success(); + } else { + shared_result->Error("startup_task_failed", error); + } + }); + }); + return; + } + result->NotImplemented(); + }); + + flutter_controller_->engine()->SetNextFrameCallback([this]() { + if (!start_hidden_) Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::QueueActivation(std::string activation) { + if (GetHandle() == nullptr) { + std::scoped_lock lock(activation_mutex_); + pending_activations_.push_back(std::move(activation)); + return; + } + auto* owned = new std::string(std::move(activation)); + if (!PostMessage(GetHandle(), kActivationMessage, 0, + reinterpret_cast(owned))) { + delete owned; + } +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_CLOSE: + if (!quit_requested_ && hide_on_close_) { + ShowWindow(hwnd, SW_HIDE); + return 0; + } + break; + case WM_GETMINMAXINFO: { + const UINT dpi = GetDpiForWindow(hwnd); + auto* sizing = reinterpret_cast(lparam); + sizing->ptMinTrackSize.x = MulDiv(kMinimumWidth, dpi, 96); + sizing->ptMinTrackSize.y = MulDiv(kMinimumHeight, dpi, 96); + return 0; + } + case kActivationMessage: { + std::unique_ptr activation( + reinterpret_cast(lparam)); + if (!activation_ready_ || desktop_channel_ == nullptr) { + std::scoped_lock lock(activation_mutex_); + pending_activations_.push_back(std::move(*activation)); + } else { + desktop_channel_->InvokeMethod( + "activation", std::make_unique(*activation)); + } + return 0; + } + case kUiTaskMessage: { + std::unique_ptr> task( + reinterpret_cast*>(lparam)); + (*task)(); + return 0; + } + case WM_FONTCHANGE: + if (flutter_controller_) { + flutter_controller_->engine()->ReloadSystemFonts(); + } + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 00000000..d7ad6fbf --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,52 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + FlutterWindow(const flutter::DartProject& project, + std::vector initial_activations, + bool start_hidden, + std::function forward_activation); + virtual ~FlutterWindow(); + + void QueueActivation(std::string activation); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; + std::unique_ptr> + desktop_channel_; + std::mutex activation_mutex_; + std::vector pending_activations_; + bool activation_ready_ = false; + bool hide_on_close_ = false; + bool quit_requested_ = false; + bool start_hidden_ = false; + std::function forward_activation_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 00000000..b812efc0 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,83 @@ +#include +#include +#include + +#include + +#include "flutter_window.h" +#include "single_instance.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + std::vector command_line_arguments = + GetCommandLineArguments(); + const bool notification_activation_server = + std::find(command_line_arguments.begin(), command_line_arguments.end(), + "----AppNotificationActivationServer") != + command_line_arguments.end(); + const std::string initial_activation = + BuildBusyMaxActivation(command_line_arguments); + BusyMaxSingleInstance single_instance; + const bool notification_forwarder = + !single_instance.IsPrimary() && notification_activation_server; + if (!single_instance.IsPrimary() && !notification_forwarder) { + single_instance.ForwardActivation(initial_activation); + return EXIT_SUCCESS; + } + if (notification_forwarder) { + // The notification package receives COM activation only after its Dart FFI + // callback server starts. This private mode starts no BusyMax providers or + // database; it validates and forwards the resulting activation, then exits. + command_line_arguments.emplace_back("--busymax-notification-forwarder"); + } + + // Initialize COM, so that it is available for plugins and packaged Windows + // APIs such as StartupTask. + const HRESULT com_result = + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + const bool start_hidden = + initial_activation.find("\"kind\":\"startMinimized\"") != + std::string::npos; + FlutterWindow window( + project, {initial_activation}, start_hidden, + [&single_instance](const std::string& activation) { + return single_instance.ForwardActivation(activation); + }); + if (single_instance.IsPrimary()) { + single_instance.Start( + [&window](std::string activation) { + window.QueueActivation(std::move(activation)); + }); + } + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 800); + if (!window.Create(L"BusyMax", origin, size)) { + single_instance.Stop(); + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + single_instance.Stop(); + if (SUCCEEDED(com_result)) { + ::CoUninitialize(); + } + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0dc7487a71414d99f48c243f253c4c30ec469132 GIT binary patch literal 97283 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 EB$0SKkM=sT-b=_Za+iqVP zYn3En#-T?ZB2 z&u7usN}r?95_L+9z$0TdE6aHWU5at8U@LD$bZNcu6xd)>s};3sJ)XSf-{A2&d0RXxv;$xyhzQjEah-b zuk5HGGFRqnWFn3-^Yt4>LQQ@cskJ6l?#Zr+%MD4`V2dj=a(4m4ZK&&-+u;d*_IGXV zj-w066>j8wk~l)g>G(#SaE#cgME!XpuE-AG-yqfOIax*{Nt|Y58qLLiU7xRQyllZj zNF+^iO?6;IvQOrQZnxpHNN@XSYggf5MoMwm0>LE45SQIYf3v#3d7CR9>3R&It(cF} zYEB!Q>?q%y)$l4gaE!r^9>K;me^bk>D758@-r&6DsU6;9qf!y`_2>5F&M(>_MaD%5 z)zWQlPO&CP3p4&bqv+TzbxRuE5N&EGFl{&vXtZ6vFgW~U!7+Rp`Ac~q?To_?S3Bf; zPqFW={Q<+$qj@le6eP5eUrRLg%k^r-rTREo{?1DSFmRwA0$VF z$^^emC?WqffQdu=>EAayi6PBqGW^O32a&+8rda54UCO^=7FT)q$AjPo-E{8`U%lv> ze*Tnyo`nfuPfq9RzC){5?jPJfK5xK%9=#T5meJhwQ)y~Zf1|SzND}u}<||*(#H)R7 zh~aHt>6l((JQ@=*Q5H}~5VZ#6r&5f7msfp^Rf0rt@5bFW*EKpm*7Tf>=G3}(J9%lJQL;R+z$8@WYV{yWra|zV9TAeoa&rfEh4m% zLM?Man#*Sgq(a+)y%j&kj#`g-WO~zzx)k)P?NwFE)r&DWsN!WjsUt%mV26FqS`6Az zxU{v4iIrKH-R0ix05`FpbGkS7yH%7&obi?LGJ4V{9D$rRV%{n~R=weg+2}G#hU65o zn!WB`^iW0@5W(^iErv<)Fys2CAw6J&ctE)w!976+*~|Ne?QO#H;(RFY!pYXM_pi=pjIXBHNs+TKlL!Q>AXSshmAd&1z*!ZEITy`U_uTp$612cnYR|F z4R75?F+#(|1HLLJZZ$Ce40q7}wk#p$fkVP-y%Z;~CfJNMtl{r)GO-8AemkLY0f zAB06rZz`P(sP<=QsDO(jV|*EPE6z*(Y@@L;STCMIJ|IKrhVd*jzpbd`QNAcqVT_5$ zNooKt^UfM6r}H^@1U>>*Z1B+J%@P6qD(^;>vv{047R;4XRGc`JCrt`cLb9=mFVjEU zu211WcjB5=DE`V}35*KpNSR*`cLCBw_(!EU+rEgS^VYD#>hJEmZP^%wt&J&8`co6) z@0WOVnyf5g0(NY3Bf$x6VRi2k&2;KZB$WdA!l1uxVC^F9&(>S|k6)dRa>fqf#?R=a zkpr{*lyQ4TjoXm~F*Q`#1-!lQInP=W)yM3KuFZ(Uf@$BfpJj_(a(dqzUmhO0A}BIc z-=ZtA2t^^jcsKIMXnF})$*}2(keC9y#tN6P&AY|lS-!GS^$K⩔4Yg(;(j_Atjfo zHU2DLS$3~qb?RB2381V2W_49$Wy64_RJTnr44GcDl@ujsHG2 zin%}5H}JrQbrgc>uo<)S`_jkq#9P#HMt2p4pLI&}VxU`v;3zd8S#}!Tf;Hu?rvuGz z9zylfD?ysfylQxL&>aSD)$qt`OFcgh8vD*j!}-`{)t`H@e8Xw+gf{B0ZS+_ZtzDK0 z`=Ib=Fym<8za^>F&as|g=OE(Qizp8!+xUp0i-ZvoF}TA;T$z<54T;zSvo5X3)iC!S z$KqziT80C6ZDR5HhTf&5wTEc_Z>?KTb>8qg5hwV*+s}Lz*kI&!`|IdfO+E)y3{!+o zCoPyq2}RPy*9aTxztIfk8Vt?(Uh!IPN1b9rsbhbvw=wq!^3~yu(vy)S%sY;Er<@Z! z*&E%CtQG=9=H&N^-N`U8U-4yy%xs>B(=-R+)4feoPo}X!lHlj9pJ-J zJGo@R`L(Nr*`a3^398Q!9R^f=?{(WrmnX=c4dQGSKNlmAUG&~B0nQ>y3h(3IO?vfo zm_msPyzJQy_TWXau;9}IMiQ@XJk!m!_1co2j(hN=`m9fWFzO@=QF_t;I?au7VD~DM z$8hzF#qZt{s=B6Jl3+yW)YHnBwv;>sDG*R!haMfb9giPVjc_%v>#uEM3XAT&b>zAk z?TYL8EbKx*cl#U}`!8+l3ZJY*5>V-O_abslaEo(;$=RM6S(ZT&w()C7F#QPmbzvpR@m{U@Z zNFSe?awfqFQFUid&QcbfCucw$cSU}{ygvJ1@1-5~l0*mq6KR@lZijxkfe80^6GB98?_pCcSli4&RfLUnyQfNlBn28-1G zijjLhqjv;B42uE+_Y-KwY7~guU&8}mYX%l>d5n9bn`N@0M;#wLD#EXT%YlehS}GPp z0#CA_ad4$4ir(xzc}qS!hhlok%djc1OU0b=@r2!zny!E%l08UQHvy!98{4)))KgH6Kb*%`(VjmFOnAXY&ZeNHA`URX~`%ui?OJ%`X)6 zdRD1n{z&!$GC|CY5&)71-&(ZxxgY7=l*_hF@VnPf9VzeUu53etpmIeXUM*_##8jdz zQMM~!a30L!9TbrNGuU0Ww7Y+^V=T#vI#RC{SF+CwoIaYwhm z&vo|?3Z~_Pph-$~E&bFEIb2SVevSqhiNmcP*laE-K{<0wOZDwc> zZm4OPuhqiDOmOb`_egmiw=`wsMZ9{-qlACj!f7EOA7M$A@cEIHq`1F)bW?%U{Y3N3 zVVqx%k4-(-8)FJnonaWDl~d4CD|KbQq04{?4=%@ypc^R+*Jtn~1g+zL9x}LlX{>kX zt?%zVCb{R@TtA?o6bBkXRteu31;+@(qM0AUBSlaIJmXLAT)tD+9k<)Q9AaILxuqYm zv+l#jdHeC3b)^nfd$SXxV_>+g_{xxl$Pfdyjl#Jv>r$PH32MD*3lshtr2y&yx?Nko zBr1n3Ei*Vd0gdzXz$>nNNQw?po^uIv&&vqLiSwJyO0GEfR(FpR9LkGc`MtuRbqeyE ze>_H&1|~1T9!2D+j6-_?n86|VaAjlQLALmQ}xC5(TDub88Y*D%pBh!#RL1y$eZ zh3N^*A#wY)N&Odoi{t7+;jfXoma&&-+uBD~-g>3GRPCyjawIACKF^aUVa1Z#Fh_y6 z?u=-~P}Jds$rJ@6syOqS%6@16%6>3{8KKg&bW*sCv!TlDr`_MHjkhDQsUElSY-z_t zu%0qX+6P=1+Gu$eBcGi9v&3}a%^g_&c~$TsMNx-6px>jyC4^1ha? zu=uIOr-P`!&5gSL+CppFGWe$l#xIm`&o97)CdJd zbw2UG^1!(A#NwW5UwUq?O!_n-85^A}<2nuF`JdCp{EuGIWPB-Q00Q7Z22oP|7?!k? zRj+1KKxoGd0}b+QAanhKMCKAyv>{6csiy1IiUR zb118!O}Zk?fHz3_VH3|r_`pFY94v`zwLtw%e%N+=Zz<6Bo=%g2{9}@I-^jA>r{=?1 z_v`bmNkSp6r#7%fT&icXxLFlz+6D<1e|K>wL&f8q1bAB+RdrP%hU?SZUmn+Eyk5PH z_aD{p_wX#Vl#l^L#Gbvo-hj78Qp^vI!+WA5j|{Q{3gp0Np6i%0w&>Ih*y(1d2zO-!;TB_yQ#-XjKMPU{uL3qL~~H|}bX_N~2ku%p)= zc(~E-wU{FXu9LD|M|+Pqf5O;QV(!D2l)UibOQ(p-u425I z`L?vU{XE<_lM=mwIKh5rubka|4D=m^qm2vAXWd(V1yMihCmf`e8Ic|a6j}~F<2s)Fumow9^8GSMTBexF31Ym$ z2nAJ&n35hN2>@ON9vcm?!5FlCM-WWKg1M1z#K6V{tLbz+(A#Wt$hX_zsYhzumZIjs zgiAa#5@PODOCf}olOzM;y1xi{99*q-p;EC>Oa=(qL$5Y<- zegyb4*Z}f|?gz2jE`5Q3`PdeL*`QRXU(o9WM5vYv?cJG%k@L=vnsp=z73k^P&%`*2 zshQs2Y2LNbxHcpAX6OBL*XZLUZN21*{tO!`+u>P}BOsdsZ(VlA5n=J3^bV z^FF#Clc@*|E{( z1;RQWAwxA86(N!zfF^m6huMPj$Kv2QUxXoF`{m+Do&Veqyorf1SVYh-_~e6m4+q|k zvoX$xGY?%t?^=2AjvaVmoRKqLjhkT|Y{Ro-`KbC6wXYq#+E!@y6rv0@jIZ76!o3$K zlhZ$*v$jE$?2HalWXots=rI>R`iz6Nr_bfH+s&3uue%iceS0bB@%bt+5ysy<#z$t^ z3HN#*_6>$$VS1MiB89&38{@vzPXB<~Exi~I=|h=Qiv8bHcp; z;I4|3SwxR=Yw(l@9FMygU_MK=v-Hir4vlY#OU|A-WVFP;sc9aM-{tc2RP@;K#<>R*M7B|Hbkd&uHW24fe!DuKZH!l{aICS}CCuw) z8~+`%IAWyxTuQ?E6LZL9dbs)a5FYwL9Yaz8+QNH`zzTf~p`>!aa&=>E{No-pdWoqo zi{1%VyFb{!4Uf(T8h_aWAf`|)K?&qX9OQwJ$iIdQ06qd#k4#e-#^g|X9DY<1_tXBN zYz!`zs2U=JJf?H>3kT1tNB8@a{;?Ko_;eaQgJHj%p$G^PgAZq#B|-L#L`03}wr9VS zk%B2VFg;fPI@2BP1wnicv99WNjANS*`iv`dtU}`gtB7kSl+Vv0$z#GU4POXSpLCS@ zys2fya@Ld+%uwJvFZAu3Wrk}etukga+9jJel^wT&?8pasNd|X+oQC@N)rQcRrD0A1 zk{G8<#2->m4MXqje54Q%{724n2idSLROYyiiv6@Dyzgp|K6&Wk*xWhVC6?b~dJ>}7 z&oZv{mcg6ry+>OD%$kwqg9Ir9Ue8WLCtk0EearLb0Llj%gB5C?mxv%bAMV+6Tb;@|e$u^h9v zjbh<)fF)eH+=HQsDa_Y8JAth)Rk3F7;~l07#z*nRWLSBxi?qCcr_buY*kE2kGK2c}vVQLJ zq#WVMO)tiGd+!7)x!XRKIV}F2hz%A9ahmkPC~C04)@vqZUVJs|e|bMrZ1=EU{*&(gch6G52w}=d?}+XzsHTjsDhABhwH3+@^=ljs#@l|2BGh6hDcb25$3HdW6{$UD~t)%xP&&{Y)6ii@E2lc_x|B z!F9Nx`QvaZZ*G@>do*6sgh#!~W$(f>_@Imp*G^}YmA#cFx(j+ROwYJ%_%Cez?k(|= z{dJ_B_6~{Pd%M**gB^-bJcvvs9FGf^NIVA@U;XCaW1%g5ymZ-UFvq zY7ndW5IF1iI(a#oEnP-}XfI{SMy>OlN{=$>_{b#K12;v&&J77vM+5Z?8pgvI?k*=k zx+iZ3pfH=HM>pmu@UV$Oa6cYr^sV+L@ayl2HEO9+Wbba^^dP;<^FeT$myN~Uh*#QC zY@RdDlR{~kS$*XLj(28VPy4OKAfEWoe`~s#D8Pp;g?3z=&EO&I4aLG_=j_41i9zZ6;;ga(*FO5Cq35*H>X-u@)Xy1*4P2MlzxbtYFgmOXK@BrUZ* zZN!py3>ByY_9i?8OM-u`ulhX*9tO|kuv?VT%!Orx%QCB|nr zpV3ftn5#`F{7#WdE`BXJk9sE~gumj9wX6c zL7UDII^Ns~Su=ewI0c9t3?SRM&?aKHYegJrT_?tfM&BH6cFT-b=9-b>&ATY^uRxo! zw|ui9IX@kb4qtdcgEFo9>-w{6+@9+7`5*s{x_=#KV$5<*w7_YO+eNuw?*fJi(1;Q) z0iVDB4**m^tG@>Hrx8%0(}&`U^USBN7VJ5k973KSzs&zHrVQi*`v4=cfbpt(n4!2FLB zxNhh$Y;;ry#+}zza)DAk&9U=cE$D60anGjpmUo&92m{ILP3i(L$9-1d*yXAiHkcf< z`sBuv=joep>mf7n<$v0PIRx-SlD$-7T0JLFvk&AtUz!oaef^*&K<)LQb^|ktuLqsj zWw%LKcfHL}UD0Uis@BzxxX+n`^k!VI0q?tr{Elj2@f4k`CJ{Sogp$?|t;Z zlV;6+<5d9j$m=#FX+R~~hk6QD6at_^d>KzvMvO2CIi<7IT2~DC`rm0*nXc4p1DCkE zt%v;XS?1dUC_xYj0Yk0ci0VWEt5h$3`jLNdQK8qgmzJByed^7 z>b~w=KD?z8@BF0-hrTi&O&eyd0HIb{GO`6Cy>c@OUvb{78}+A(NR@3 z7>7)H;@X0dD_niU8oL$p-P6o6`4L4uWL1E2e$|UuA>4paP%4!^Pl4h@3P9!VnG62B zOT{U>S7}gT7gu0 z@C=)ZBAsqqA@!GBmpA&5s7m69gHFSsrr}7&Dv;;yrl1~cb|ZGI2ttLkPkiu!=Cy?{sPZq((qMjb{iU2K1b{ z-s?6_PCD|S%InUo)(2=Y%@y~3v78tULhmdf6KO|@gHJeYT_UBWF*udRPHVJbes34v znBI*_y$&%X1AWT3l=T}GcUL{2u=BLvDA%MJ2E6#|817hi2!`*}i#;A*fNTjn8Cmv$ zEW^&%_hRdd7;fJ*VfpUeNE4L05Q_#H=%Jn+;1yn60mKl}8~YXK~d*x5%g z9xA>sGyE78fhf3Z+Faw6aaOVFA8WsQEu+=MQ+fqi#4MD( zgO;#$YskVkEg&l7CHTAKc%{0)1MFKNuh2^rMl>gnsS+8xn)C`6@l=9MXojrZ=OvrvfQfWmnEs5P?#WH>^Dr*M|{> zuxrd5fu|=AK&sZjUQfkL5Uhk%4?SbE5d{Gn}+3#{Ih%7I5?4bFuLI ztX1jQ+gni5$7S7~ZZqa{ds3R4^aGle2QF3lk%N99`2Ec&zM95r9Cz67P?f1eGLb>S zwdT8O2+`lEUhi4^!F)BqyPq#53CQn{*b8Mgoa-rJi6tsnCYI5Db@;T`-+kw*TRwjO z>dWkMVV-;3-5pqEC+IimZ!Z8R#tkSKCesif_=lSJULUNlH882A&6mjJ$~R2@;Uch$ z;N1TW$@u54ePt0*u67?%X(Oqpkcy?THg{tw&5Q4SA)5z6KM^Pp0W^c-qCK^ z6Wrh%!)DPEQaglz77vwU@3~Z>Tn=1fK_VjS{vOe>cG}6JkN05GOtyf{Kx(YOI*n;mCwBf5+9jIbW9>&pe$p{{x zdWx|^Y6Ee|;+#U-%XSDoVPM9yN!&D~5x=;w1MB^!w?t_m_$$afofU`g3hEwGf*>tCvE5JOEqKW5=Fw1tohKDA&vc2Y%FBlyUnW>q0zlWcN(!K;IQW{{1)rr+Wn1_(510IzIl(+Cx!QIA zc}V4o@S6XfQ;sgF0~+(wi5QU{s4Cz}0$w{!Lw&Ll)kAYwV@L~L`?wjsy-8$PT_C{- z>R}fZ=jAr^Pd4?+?I{R}tObs#=U3oAXQ%P&kJ?btWLVLJC6z7rxW9jQSI;-I{;t`y z867Qt`EA!4?~g!4N`ur9pn*T0?(dAA6DOf>2s9j59(DCepQMmUj`tu_m%(W#oQHIx z+Kb|Ib$C}HDNrdu65R{bo#!FHZ`HT^p`R{cfcNJS5l=lZZ5DngvB9|Chu61;bY>t9 zIO6*c@4so#D8TqZfnPjiY5b&K0GScFKpA1piIvYitQ!fg#TcVXzK3|yg;Wh-5=W;Z zJGjr;HMx^85tXOd(-A3maj#^R0|Z*gkd6m7*S9? zOi0udp=yMa!6sL&;RaaDz#SV@V8k!8m~d;>DBD&2!nq@HG>NFRZW4vqzpnfa=NdysN{dMt2` z5D4LV`8wEYib-TL@oKDATZI9eEy52A+wkG%9jJ|)wFkt)#wK&hg!g8m=9j#3m=?u*3fqknIdg15Dul&M^)6;msWGJ+#%@r8kjw7QUp9 zLqcr%x34+-u0W8|VxkDIuDd7f%jZ;!Gd&G$*+rN*aSx2$WGphtYDvF$3~;V=!3i=_ z#FzDVSKNnhGw^0+xdMsjfb<2T>)ZEb9-$Cy2wXBaGM`ZBu@tMQ9k`=4B?H84T+2GQje$VeBcMpnJ-2DBJ~9F@a@eFQuMw5`*h z25}ISP`TgD&#G7h)*HkzctA5g_+kNC7wf3hnuL7~i#;&;Q>L8mv9QX%^tZ1cQ-hnJ zkwQ-y_~2Fz@7)9({$?IEt1uzRM`R`TufJ}w7>j1+MMUPV!B4crLm&hO`9|aYxwBb^ zs3r(SNCg`gkpwvD3ue+zCz^%~z-fmajk?MvlY81{4OQH}lLYJ}@3`khSD%Rd^g2V} z6 zue~=HTDIECIq+->AxIALJqp3)haBk7)o%J#PPx1eVB4dC$uD|{L_~#JUw2nlxs*jx>#|?BN8P2R`m7zDZn)UA5K^= zvx;-{A(jSyf5Ne-uBbyMRZXn>aCB`5)^~&ZQGBlG?{j+}39a*tX8cjEEr$O3q{+F? z<@TJ35(Bjj$kBpaCNRu-t~%-lZnl-VL%p-70Ii-)c?BZ`VX2@$>j9|LT8khoEX^$NXwaTG!&4Ti-abC;0n$!j7^YWY(kKZXHb!-z%E<1 zVn%a2KAYB#4C)bQ$-ul+zf~fWN+Jke@77lqb#8Ha`Uq)2O!4jO1a8|lhg@#~EM7oG z6GzMJ0(8xQp0tB`KzVtVX!A^f)po5))mUhaj=Qfo0@% zB?h0MK!wBs|ZyTn)f5pWBa?%pu65?Z7kRScz22?RI z@|`gQx$FXbJEwxz3{L0?JB~*SA{KLc)1+gJ5C$Q^M`aUGsQ~UkoxTOccE-V7TLXMT z;<*OUSEn{Z%Vw;yWQ?&;WDipl=z|s8h{)`T$&xwC z^|2d_ll|$bxJ^ZS!wcW_Ij_^J=kg13XZyh#zR#Tc>7{DJ02d9G9%1q9=fVy?rdyBw ze*$&EsmG$Oa)9O5GpryVPDE~!u1iGh0!2DTE&guoKk>^GB=z)n1~0J5<@Vk_K%Du8 zJJ;=JIHJC{uYey&3t@-9p0vBD)wMBYMEi7o`$_aC}HaK(;lIc3jbiM;Msjr`I=mErRWU znTlHiP3ggyuw5%=E$GI_A9W#97=XAHHNypBAU`-+GLX-CTD@D|npE<2xv$4Px%e5H zdB?@@UAL{L-@8dY?$+A$nA?UJ2i<#m#7K4n(j9xKA5O|NeWlzZVRH=W-qFnY%`f2iW3}C@x{SD$Xwv^mkQYdEL^&h>K2b8NAhX z0~frJvUdB`7^4_SFOoT})T0pktKz5<7P$n4*dYUh7o-Ad5}vrPz|MdKLfTixYq9o_ zDhwU87+-$ff^VnAP!nqqI*6j4YKg(G*5k1_iK}}AfWhbRPHcc5WAD5jZ?g>A*5Sed z*P9r4Lxpxx@27;xkq`u;;+W06vav35adhNbvF&ynV)tK;MH8 z_QdTYwgY%GS(--3nH=Gp35#n=Q@pu`CbUJtWqfY)#NusB@BnyXiP(mJ^-7no5yOy7vQ}&=b@)F zfhw&*a2tk^a=E&SDl|5j3hQUt(b88`2e%MW+A!1O+-}G&l@h#jBGHj|=n?v-_5Gyl zz2F~b6V6_v3z_N|E;``|46GXjrp2vm^DeTvMmVb~k0er4{oVO22%)?r2#0}441Qrx z8tN+`yhIGb^msAZM^r5guWwf^;GV(I9B)3K<-L9F>|eL9@(XZdc1R_lWzzM-V0jn; zWurB*1ex#5n0@NJA)EiU?w*^*q;}b~O0Vb4lm;|{@DlPI{-BRdago8^>Cf52IsxKi`l%{Q$L;h^rXX_l3{@|1Cq$^p6?e) zpuZ&m%E;&h><+cHA)#@bD?(ScrBFy~kKkp_~MUZ0#B%PmL z6Hp=K!BaxvV4~GM4McJ$ClM2G@*(a`H|VA!UWMvpHFn*n1=BuXgl|7*sMZ@0L)?34 zOkw=<#B2d+LB2~!OSv;aN)Y6uK6sBTF#lRrpbxkaAOh~6yhD%2=@l+Nli;*x7h}8a z$6(*x$0HrDvWsxK;kzX8CV-f5_l9zHeMai|$@TzI{1-oITz@cF#~?(*yC+v%2S3!; zm4*y2c7UiGZj1qA$mROb)z#el{3AOY)6+TgLjbLG$RUIPA^c&fVL7P)48(vzCt>)+ zfVH<9%^#gPcjR`XQaesOx#^*E(yWrl^|%nMXZ55>NkWv{azX@T)j*O6WC?@A_X-5` zd`^se^3-V5AVkT8_A8GSjznNCp6XZwHXWVEh_x5uv;Qu{qM43bz{Fco2qLY7h)hIC z*>^8j;}HyXlxqt*yQ9}CkeCMR2*^!T0dZR~`(JmV18c0^h_fc|g{GQ;(DazYJzb=i z0%;TlJ6xLNkAmvcz7JlE)0gc6oW$~T-+f2y2Eu(kiG+JZhu+Uq7l_Bfw>8*(Df zm+N4!O*!(s46@$5i_#mHJMAe{Wg8ohib|A0?xH~!KO99rFUpbQye9`;D zeaFshdili02QD9#*=B5AywO?EOppL%T{%E0WC;L4eI8749E3QK>on4z63L}mD+7}h z_tKnsnQp^@6@yT>RWDk)7UT7&n~~&|NV7_DTmp|K%UzKQOT%Ra9h@f7I0ZHg;d&w^ z$hr2O1RzHF`ErOvj2zvb6R(8XCeQoOi!Pjd)+7vR7>HydZHv@|Ya3E9k3eoXK|+GW zo`~)0)>QXb7y&s&crU^)SRh2Wub_fRQHI`iG?WRx%h17yo~{-wT=>yf&p)>N@!B%GWhZ}MCipQ+f*Ax&&F^{V{$_dF9Os2u*UjJWgbIB?)44-mynAS3 z&DsP@TdBhVzdb}TKPoy<;Or7nP(}qpjl==uRuPAgPx%_ZUt8}_#*!FXI~Y6f*MjfA z?ZDUn?LZZ8gl0Y-8cQy?(-gbocyafBs;-^@;5Y&&i=>MI5yZ;0w1P2gxA|Nri@c^`6g=!$%7UX^d#Kw_>+^B%MWCLnO6)=3QMfmv51z7lP z0@ZrG=aLJA{0f$TLLjIW9)6v?K$@+Td*{6>C)-Yz0AyK1=KMDIhcJfDLOV9tUvcRvRzcar|O3os>_IYAG8_x zb8rlXMh<=1ZuZjiNBnK(^nc$Auug#G2?KPIzo4QY3j9)iUnW>i5&-uQp83lO3U$B* zvzg)Qn~brJ&$?f@|A=pzUYt1Koa1(_KVi?Bc!RTxi_1F+;)@+ABLiKZvqvZt+?s0& z;^BZG?kl7W0qH4{YhrGKthIoPo(fYfFpH3EH9m`ZV_NX;GjoBgxusdPB8rX&a^rm$ zp}QdcZMFldfslpAgPs7{MJQ<6kgj(Ul9=rg?=^a$r}8-Ggx#>l&|%QEgvC`01VQ9X zI*=^BfB}vmBQ3ivSKjgS^r-|vkytj)i8co@S;#3{|74ZoR(LcX+XVMKn; zKNntf`J*kD+;H8RUp`tJ8<45ctGT5_s|VASp3hWRbsc;qhq6yT65Kg-V#1t!sU3hTY$sTa(1<@tVj5=PAFHE!ge6Rqck0{hpwfBAyJv$<~8 z)w8hgzB@b;i@6mXuJ0f|Hx!(oivD3p-><}=KPv)3@hO0_D=H&Ifcq5C z6ACQVz>x-YYx4wdbf5V3n!B!<-h7+iulCS+1K6N=JeII8cQ8n)gUg0Ed5{Wyo*oaJ zqjm%0B@je^iEzl$f^3TjJMRv%I#q+3Vby3lq6P20vIw)jDWJ+!3tTbW4fv5=aPY1C zZqksi-xEoIa(6~{+L3Yx{A>N#e*ZdK-0#S@V$7JqIN|U;kd9T`NbGWS`=v_$c3%`1 zH{Rd^!h6Y|q6Y|GK?AH?c%`VwN^jTUh*sr$Sp}?R) zDS2MJn4xx&kOts{Vr`C=B1Gf&&fR#$TQ;r#*~$HHIP0$t`}FJEqb^-SiE-Rl2MSaH z&}@d>fNVFAFJfA#NQrrdwNuEJb)>~u_&gH^HZZQJtinkzUckt+NAatxhcG!+tOMgR~p?#yZc&&QW}D(uK<$gs<-t@+be3Ew)yzYr(mH`EnexasZWbn-itIJ@nYP zr?UH%$DKpA02}7Z$nP29eCs>w;=W0pxV|kILJ2=GHVk-@gm3wGgd)!3_vaU<)Gx08 z;D6rso6r24f|`yHEyj++6PMR@I;Mr#pMj3W5h(>|x{mQD$ZYquEq6&U(15f!7K=bP zCKv&=Kzr||vlvwcOkO+s<$t|y^wMwr<1yEM`p~wcpR}T-KXY~Y(f}kf7I1t?9PCS} zFft+&4_SP--3wxnlMuLau{>Xx(wyGeQf|Q^ed}=At7frlWE59iwhLY5E|gVEj-wlb z;KX(KNX)-Ez1Ks2XJ23gq&Z*Lix^!P!w1ee1xFozDB4>(C?=ouIcc>I=+M3)Ft3}l z_@Bn)>nuW08vjCliRanKKO%`T*oTRUBBG-W*;AOC8^zSjp7F1I`NT8hcwwi4ns3;$ zQbZ>aUuSy1hwycQ;6;HyOA&&{gan`k6*7aO@Ejewo(~f8^K}iVOX`OwUrJedhmwL? z)T*}qoXyXBbARQqHUDz#^K$h_wAkM zuxEKb@ag(YZ)mTy<0*$6hW~NaAnv$z8h73_hEDWnz#97=0MeRScQ?E8n#i?$v5%M# zPKE);n9*Z+!Sj#68K<3sH9e~{Syxd7T!Bm!ZtE)IYNrZl601F=C5{}}qt8kSp1
z2N{uOt;7LtpMtnmEa=v~P zAUxxc-iPZRRS6)3%t95&A-`!tB2Y%NmW06A>|=ggt&^ouW24m_)$?w9;Xi!#kgF~j zIi#o5r&gk@($|v6ENlMRp9a}!Cek*mC3`CRVng zuiT#jv!5W#-wB{h6v;2HXKpwB?ylQ(1ZwNxrK2*ImfwE}y_ zhw$s`MseS7=Ne_o8i7O>{q=Q>({JBTcyvP4D?iDP5 zUt8{~z^0knI^c84cMFPpQZK!75?*E;%Y3>bM|9je!&`<^L` zJurh?j^B;0ayQybo&F#NNA|d{AY~>)QHwHV-is>NuSZPiH{Zvqi3Sm!fIUI;Jwh^Z z_oR1Kq6!XQbtu-Iz8XU>8OGLor*X{{yU|i9qb&(77O?=ZT-WT-qiw2j3d6vHp2AFZ z7Oy(}SUl^QC*qLaRcNWSHpsA{YJSSC4sz7Gan+e;+WPp)rSy`-VDF2`#b=X$9Px zwvo$za_jx?`B0MNMhO|Q7klljvmm07vIzObg+%^EiCxb|?{ogTs zsX9e&mqws{lxp1CeVAH*>_&a_C7tNdEvijfpvw@VOLe2G)`QlY2C;MPBzCNyL|z$B;mvww0qyN2Jma`Ec=1Wc zp`)!8D?9rA5^gFTTtp(b(ckM9(X`LW$zbR7YZlv0RUXC+PO$ za>onLoSnPp)^tM;3$q51L)uDC{10d$AS74fyY^|uLvPC+3e3nK+Mg3XV!NiYyfH!X zjF8>YjTJ{+fh#V0nts~hJ*qp6RF{=jvwo6AS{)6bd(Q#}c8}q{qX#j*rWGs7eICN+ zM#6|M_aBt{e86P~(QlC_Q2P3rCMhuJ&gc?e252i{8yDIwRdL1-=9fbcuBU&RBm3G;~< z_pkHUL#N@z?d6UJ&+lE+3#?5~Xq9FZCVMv6E!xe!5rs~B9hwj6R+ES;*0gXW zxf?yDr>#EaS)0Cn|Inp3-nsRiZ^$b9E>JBDORut^pAZp{-<7yV1Z-Ev6m5H#k}*#e1uSDv3IV2Ha_6+9$16@JL7?`7_VD*zip zQIG4GoE^l@TNUQUBDA#(K$je`F~($TyQ2wXmaQ{0R=M1ZBiEhz-`4iO;;!57d&l`h zW0!viz!1dU1p5$(Z&p;xN90%P;kD&-1DekWIYOZ6J3`-CUFhgg-A~=2ANXDwrBXM# zJ>RjR#P0EAB^KYgCHJpXtI#*2(Q*ACb{so|iGc|HEvr#dkvEk&4?&2FGct(#f}aow zDf;%@eDI%duFn|*3y2PnP04pazTd*<2uVZ{QA&5?$-C9q1s4aovlGn?!b;KKRretz zCaxY-6)Yj(I0KSCY?U4&R7nxixnWEU)v)sxpuKG++FQy*z#M2>7bm+p;iN$iQ!{GZ z-gbz3)-jiSpswrZUAytGWA}_>*VA{S z+}DYomfl>pq2#c3(99W;cOUX+WSFnu>gJ*l=l76VA|qd_^Ys!X(ee6)kHjH~M$vbK zp3U1_XY1pou}ODa2#MK1sC`Sk&AbyvImgvq{Jl?n*w62m^!C+-w9iD(pBS6L_M2mr zbT2wPx+o~h>47W(SlFVq_RR8%m``?psodEBkCRW_{GDARm;dsvd;a%3bUdF(12Yr` zzZ4SlW0t)gh~0rhBE6(|GCRKiOm)_~&e!L^Z8cV^5=vPIS?pg(46|JsifjmkuRtj# z1eSVhw5txZ#qH?0bpqpEGuU&&9D2*WXe+n-;yoOdj|3vYt#e*n&i=e@%IH3GObai9 zH>nhIoa=iD_aOwKV&r8ThIvNN@FZ) zTTfSKpUZ!+c0BR z$+>c=7l#hK?s=>FPTjcm!B2eYfgKlqBq^0Ira5+BgA#x}83334Y6rR^5D%J}qWSDu zr3QZIHhtSQJ-SPGqEpEm0aswmm3NEPm!puhy!P7v|gC4XCvTEJ?UEZ^#@#lEOo>tE6|e) zd6yobo)MGr>D?IKuCZscMn}sUw6qvYr!g%0YNUf7So?{CE#(Y^hA`NjrBZjZfjhHS z8YQ%}uEy`I|G*p8Kk4kh`1Qu;oKjufc~9y#Y)q?(eer0^a)|mx?U5`A1_HTCgz}n8 zaLH%?K)>wde)I(?nkGOo20yC`m}@8wAob0{o_FHEivlyFu&RYl&ktY?y0*<@V(U0Y ze}4}uE8Edi>9rUw{qa^RlpK?h9zK$pg9@%T0TY=9a_T%%S{A{sNtN&0jxEX)p&&Px z_tE!|w*g4=_ca;gtYM96>*X@o;!N=42mD^Se(y$oF_+ulUj0_aUzuEZ-j1A9V z+l@(_L=U=>q$Omkf#K4Y(KuJ1w(w!##g_;ePT@%h$`TcW%Bj5|y&x*Jsg`mFo^!&@ zSB*{nV#6JGzV?mKwOs%fX|@jt!pnA`2>`>L`RjbLI`V}Z^o`%>Mwf0yD+G$$jg|j^Ads?j|#W}c`QMME^;RdB4&w#mwCrI`_2~NT!QTC^UIn#ot}xj zIYv@k%u<4H!3Wpg=lcxjRSNS9vzVEm#C;oLR3|#nS?P7P{tbc>!Ei7}BEN4{@;4e4 zb|&Q!17bFYz*~4r^D@enKJ=|P^@Qg=Z~Gm;`OQoJVs_@vtCDT>0&L?kZv)U|{SU>z z7@fcS73%c2eolYxyoBrfp67gr27v6uvsjP(9~_Z2zbsbx3rQCPo}ni>(pl<3$8ujA^WD9mo(!xm%?#-w+250xA%-a~xWE!}V_U&hzjEvTCux3Hw%ZNG(&qc`<|Vj zJ3k`=(m-ei%Bl0kjB_8`a_kOV^ji}cHaPza)XA^=y#D-otFbb4FWu-b0xyhb8=y}Q zsEUrsPa~pw-UT=E)oLxOgH??FXau9r--A+L1^uln)6G4N=dg%_7P#Of$x|pM221su zgCR*=WGHNG0E7+x;?uAjq%PAnmmD#`2wbYV-J{` z9mB-L0=E1-$rkHFXG@E_UXfH{i2xxce-?0gr&gwYf_~cx6OxUCRY!E3l#xPFrDYYK ze8l;0SzH_(9UK1Mrzwii6D|U{7CN`jZ8^OFrfHw|H2zBcYW4Bo)R$eDA%C#B$?`3+ z>;aGy8%ufG8x4zhg5X;*5M=Gm3_(_E&?u=223po(;)V%Kw@zd5IlIu)-P_2vLuTxe z925{psf8<0AYzdf9-?0sY~-;VkqF6Qv>;k;ZW8FjJwOD~1i7#yo}9MpjC!Pc#o|8F z=M55?%E7U`PeGLNO7B}(oWa<{IPSesqdwn>?)DYgW~t6!@8gmp7!a;LOWA#|lAGvE zmY`UG7`}Sx!!;#C!5IZJUBNP+a!Ws+^1BzEd+SZNY+Rgw;HtoYmn{U?wB-r~mT~`D zxca(p!>w1X!9X?z;dfBkS(`GQO}5Io17FEB)lNPw0>~J{Du7Qw+&C=9A>=pDPdH0g z)Ps&{H^#0V#q63%%sgireWgCMCbbC#0yDSQq8vHD0h4Zz5M(EF?!$m6FOS*hYTr8n z@yEAt#UwF=jMMp1dr&?n_lW>i+zBBuXWl8F2*8W`SH7!{qzvw}TCZYeaTHr_j4`pR zgpQU~D3?_xr#QHZEhGtKG^=1FK&#_x58|98kX5zRzIZR0k2xkeKkImwu~FUZN@W$E ze!?%mz47|P)~DHpb}1P7myIACsLerHZ;5^vPkqh`-LoX}n~5?D6Pu%%3kP;#l&6c@ z$!tJ;))#pTU<@ywxaQ8==NfmRqWhBPWjn$1`r2%xdMqY&=7zxpo z9M>#Kx?mr(*69T4JO|JEkg)-f>VsJ@(WlO+W^4>p*1hTsmezC9unTmeqx*#aK09^O z_l4Z5h~~;=+^@FWTZ1Vvxa6C~Nj?I5H#IgVo%#*fOf6xm!0SgLBpe+#IF zkhrKVXmgD-?!^Wg8{V;Gq+D+>W*pbdC_ik_jaMHrOUbujnzH0N1#Pmz_TSt zLJ%ShY|U4KlN-WdJr8nan7|k>hTL4$^g{q{L$SG3mOw@u50LZMXM`b!NZZX9{ix^q z)K>c7l-{|;X^f3c;m)7MD50xS1u6Cr(7_M1lnT>p7?V6ho%zll0IL=vbMKK_LE?bB zN7?JFBoOE~q%I@}ljy;(Bwwwh)gdpsAlD2exDee!^ zjiIsuQOqKLUgUCssqBI}k4z>#urY{5G4q`kT$7xky^(6PM_p(`7sjs{!}!`M%$+!c z-nJEJue9YR$U{bPE~G*P4R)+vh|Y5=1pMp0-=XV}|L$+7p?$=M0Aw%a7S7NnU44?s z%ZNZIKei)wX~v7S1x(J3 zvFrVJ*osiiXsxjlUJ%;jjO-?_jvEiI?%CK z6~Imk8T~+%)BH%Q0E-0?MA?l@stgo{m&w%5#pp+RnHH=3>>Hr#E%l=|RL95<2eEL} zC^~+xjDfZR_Z-3fjX}`kx?;~m6ih4=2PF|-`w|6r6!h(ze#A9ymNF4_2_$ymy)M|w z5K=C|ZS4S_KFH96%TF(+Dog^UMYLI01P9FU2-{(g34UQ0v4nj zkS9y5w|SP--+YO}c|gpyWO0UO(S!Iy!M!FADbQi`v>0wyP z4amQvtcs2_;&CM(UlQ;w+_AU8hi`!OR0MBFAkGkUF2m{h2~17TVdIZuw3d3&S#I~F zKr?>H9^HC5+0A^9l^8R$kF;9CPhj0eP5=`JSVlrH?JXR0e!=J(^Dw0*NB-;k6xG6Z zH5nB2u(l3gZZ}{^9Z1^ZtJSODeue(gMJv(oop1|B1{$r%MJG5fO9n=-hsx_x-ZT6O zH&TWo0ujTA{yrD*0E+@w%-c$i*b=p20P8Sz4LrnEo9c8`n8 zS(Kc=D2gxguK`IAAr5aote1e0-cW8|3=O#B*eGaY@+oJZLV05%BA&RM-B*{~UO5S1 zzPf<1sS#|tL1Sj51>G$x#nvVwkSMnvnh@SithN*6m}p))l|H)eZWFtN$GrfA3v*$LU?@ z5?yeZuaIoC?{5XWVKOwfL_jVA4bkayPBeA--0pq@03k&8q?3YZuxy0riTV&v#u)kD z2xgBSLH~a%qp#ABa^$AmAZAgRu9rd_m|RcZg8MW^ykImUOAEyDSu%)Y7RN=nP02lcjr-2PcPlq5si8rV{&l>_uNv$j!g=kt*g*pDdp`x-aG3I4)S1`rR8K^ z23T&-Oa`94Cme4g2@l|m%Kn0yN0W4!3-r(gw+_hb^*IdhzUp8!&^lj z(h_WM-`auj0)Pg`1Dt$6&OLV)bM^P&>@)h&mtE)<-?&VSs}Gd(4RXs_A`s&4{CK`b zyEts-hB3QpEUP3pk8)!(?noGmbHu?){2W7?6jrvbMs;fq4{RI6>wp==~!o=(d#zq!#>$P>XMXS)$){?pbJ!Ey#No*N{a>QKwetxJF`C$l@4VPqQ z!}cC2CAx?L_9j>)wyt7BoXk$gX1oq=mvU`as}mR=`sU9c*z(S|0hkha1wjb-_rq!j zDh>d2jQ-U4M{&V6upoE5PLB~`*|bqFS}8^>@ohwACO(AU|Ewn}?SCRl8z zWFQTn!?P&QK|Wfh_`<)QB@2@h%xZ;r}%5c0Dn)^KHx z2($yUZYruPNYD1fe{C`W9YA=xU@jETjMsNDbw<7D)GP4dw}&xu)HnuCm~52XPKuXW zWTmBzgngjkF&?7l!+5i63W9(@G{*Vmg|6-S^~q|Vi_if~`IGo)O{TEtW9?3T|Ns z?uUT#*u3#F&}SQ#8XXiFH|wpeLUrpR?!IpX6DJH~&9N=$Z(EfjrwpnV?zu=Hxrg-p zdggYz&cbAXP&nfha!8EaF?OxFcMjI-D@fi+#Qg{EUBu>_Vzfmo(b-xNb1Kqq4Z_jf z`0L=-Sb+$yWg4(tT3LxQBMvmbF{{}u^0P6Tpwx~jSC4=^4;}+cYAR@5tBB#wU7ZgjVIpr^gZc?LWdUtn+IV}UKpE%6Xme~67q()@eJ_43tu zSwisj_}q0NLXiEOiaR#FlRkqy5k+9R9*-40NqV zYo(HcRTj1?b@_%qFE{Lv3&GqU?phf!Xy%!B>9umGNcwSNzS)t7E%|DoXL&3OAo3WYUI{8L`iba&(ReLQFv5 zjo#FgW3Iz*XeA5*Fd8KClaxVm;vi08#+Pu@-qI@6c2#lPS4MEy^M-KPaV_X?=}&DB zg7YgHQ%>g>c>*~}!4L{-n3C=|l7MpCbvtp|>YAQa zTQ97Ce{Fo?;;Gu1tx1st0fqzZ&UxyjmHHGGt0Nejxc-jKcmB_FwO)J> zPzzc7Q-=Hv(Z9~qy9DwtMG*G8Ew>GTAwD)XE0*BsWiy12nZ?-&sH^IZcgAP_?KybY zijAc$KkU&xsy*sx$ixnzc|kJ(=6#7R61(_ot{u>5>{=p=Jy*0b{b)lB*z>@MqTI09 z{o}w~7_l<w1!1TW|bfa#-kzY2}t*% z7CZ9V^xN1^+vV`x1_D#1UmMD6Dg!4GEVU{|=Z5gh%QfbvO6VxBfsV9ry=n_2cF_sB zToqoLm}GqHQp*f^#+7NA150cwKv>{n9AKVdYZ1EI6J~x=_#q>y9T)WzS&2!<^;uPo zr*Pv9Pd`1bPmU;s$qe3W1pDB)TiiRY0KI>x2 zP7~7r;6(fERUB}O*l!^mI3nWC^^{gZ&ujeR^Fw&@b4GCZ(<7|zT8(m)?VBKth457X zJEcN7i-=%4kA;iDcbR#4>wnC#{Tv$Hh-9l{baoh9x72aR^+0dO06NP|ER@|4G$*ov z=FtndfxnL4dWf`dn0*Dv4qyO-Ty!~tV~;vJn;~u-tfK`%afv==3{@;Hj^f_?&bfGS z@Y4TCefo{tYlP%R@2>~Mehx!K$(oIr_09H*9=}Ab)uhcO&w0Q8Mf(J=oMWfu12ttNZq$lC1el8 zdzHV*U{j>2pEvQ{EaXhc#kq1>``NpeEg~}30YBP*;%TB>z4dG8g0i1ybXF*Tt0JUZ z{3huf|Ifc&QI}47V0-Ha$}nQ{=u?Z*W#T#xy)>(+Z{hay1Ft=bG}mTz&{YR$$hQGZO95kUTHPnU;CQbUw@#Z)NqSd z%tV7CAJ*=zLAm(#YLp35DX%}%^wWk8`pE;{vVT-8T%J(>^U4;TT$QUerB_;pB+n6yl{^BD6K}j&_w*|jO!7H78+VV;XR1~xuQZh#$Co6> z!~_2P7AuDt{9FxAWOXr~+Xl0q&_-uwA59Q95+=bSIVn&QgZ%-b{JEgou;9+xckgp_ zg-tK`$f2Y$BCvn|=i1LMo3}~z=b_(EY>FUastknvTp(O9zbbFs;9CVMN;IA7S6l5T zH5xWSd{Xjh?7zA>9!n6D&pTAPM1v$FiaX^qxr6ID!Ridk>r1*o+(-|OhHVZ7mPdm- zWJJvg!A78A%M}+V?A$6_8(yZv+^P@NthPa<%i0=PRjCAyMOQ1ri)8bTp0BvHm&Izh z9BrzjY7)i4U>3|~`_`@Shlp51v~8NG?? zRT?d14P>=MP5Yh=7Ym3CN>?!TK7>U3Vbz}1o~3TLG5kg8ekgYngdd;P-IvER`uukk zj3QH+kaFzpPM}iI&#OB!2_JXVx7f6t>UTUq2Z_*q-Cpc7{ZR z;G0_`#=_zFoxZ^xUTTYu0#Mq=3AD#iQ28X?*gWL^`@6|gKp&MGM*9y-zbGNTJjv6f zRy+FNc~1V_EfkN%PRXwXZ-CYT6+yA{QSVSt9D z?on7WS;p?W#w0>1^`r^bCTmqw+GyZ)ka>SOfJ|1upC(JTs1p}wQBdA{q+Cys=@1Gp!~&zMG}2*v`GA%eeki~2;r3_`s;LEg*lT)wuOLl zn2_WSF?{+^cHwl2N32N&VZZ8nXdS1-lIyzlHE!XHvqniMfmld&NU$V`FkAAGrg6j& z`1&kzP%2^*{Xk-E+_UZi1D@Vj2Qit0&e&IMKt7-#;kE`09llkiEKW(NfO17h+juJ( z(#%@QZ(q^h_Sjz0-TV1h<&6C?*UxCqWX{{oiMR1ygAPS2L55*jqUiLI4fJ~~fbHLx z=nXQ5@dsG%>@y6(oQpu$_n z^>mEFI-~g&#Ry(F&k-dvANqdAca_)@->lAAFk@Q~apAu1r|vt=ay?1OFo%tNXCexC zmdCkqQS?bu4c{|=8nK?~LbTtreFat@Y2XL+b#cq`H@G}(y^QVOvO1T{`ats z{YwDUe^f!mcnwKZSLnweX|vAf7B zq+LP93XJ(cs(kM0(JYcEvtVf{Wex}Ethkn@=QS`p_Wrw5NGDdDp$>$1kjl3*>gO6C zR~vVdJ=X8AnpR_4(;ieFjw?U4Sa#2 zw-SIJ3{lS#RivfImGBrqfk<@ZLslh8xV3Sa%z$HEkyeTnC?j^TGzzjYY<<~iX7YdS zV#s~D!9C>SuT^umEhZgWGQ5a_g5iv&_?fF|7wjP-!8q~XuUIpGywsv8!O+_Cb{6Ss zxReRsZYMr0PbEZaKl$LE>c#M77cS`QihUT6LSe;G{Zo_*zDuj*Icq3{y({cNrsXf8dkMWwwr+3wV+EoR%b71zt$x7*B&F**SVU6$htwD7q8|q?} zgZLG5s^jJmnlRwDZ`3gACCT75BxuEJF`kAj4-FP2_#>m+lAo$IvCMjHkfUxnC_BAZ zT6uZYZYBK{C3(B?$L*#R_O}1D#8N!|V|E-;?x`vrZ~N^2ph_PNhBGEKH#4<2>}gQi zqbn}<)dudz3plimFOst|0_sJ|c+RzXE^58o*9~ko!`SuowBs91zES@}S<94e8vu%jqUTr?{a^>3Wvs!EmD)oc3&etLi_5MysN4=P0dV$MU6 zzX0uq*2&VLvy%{hX}zeXup%(v{x(p%8rebd@dJ8=3HYo*-zrGborw6up5`FcfVmRs zA0<;>Nbg{T6&EZfPG(LVEjVgf7>Ydnf>2+{-M?4^4Z4b>i(T!fJ+Mc-=0X*cgDpSO z(f4)|wU)pBp}_K6?p>2fEBMcP8Pulkq%avN5*Q`U^og0o^5pzfqA9d=w#b|lNg$@k zG5ufc!Kh;B$|aX&yaXxuU+&TYR8fC_%BJ$SwB7M)%4j3NPLjkE2VKdgz_Vr|EIHn2 z=>@OvOnF|-khF#roOOS-C8$?PO!6b$kx!uWcIaGv(Zv(t?S-G2Bl67EMN)uS&Xpq`7 z(_bnkhA-7YUtU1agvY5likL9rtSf(?24=JSk1|bLzb`^D)qbqq`_>ioQMlq$sdaIW zjUr5sje9w>sFJncKdCXpR^HLsQIcaA0@_omiBBE(+S!K`d5y#qe5#eCl>r*l1H17t z(XnL04WlJwo_3EbYuMMHkl*lAi2}!MkH2v|d&$t#?)9=nRKNIZ&TaV13GqL#raU+A zmwPm3*@9%xZmE2_;Wn0#Uoo8nJ@n~BonpJ3{2Xl#nIY7Ado+T@>kRGN>;S)@Ex5hO z10u=6`NOcI7)Gfvx$I!e7gAv(Q*46AOrzQUJqrb&X6`L>U2prLr1VyAv*YnY_m@2- z1r)J0;@Z@M*&HMH?<&BX>rg;|MRgT#2fPVN)K?E);0*M%~?8AOUvrroY;lG_|LgkG!Kw^)KI-^^3SUBWXUQhlgiL1KvemK>6Bc z)X43MQ?;36(UPe`A;J!=IkS>;B63HstNaQ|ml1f|;F`*MhzLHZ+wY$9KmWFl(RD9g zEZM}v1ss7~1~erPD6#(-uP}b&PcI}O$ivlvZ}ljPhBU9TMJ}89nH~Ue<1c9p=MJ1S zMOh7h?Xom1ZQ(9#zMDHkK22rCIg&$04uYdKn6h3jCy!ReQUDDTmh9Y`5u3R@Exx8c zo8Jm*&T!p7FR60b1VG-Q;FM%#J{AY)`R$XB#VktZ@hkF?+T4j5>Zo7|AvlBA1^4mo zNI{w6R+4vVDOmw(<%XbxqklDxvjva_krXUCZW$NY(tczTlhPrPUuQ$2#-a@I1vuY{ zf73Wx3w#R%ZoUntQgpl%tG4hLfpIFl!`7+Dsb$n1f1$Pe#o1b77mX_Y32nMtFrKnQ zyVDNS0Mkww)MYa;n*l+}I4eTNOpd!Y22&6TVkHo-dO9BW)j(nr*ImnjX+)f?HeF8k zOeDoi__Rj|<#%}-(@YQPEBSMnOZc zwS89t7=a174py)E-@En4C%4Nx4Go?w_Arc5e$H5hg{+ zs1mkx{lOF4*KD3{BEg1d>24qk@R(nP!R#}YwA+C3U<9lJz6z>9_8)X47(WA%j+W`M zLDWXN0)FgA>vFQgW(1rNEl+g*E+;!$KRs}!)A2}YyOq{wzOj4V^}clC@z)jgBT$-+ z^hTH(cL+eMkr@m3N^VA-M7?@%^o`*M04b>>vqF+$)F3M8(FZPgKI^SoPhOVUNw|q+ zjQ)`c{PoKTT2n1Nu+yF_;C}h> zDhGA%<dpx@-rD#KHunnJ!==$;<|Cmepm)CvXomQMYzH)yg_>s+?n#e5Bi#rR zew(!};!k~0gxU5WjDo;GUsmKlFW$Ba@Sv@Lz1$L;eR%Ri&s|p57OxEVYJvwD0cyE z?D1$|-r+ji#BtL*>N%PK<|%=_@Q;!igK;7n;6%U4pbV)z6V!EFkWIGzL6Y}O?$8B! zYl>aSYuw0)9;O1P`rtqZkNtn*83?Z-8He2!iFpyea&yA@1*zCH{xZ2 zb9#8)n>)=92}h!=a>Ssled#WihPX>(-fr-l0+%9;crC|dCSv{1cHrwtd!^9k=TuA_ zQ_qd3Z$Ut&qitfSghHgE;2H`ff%gcUNbFWlGN4m=M_!>$k~0|av@m)ns!0Xco6MtO z)!AQ}_u;t$-e!6kzl|2wi*wNbjRF$RX%O~kF*XChw2|ZE;awzM3|BiPHe9uLuBM_J^;T6c% zq=U;6M7>!qqn@8))qHZsGbqh=K<2jX!5qo{vdt-Bh~$jgjo7onJc|BxjXn!KrMI~ zhdwf5@YknpWfg`LSm3=`G>USozx7;~Km^lDO$`|iAI&NeHSG^&IDc#}K^uH3^Uhm* zO~R~3>*6%q=Hl)PAj`OioY3vGm)CXxi%rG3@W6DKnTBIiQ&xFUG$1mAk62G^j zM_;3zEDZ4HSWL@@V%o8kD$iRwp9%-mgnBYIea6P6$qfIJ1Sqa3z}F$JGU%=v-A z^}aI-rNc$Q26qN(uLswGg+&425kr!M2ZI5myw(5g(Al#EhPbL#un(Gs=+T9dB_VK(N{QP$mjjQRwsAcKsGnD+P&-i6ygbq`$7IcR`N z8SD4}xiIY93-B;9a-H`ZJTs3@X6K!Ch+iMvL6W_|LwIM`0F@l#uNZ20=6}imxOVCF zU3}sOeW%H9r@;|>a$$G72{cU-j1`xxTWm+O``7)0;aa+$92~ zV8=7lz^MD#u6L+XO7x_;eTG|=s|BZkW1?q!$kTJRJT;H_>VV4o_IApW$^Dqv4b-}% zIPVr3IE(X(`cg5t&~&g`cY8-G@j51Li{&tXp83}CK% zz|jjmoUE%P?CbL({v0IA&%=5kGX2nF48>;T9P-0eFEvgpIV3MLSp5seGgNbEmjZ?i z^m;o2%~;fMer)w71~Ch@sw{f?qB;@+TOgaqoo!qh|NielgWKf$ajUuNVr04xofHz+ z&-~7@Xn{eOf|k@<%e!l&%)R*veu^gvhe&=%uPTiTaH|W>%^jIp;t0dN>DKI6}t&?Vr z%LI7vBB2`k+wXO~*vWjYO^7hsAFP4WkdYi7)dlN`BoJ#I_M|{LC7-KGy`n`#+zDaG zZ>0nu4`e*>GXe6qUFFtWs1Sk9=S1?l;de&Q+1FrZ$6T)6y=l0`cX^c%pTm`+?R9%v z0$N1SssiKvPcMZ}W>s0HFx6n+v@aU$En6tcctyUIEbNM-q5BRiWIT9=s33}8NCo@w zMZFQ~d<8t31pW4WLQhj2&wfX8!u%K{6}W>aMQ1ibaoeQlVsJ-&g7}dqrZiggdw22F zE5}dLz@P$|L_K}QxP!0!fZ{8r9_G*@Oq{T|QOB-4?9`IIMpfx;-M=>#`>eP#YF;7#mVc z)xnn$ZR)Z)>*wT{(z?5BjeI3+<&Boso3km|o&~1!yIz-8rt(4zilTnNG24tVSptmf zD5Br`@rz_FHcdMcU!1>!!t$puvy$|WAYcYD`64MJL5Sr6wxePC)*VPn7CcD!K{+`h ze%5T_y;uqite=FZhkbZ9mlE9QiTqEA)lf4`(^@Zam&g=F?`uxtai@jCsb?H-?KMm; zPFdA;MP96E(5JFt3Q%>riovDb|5!LHxs6E%z%HA;B#HLr46o$^33XHWwj+Z%j@KBt z)wYk5Uo8vq17YM`*Nm#b3Mu^H-tjl1Vee%B^lR>x|M2_B@pUIf$d4&laecnIfbC5m zD15{Bg7JZ4N0gyd6!ATUFGKs+2$nYnYA z0B%RT<|_({6P*ocDI&g{lLMx?$BB;v@bi1B;yoCss;ibRxZ5NV3G&-=1={Ad$k7y%u)cD&# zF;h9hp}(|nmipZD==vNV@NS!)V|f49Cu7Lp`e4EPV4Dk+<8oepDaTm79SKZH%*I9f zHh_O2tbeis0_aJ;q#;v#Vkngnz?0r(SRhFDP=7<>R3Ucj+mFIx*Noaj?PMn$QZ#B}NVF%y z<|ivufCEVwBPk@w`1q$)>9=h+Cq^LgcmJ_lmc-LWm;1l`AHOL{ghmYkQJHcs458j& z^J}brHD zSn#6$uZS7v#?6D@tYczR;q&n0;luhV@c5;f1Sjf3ssG*->sG;+9teOPG>|3jMLQ;s zL?NK_aj*&=bYDjUEBnTe-dEImVZq zu&1h;peEM(hT|enTYq-}t23Q)97uK2@YdR514Cxac*$8;*dWIZK7^MRkj*zwrq`xX zaU~!-K)#g9e5%1EheJSLEWv)(5*)te{;N%P*z|>=v-}nPqIfWBsLE* z@MBN_wpSx+SHR}7UKCJ={R@W_nM7P_hd)~lT;3G{roJqM8)v%Y;8K!-dDQWVF$!O^ z-eF*D<0#;xx$$WE^W{(=@IFQ8?CV_UiuO?#5>mccoD9=y#=pe7HHU;D{v*qNblGzy zxxuGLbW}oWhHl)7UE7-zi5DnLzFIkL(>l2%S?B#Sq3O8!ijq6u&RaCE<5y2N-RIMd z-F@EnxaoN*U}4A|n6nS#{zclH_?|R>Vt_MlS}2eL8)Pl9n9LI}_Z6z6PP~WJ!H&~0 zO7bXLft0rmJ*B=6VpV({PI&DsgRAXz7*G!?yMFsR7!iC59SeNj96?$0)9yI4zovht z(MIWQ>~_T#mGsW`?< zfcG+p%N+KPM*lghDw_Q7BWdIQS}QA=WZ1$M-vd|nJ;+0nR@0ZbgM|c=O_=|m^IWQH zPv*~l;5R{K&+G2jyfH(uDuu5|s1_s12*M2ueyUc^Z+Jbw;7ma1I89_~O5n8ZsKEmM zj5%JCnbAM~tiPA3^#!l`z{B43I>&R*Dbch1_lFJ?#}D(}4u?PiI)(aR$K1a@H-@gY zXaJG`*mTrZDa?mRK-Ks=k?Uo>BA_me8Zb4qX($EmP=sQhwYt6FA|16;vmFi;ewYfDsc0mfWD$gloRwDM&NAtgjg1r?M>a zFS(@RHmC$RdA$ZbwXwUTXa?zoz0?K6PN#u~6(--2-skUA8T%RT_R6Mtok>jRjVAPU zHDx$l&+<`Wtm|T1VE>Y(^KdQKj$n2NOExphg?*|wSi}-_g_A4(XWW4Sm*X6A!K%R~i(vAog7oOySm*yzc z&Fiv-0Tk5lOJLPl)*7)&5E{8O1{Q8l0HJ5QEO~`P6cE5lu|R_ z+be|cvQSJ=+-*kZlr2%SE-jQGv31Ah;&nEP?L|Kl>0$$cj)L1FR~Sb#4H%}(Gfvz_ z&5-O(tQpbZsFk{{R+tGKkar&mg~w5e0=`G{Jo4}9&cMsV*1GI=tTC%KixAg}pQVYj zbW2twd_5c#OW|th3@YDviTW)NIzsj~&5Du`gGnz1U8njFLWO}rK@(s9xTHlgXDu>7l|7QZD#=#4oE`XD=JPSn+1bxad`4~KBZhQHBM}BuUF2XFqas=gC63M5B9}Ysd z8E?i)HXXzFEOSWbcN;(%ax--fk2KTGwvkIeS?Q{EnzItbMCW;R|S z)Hq7~MvNAy(Qj?`2*v>13Se~1Fr&lsBFI3JX!&h6kI;ejqMxfqKH%5M!7{C69<6az2k*?tCm_*1!cJwaWn)G9tR<|#0q zM)Q=Fwu1w$hu850tKf~|ti?)O4X=^xmkb3AbCq?e7`L4+<3NVW@s<(j`a}i{g74+2 zzxF1qrh}=+C|2ZZ@~u^i7r)w%uDta~g$R%TMX$IBj2>0IiHjykZ_pcD37g)SSbnB5 zg4to>P%Bf!V`~u6N)-V>=3P1$=IY*6cN4n{>bdqUJgyPCzL_$t$*bP{c~&y9`+6%d zy5g3j8vXiN#VgDQcwPRy2&c%mJ~){%0=s{!?D1bWq+gW0nrK-~vTirqnTURm8&AWh zwh#4winz!(B1~`#7bY@_CJnmqLxBhOSWOXWUPcT5+xO2HDZslK;j<8;Ty?;s%ioyn z;pGv%sL0eB<;?J8NsYMky>oswabQr|h_H9zD1AO%g9z?mMw)W(l2)qQUb1rT+LKf% zP#;1&DFl5vwnSDndWx(IgUF6vNS1$`&_Z$rMJl3MsuHXR_n z`TWaOt0q^VG78{z-2eE(@2D6BxM?3U`TI`6ZCUIQ^C2T#IQ??d&O$>EjH$<1epNk^h6F9B6aUd3HINjj+O+-o6;jRD<`3Dnnw+CH2jRZ=zCsHm(H2P^E}$jhk(Nq+GTL zj*rEn(ll)aeZg?;u8M1EZ%x4Z^pg7iIMU{J&4@56+at@D5&iiOU1vL27)sk)+3zv| zLQyS)b1HS_nE`7hH}D|vx!M!{n5TSSkz-S5f^E?6Yb}z6-mw(IwrnB0dB0I*>oedg zdEtLRa^s=odV{RDmzL75hU&$Ry~9c&@4jR{LoaztZ+8mO%AYc4&c9LSn#qdp4&YSE zsQAuKD>BMamOLgu&bo82KF*(KmLelCnUDHCyB75>H+}Hi@y1shT_h^NFZ4g8cKG0H zXmq$>2boAQokOXrTmyk^IZ*`Jg{$!?Gr zN^0G%iu#*;Y;DnD{l0~+Fz2(rGkBeHFIemTC`2!=WZYWF8YXD4JQHyC? z4iS#L;@U+j9S?{FKql}#V|$!K@d$t)ALxV?g+?}cC>?k( zqXFpaXH|QF z)i9Iv<24#ohV}GklooAVpG!g4Kha}6kB`JfhC08?Tg)R|qLfB!WJXY2KDE@h|IDLH z+tl%f@L6sPkh{xZ=kWJ18Q|gnHyR(B21pymH$V^{GzgN8_|K*XWh;!mj5N#zJ>d}Y zh7C=A{0wDXprr_gEQYdaSk#fL_&*#c_CO%&JoYS02cdtx04TO4LJGDsNpI($(R60S zAWr50#ra(M7#hi=hE{}C@6s&9<1qC0#74~cwUC5 zqy%%erA;si8X*h7ZuGBeRzjjCn@gj);mIrfyKW>?AphuX+t8jXO`)CIreOgGTyJ+X zFbV5576CRy5_M;;k2J5?mA6tV4U4Mi4FYnt1X^Stzk_&8A2-@^ke`ur*nHdELC|iL z^@B9)?#rJ9@}>vI^+C+F^n+!*jn+=#U`8hyaDWFPg-UuB1W6304qnYT|K98M_lhD%tNDw3i|IdoXkfD^5`+gVq! zZSBp}`(ZR;)DcJ0FSKeIz|+<*a&S!UT7+pC@st%ACh%>t5mlqv`I`Tf29%9tQG5o4 zy9W%mR>a;OvZmYa>rh4sTcr}~g}5W{KL4D^K^@ia2xL^3{R`F|dvuzFi1-C31|cA` z`^V)WuEY#`7y!2*Lr~)=MXkie)5AZ%kF^rbfuUs6wwUO9h|k|Uiy$;En?zh~ML=_Ug%0ypq_Wgu1pJZeLwh~%cWS*&Pu=I!E} zm&pNB%XTo%wrD{Xwk@Hp9B{&o92+8=8IXBv8JENk3gEMLQyxGNC74x<1z*&^OR?)V z5)A$eaYpqT^4|ZS;g{bKHr%`R)jVaJ007{Jg{ivyzaH#g3;KWUU-!QQ;sgwEO!p%W z0AM|`FcVS#cM9Aq>(U=4ZF6zy$+x(CqqD=n(Y-h=2oC4J;4lM3H~~10T{2-}Ab1!M zG0XxnKk_a3lmapUJQyenH#FQwX_P@T9E8NlsN2=CwPiv6_gb@UzRi>95qx zf093}-DZ5Jy_FIq!M3|_hpN6@Us|dV-AaMrO!!vy zn44;deh@0at%2jvwf+S4zqE%KvqxXJlyfV7Ox{0vCEOn0#Q)_3%wN>v+M1?6%D&>Z zE zuRC=5d(-b0834`lX^gd_3lZiB0cd#ju4rv(n^_b18l}@c6Ur^13DGR~T|L%Gnei89 zj!;AY?iMs}HVo8O8au+8Z3LuR@!h*%)1)M-u+@aO_h*qv2TSgbpjA$uFIHD}r zDXf*y)EaMNpY!N|IZP@robOJxmUR??3)8`oEiKsJU>S=+2H9MdXPpSPEMpU{!vns2!(Cq7NtF zAK@glW%RHvLG!}9S`gKAWKn|+5R{XmW?In0t2v1aPuT?oOM-=t6aEXGt|C#o0PXD^ z`o0LrAZ8Dx!!xtX3Q{S9I^PRNHqt@juvhyfy)=Q|rPw6^%FQ5;lZFPSmikwY5B($l zbUzO_y`_wm_r=-4SzGnbSa*%`R0~R-zaUW!4 zw4eQN5BCO(N2((_N1-I%TVa3zA#8&=N{=(KpBe!6J~892E$ur|NVZBReWb!I+Zy7{ zBH{UuZs~0YHXgLw*#5KhZivQGt79!|Q*Bhla2hM>ZBwo;BW%{t!?J?>;`8R~{J~y| zc2^|9{AB7BiD>Cnus!VBpd&cII3TARTR2K7D$9TW;6$NRoL%?f^WElceFbXsZ{v@0 zMohJ!#YIC0KfoEZM6vdNIwUUDR2fU9=erEN_V#Ar~^>fIm7L`%~G@baKJX> zu07RS?0GF41d3WfNl+MEfs3d`2Yi;3{IQ0dl_bj2>LAaLH+qOUt13T#YSMq$hQ^3z z;or4HE<|bdn4mpDO!HBNDbe4e{A_XqMcilD7!?Kiwv@SShE0mM6`scJ*cE_=@ucDaI3pf$iFQA{oK}`q>39_Uw~VpZCq=O!ep@{ESo02)4_aOn<1_Z;ypUnaAw2HRVEpRx}5s%OkNu_rq}|ypHqrtP+7-1z^ut zI>?PEX$FQ21Ow3H4yhnx(1h;o&XGnP;!8ia=y4c?*3KYB>)`C`Cgrcm<~tY(#`LXf zkbEHZqtJ$n13j6rvVM@x{Fm&oCLF+Kn4RL|7S4ll602_ZL%7+Ix8t(hP{XoveV z*ut*SF_-FN(l}AG?xemHnzSi|^%k>=yzmhu}EC7`79Ge(_nAT;@x{yk$= z5QE(eniCEh6EG%LRZ$ScmQp&H`_lT?MqVI?*S;!Mp-GxlhKyJkmOH{8kqfX$l(Ab9 z%7|ZWlWX>gE%OsRu|rDwBpg_fYG}5%j&jzJ;alRIrOW@Lt<6c{i#YC-5z*ao$a&>O z_T!@&8sk4U3gSU{?trceT*qyU0X~pTH^t5*+bbbT9X}y)Rann7KTwO&$fYS{H9A`A zT!=O2^Ide`k&13@(C#(FxEXc;4xsVFMxMzmvq#OOFOsZTpEjIHGQz{M!OzzTqSaIb ziCy5r?aFKS7x>5X7yrS*)3s;=!`{k=eXYv~bMBlLQlpXwLdOS5xc>Bht9=NvgBO;t z6~8xAzr-0MK69NBQ4HRyWUj{|*1Cg~Kz6qii2Yh;Rf*GiHAa2_EQnbqY^vHI@7iPh z_i80bvKa@#^mYxN=gFMV-!CHGQc`E9U4~r<7X9yxd@j!2EO0(-*zJSdV=Ay?HtzVH5f>>hT^!-8 z6B!S<3+1M0TC zL-WCfm)@8uTzzgS2Oa%=nmmr!3FzHQzXbWN=6_y%S29Yy7!Smq25Pdb%N&{69>DS` zdE^p(`Sw-|XNDWbYyHC+h2|s=ujNXRH#hMm1ia^>IZfY=+#Swd*cH}3ibRc47{Ma+ zb-AW@KhCmeUzTUTX)#?7emqpk0vHAv%1#g5X<3n=0B3&X_eZ0mmJ&i~8Con{<9GR% z)?@IxplvBqe3Y89VDpGf-}iRLjdJkp*mkwnQ>BgpdGEA{=jRT;Scwvv>+=SuziAE^ z?)7omN-@0s&s32tdK+~-HbSRE3JZds^uccUg!o+Ckt66@y%d9J=FS%ce3dN=IvWo) zwm80aY_She7Vzq)<0c7FCMM0E>m~94BoI0Ve8R@t`hZ z<16-YbH-e-nj&sH3`Y(NQMsg5%Amn%zli(*=d+2@7VvEHqZL?{M?#aAW+AEy$KyIyi4QeRv6*o*J#`nZ zcJ9LXRGUJQ?HO*l;l3G9Atfvy6(8FHvAs%|=v>725F{Tv{4G_Nb#Hdn%Jf3%K%iTW$R3Mf@mi zhi`Bty$)(N`$^25^7G$p%m}Oi%5y2; z443G`{1{V`#`Eqh;!rAMIHaRL-hTEAIVvZ$ls{R# z5h6`Yy^`^VaG!Mifj$R6`H-R<&h0YfzWDNU;GDQw?s1u&%iHfC$^77?XSph`5pl#G zXw%gQc}!ka(H|GqGtHCkTTr6$7TbRy?vj)3E4orz=wQdJq6%V@oEdn2qU~LaSA42f zOIw0TAqpRmyAmU<3Fk~H(-RYPteYw^^wwFE$o@GnnIJmv;|x#;m_g%p5pDLR-d#xo z?QwhiQPjLELCB6wNTiB0j#Qof_;^q8>vU5SJc%VV7A16(^#%l`g&oT6rl&5}^EXcL z96}g?MI~|U@yZYMLS(<^f!OMNl6+jQk`xKaBg{oUa2YnQ~rR)Rhr!cH?SVB*yDZ+)eG;V5@aAVs=EBylLcaS>(pu!r5;!E3-m0|a(w<3wiy=x39-;Cig_B5Ep zLe0qLJjLhka^5oJH1JF303)57Oc1NytM!#oUMJNB3KtfiGIig#$8F;GAQIeI7+>Dk zYCtYdpVK2%N7z|TbxO+ypYzB5iNYgTh7(CqFeDgN-dLegCI!WR7tadA<5FN-74naD ziNZP;^|kq***X2yB}Y}-Nk$ZLlqf~c5dIECoz)^E>86tVMH?$7@h1**&SLsV z|9l%eHDa=2Gv$QI2Eu_gy#)b=G_%rQD@?bvkSYg=t$!-pZoGZt@jsD z!Nc#zhhS}p41_d#gB?tL2hfJ*0XKA^o?RHKk{07#aOeM$1LoD6Yd=t_n`nr=@5$mI zABd&2kctloy@&ACAJmhh@n57OghOSyvrfxL@`gm}HD0TWInotx83s~}DK5Uz>~Vg8 z*6l0*z;}{51Gw5u^!Mkk%nsiy?L!&loGmEf%u$_?mk}3n& z@W&%{Ro)h8xvvQLAfuWPLe5av6M>K9<70QK??y4dEjEfo0ZK&nQ(9vLSCpry;h=j; z^?pW5eNX{qp~U$E_K)meJAX_ovaF1W88p$wgJantT(845zj({6k408ccEKa8_afTu zN9PWCKr&mWnV)$6{xqd?>{djL2X%4c$lb&5S5!L$PnI;5D28eGPY_f-JwJJq|@;dkJ9tIzc0amf`TTwSw~#%KeKi-MFOUTyLn$Wo2diw4Wl7S zL8jN+9pw@o07pODCI&XAze+B%nOhhkJXX)ZU{ z?m=Wy`B&L5K{X>iIGT|VhLdKwnIirCPIlkP~=mMo;@{wP*`l_Tq3xgo3JKt zo21;OGFS-yof}#v(_5J=%UG~Er2>FiwA;M!c#@(J4inXr;P5e)sG(h~YlA0Mr9MV< z(w?4!_c=Ud#x4V;?Qs`0i>x*rwB#SpGWp$ErGoII~D=TdPIgx9%Y(8)|x ze)Rodi%queGrb%PrbZ==@)*ahw^1lJuf7au>0*)rBIWcJ>d>M73(~v$5BD%uJF!72 zM_Fop3zVuHSC+El#$5m-ZwkXJh1@r0NTzAGeJpgfFEs zbH!BEv>W?A(9-j6Qn}+sgiZyis+wXv-beJd(To0&BE>B0P5I;5QkT}d5)%JZpL*ZM zquR8@ifqZ#f@I<&I3O8ia6r+-l9XY(jU=eI4>49^;tKcgjfsI9WX7bUWZGd7_eph^e*Vbf5 z9Cho9RC|dScYq{$r>irRDp|+P4AVy92rV=QGc*KvsS>c&+z*qsJ9n66hSMS}mMYNT z+znZ%)f_iZH;Lw_Shw0Sg$j3^Ic9*b*bg?(k5w{n7pw#wtg*$)X9%FC@eM}YKUF`C zIe>K@KkkO%ieRWaSzD=2Zu;Ya=~AB|&Dr(zC*T}I_M{Drn$c+1v`irQ0TOL@R@-F0 zy70{6SVEGS?=;`qKqhspmJ=3DhcQ5q1zW!pY=&tmp)@v0o{XHI;*}IrV5vaGl!hjWWUC_Vzoitv3~CIIaD+0aW_Wi9JU*r%qtRUZ;kFS;l$>3S5em zht=iFTYxS%@Rke2@b!{EA*M5|fm;ni$Q#h?71-}-+k5%g2*ke40Lu=C*YmH1>ILSm zF{ox@svAvu2&8p2A)heJcr{pZA^@Lg{jtEV=y5{_VG%}{D*h0m4&Vm7n~&GSx7Y^F zSRIa*H_r7g@l`=up5}QHY5(99@8F_(hE4K?YD@0E?9)EZjZf;Q9T_QQxPt!)o6~U~ z064YoIY$d|pLUafpA)dWZXe;W+zzxH^19P2Ac@WA40{2zcGqijW3Quj;FVw!`|Y|IET_2~n9FJ0x}stq@!^y(4qKNC}1Z;UenZ{LK41y{8Rk z{PgLbctRyy*Xc!{gCDInbE@zBeJPR5P-sbG4X5_EpBF-ygX&G&o2kp7C>8pMU~pu^ z!T^Ci$NLn>p{T;Fy@#q1jK#M zp7Z|0KD6&|CR9o<2>!{#*YL>Un9=Lw+9kp?@bAsnccupOa3f@eVpS>vFu-uq-Y;`B zAen$ofn5tW*#=Z%)zQ;)6fC+6y+|uhO*;}M5X=*EX56G9EkrqhZ|=?Xl+WXd)$H^h zR;D)i>fbEWFXye787GFv)?+*LKx#Um#|PFpipO6ZyQ2GGH?E^8TcJgK?k7ec-pcJ+kTY zl#cf(s~jt~hi9?4o7cm4Pr`zaQL*0mQiqh!lyRXr&dKuS#`(Lq^+kdY_| z14VTO+Ru-Z^de-@03yIOG&Df&V*XhOmwC4-DEqQ!St@G}Tt7a3b!C7}J%I2A)aR2@ zjU9pSTuQX=&aQqSwBM49E|p30GBzc{_RfHBn6XS^`r-r4WrUvtulpxa-Tn8n1zg<5 zd$y;Guxw!k>8tc0_!|rCrz*tac)Pu>9F#6B3CZ2&jA{E$Itp*kzH1W0DXwwnoi7?} zxH?*|`e0{W3+5&%Cir3aaa%=M$;)u8X*kSjE;Xj$+I^y)eS5==`|A`r|7L?SJ8Y`f z>_*H5a&ChM5;vvAclyJEm2S_w;~=le5h{{^r2PQd;+u9q+lJ|+E&oLgGv67u3JW;9 z5wf?N{K9jwo4g%%i_v-06)L7mo-?77Cjm3jOTi0NkJZ-;?NSHE@>K$2x*yWNWYEB0 zB~XC&;|KR}m!;8L^y}&q^c&hP_ynASJ$mgPD1MwDYP)p%^~635Wn{@I(% z!Kakt1jl@+0GMZgA@5$BJF{NQ153A{IeZg@DUPhpEt|M zRMePKS)wPCa?2TTTs7J6(F^+8%TWQCvQ|9K{Nx3 z*%z3~#4t3HXPN17swA7XyP;ZM7_iQK<&qb|w{o)|}&s#_{ zKo64(IIigqO&o+Y)RZx@D2MMWa#_h0=cu+zT?u93@>qq-y1T!v`$dOi_WN0aHQWbTs|vXwpE;OQ>0c|Ar|llFO5d1>upJ4Q z>~LV-cDh>#iQ9w2Ah=ju*z}RM=7qceSVbh~Gjv5@KrJ$0SU>xUZcc zZ)A22wEaq(sWS&iaj(hYR`L1=lHhU(_6e}p;>AqQu-yFXP0cqk8Q^Hcn0M4HYn5nk zVQa?V(D6c|ftk#PeMsU`&g$m;*7Yc_`DUhi6zF60&Pbw!_c(?==0a?`t>&gZma26P z^(k)7yPC!FRk!|CuRz<}Y1jt%C#vT|wuIPyJ)w=Xn`0hl=A>r^(AK<@c0NNbF*mKa z>@HVqVWrTeodYu|?I!*qLuo0|gB*aNf`^ac`unOy?VjY>fTCJ-eeA(>@q1M-#>KzH zQ>dA(magnhOg@c1C(@kE)VE;{D)nB5xc(3l8OzS9BfijW1EWSA^Nun^cFmJ|(my?~ z4!?24>-lS@_E@~C{W3K$?*(EFZv&|mG6nMVo93#nDVUfHcR#N|1F76=)8@jliFMhyqM}ZBD z0*-p}T=aK+h8g8X9GMH~FtgM=_2)tRUf#UaY~~Z`-TLW5OoI<0&tYzhHoy!H0&49p zD+e3C%)>_~ayqj)VVBk?`3i~LHWozu)*9XW%YIH_uyuIiU5%S3qZ_o}AjO5EFV!RXM;ttpZ4fI} zZw!KHLTrpbFd-{Z&uy^@W6Vn~9ikad*FudGQ>57L0|Ze-O%InpcQK$%H$-$P@mK~t zAV>mUiIR_v)TCuP{eESQdh<+2=s=`1V43~)0m4612PIdP(V!$r?dH(QG2z^8!C@rg zCgTY)Ra@R)J*YS}XgtbM{!4-H>=8oBX`&=sNF1uXeW%1HG~2QV!x(2dx=^#Sl#O+Q zW2hbfl9D*LpKkWI60xW7lVZ2 zkkM2??ppx8(|y^!?F@R<%jFul3JMtG=E#2`gaMo&r4rR5%RZ2M7dJ7Z4r>cHiIitY z8S~3XYhX(q0jusT1GZq|ah<;Q8oTod6j>fumoq`>Vaij)gb51#!!c@x+~A_5dp2t3 zz>jjRLBm&~y8Pzi?TFV*_2itq_)@2S5#i9@qX&s3|2b&C2MQ^UWZSkb` zK7H0uy13E%I#T%nM8E-TUo*X~8b+{$4L9vyeTy>UDB4(*Cyy;&rZo*!cok{z%GZi< z3WtX?MFz?Q`Jo{_*Wws6NU63ITax?kOU?EbgmM+DsD@?VNAl7-TsA+}J6HB|%--U& z=tO?t96;|*hm_2uU9zx&AYe?T)WNm986;q3HQWz`@#{MO8%ixTt0DPX^IW@v%|9C2 z8(IGCDhtgxvlj76KMqruPnc9CLD^?NH%x+c|BEYg+VmfRZ6UAjiroOz1oy*(C5hzL z>JGx6z;w&QY1%^wuwRnbk%lqr!&Z=&ra0HV+riFG)OU%w%lFwOjPe*PZgg|5DRNv+ z!zY_g)*l~~44y`|@3sIdVd2N$M&)sl%{!Ep?jcQ}kmP*1 zrFNplSm2CIOLv-R1PO8P?!M+3TtU0k8bZXIOl}3P)a?d@X-%tfdnt9lOkR3Q>=!z( zd|lvOo-dm5L`h5-%5KS z0g#leTnb>@$5H@D>C^-@hX{okb}KCD49qqlTaRKf3lj;SX(7v6ONQ^iQuZtkkW_2M zA^%7%EtrvIC&6$mc$GF;&nH&nA~!j+8TPYU9Ej2)EqB}CSK|{?*-n z*TS|e@an&S$wQ|ANUJ~} zJXHS_V|7G46WKb{@lPG!z`prH5CV|DvSFL6MIvLMTt`gC1#x`(S zHy|78>7xtGcA0JAlI&oT$nT7nI6hU0$x_7YURsXITXt^$!BYzBA$P`MXYYHqouPKi zmrC1y*}uXeV|x<&aM)wIT_6f3kpV(d@8pNTQxHfs)Jjy}TNe&$tdzd-mBi0L6ro_c zT{wdL;*8-cpP6WWpE2sqsgYmqr`qteYO99hT_on7?Z5vFSkunSL&$*?ztnSfeX$>P zz(H*z8Nx4!<6_J(W*sa;mv2Hu!>qKEUs%4MW@p*|osKcjB>XwRA%=uGjV;7}e-FSw zO$a0s)y?%srrke8-~eKUlJPSm5NNYoG~_MA9WyL$)la(45ZzunVH za;2Ds#t=RZ0`_vB3U#urPWa8*533BxbSB+A}!ZkBs-0igJ57u znD$?-_1Xm>z6*upYpwXJMzGMjKHaU;s67+mO=mKV?Ab7j6RzO@=z_|1LN24=qkqh z-=f*9`*Ht1-mLdRT0-N>zd?TgLwpVY6>0(iZWJ-5ETm7xp3dq``)7KOG4;6l9AAj_ zaZrE6RcY=O<-83%hDfnVE*(UqK-H*14e&|p>T&6IcVZfqu^GMD&>sG9lyH?-eml_R zXGpQ$@Z7kW(&LUVQX|1WrOXiXB^C4)Xm<21|JhI)`1R}oq?neY*$Q3V!Sey=|8@Yk zeFD`KzOm$}!y5RiTHbI0@xK@_CNzS`(w5kOacXo~Cg&)r>3Er5Ye*ZZb5LLwN)2~X zX(Swp^s#|yf-ol3+h~J}49^vOsDh@;)Cv2L1Bzp+fGzVYalA&U^2nvSb;2qW3*>fk4thVEF#~E3LJ0BybE)x*b&Lo_;f! z3iRlcFZ8cdWP-wy(97pbo9Ugj-K1}RS;9)p>djIAQ0`Gp7aQA)!uv&?1oy%gE*N-? zc~W$7zCxnXF*HzZ;6~3q6_I^pT`_rNJP#0aUpY^XF%VuZVpO?IV zHZbD}pMe??83rSB3%$D6EyO485g(ZFFS|~+Us^<_V9@M zyz#yF{P1TXjQpp9YU6;HG-0wm!r=o5E@uZvOU)8& zB5xscB^H$c%xl0(vE}XJA~wy>Siv8e|73bsZ;dj(J7h*ZVHDTh8L8deO3d+Fz7$|e z_RTf1AN!VLK0BP1f^37%7Ag=j)|60g`M2A}Y{>$kCz@5lKqfP7H*-#+Uzh z(Rc!f0yNsaJByWU6Ce%2XAz%4W}qoG<>2r4X;e~iu=hhm9rUVRl6Ct}nt z{h(uhONCiO>Ls{IAnJfdG`o~!eu)eX!({l}|Hleg8P#F6OMJ9j{HsdAGUfA~Ag_WI zOg&R%4?VS^WY)To;YrYJ&2#+Ir1tYPSz-NT>oeu;Pj!Ms;7j1ajcji?43c9Awl|PP zX!6MH1A81JCD=R7^rG$Q0GJZ=d_DdWD7gnL?=D$2?~k%myQl)vUnL3R6ED`y_y&TT zWk)%J5(e+86x6y2_^0C%TT6SmGM!}K?gnR?0l}UyRsuS}8|N!?pMqk$wrXd5-V3^m z1hxh|&d7pxNK8D0n|~xI9!Bc3Q0g4P2GBadDg}$&2)Zx1LHZ*D4nA+`6IW-G!Ri%t zKXqGZuIwZ-a@?ppJRGzL>bE$6veh_{@`{)1!h5AH-5od=; zqqOXgm)c$Y@BPQ@gLpVN``X&h(~G#&|1=IaGh!GfQ-WcTsk|@MIW`jyBTFigD`r$2 zHr!Ar1}(6GW3qjn9Rneu5v?w@ru$t;MgJgc!uwFCfv*jP*4VaB3PQ9{1zTBMdM+t=9j!T*W&T2 zX1X@vh{cyEISK~_Fn)rxbqpAQ12P$4z|$aEtC2_GV{7M$sqpgw2!0fj%@4J1H~IVU z3bVs4kHLa*rKcA2pkh6fgP!q2#buf7@X2$_2+d-%Wf+2(y!4Y1Xm00WIC-5|kp42WIRxTbx8&77=udi$FU`DLRCJGEjUxk4060)-r5z4t+&tI; zasrVz@kA7+@pxTN1B zC+w>J!1|sRBm;2J>|W*w5m=X=JS&mK_ntC~(B*5hsukxYIojkFGjIVbFx&mDfe~{p z42ypdg7wt_0>Dk=ec`}CL5R(~cVKeq7_KZ$$TaPZbS7gkriyT-U3Yg$3F&oMdE_t+ zNRzx<1hfeBpx{9@Ot*zHbt;z)t|+w9C1=cduABMtocUYbM?taY@Pl18p+5UXc8|BL zbC;}8j*cth=?y)Gu@YAn>lpy~l!|gPdTfUzqE?AY(SQ4&JOE(gt?Knjjq$Kiuzw#87y+g zujsG8Xr4706x)R~(`W*X&jxh|(HtaK? zmeq3GYY#BM%<4qh=NaPU?ACk{1*_6kRNjPZbdxgxiNA$S6~;5Tj*noUG$1IZVFf>9 zpA-7oj~*_Vz195|FWjscy4J2Hpx5|V20G&D2-0@mG7(ACEsvHxbnW(xu)gnNh72-) zGGT3lYK?c8I@tX5&*{=4W{4n-kF3lFeDR;HdF(~+)92WQ3zGEUaM%d!#kxc&A43zccZTpD&&5|69j$gmFXKf(Cu0x^v^fkfh1NK2QQciYOp z0e^l~1A|l3b=l{AV^qroykC-+$32cyd){wW+TUakba{6vP0%I<7wc(mC(REPp-kfD z1H%Le{LqfO^ESYiY1h-6%>SC*##>xPnB4*5ALbddsUo>s2ibdI0O9k5lH;b>!oU3w zFz9AU+>_3E)B0d>+5V=o32?6msyJ`8b+mgVB+9r6Zh{;_*sg2*$OgloSPESGDst1k z1ec???fQ%JY>;DjRZ?RRB}Bx zW5h-Li;YmJNwQ6Z@-BN56Zl4&pDSvydN1ZOlatvN4#iB=!OEDOQ_U+MQ4>!K`7U77 z*x5gfIy88XN;w7%JuR=u5z&ROdRD%xUHuVipg1?c-v*8f5(4u?QEdB3Y=xX%ajC2R z{)J7&yk@C(A9+}eWZX<*&3PDw4E~-K!)J5;Dh z-_Ye-a>Q@#odXaY6N@yUMi;jPS>DKeCqub+@A8u4D8mqXs*D-AXl9-D@VK0MzaL@r zZ6WJ93@4(=*Xaa8)(Kooas1sT&my$Z+!~sF(_|drPJ(jDha>P|tO05Se>H0c*5U%> zgJC6sCLre2ZxywvUXXl0j4?7~kskjkr_cfxmvpDF&|C-H=x77!$nEFne#gVv8bt`i7?7RzK~zjkUOdq~=!#7>{Y z`@A5F#~nI+*XWLiP1h@6S_=Z$Av;wX4Fv$02ed)as)gi>rzMD}gA<}QghIx@1szjE zU!=rkB$~8f7msY~1G9!#LIjYaes*gtSRn7saq!Rp!(fYulwo100u>;4QEy$i**IP$F{wv$)^|$>avSAXMw6p~ z_ZLBfoRr}qJNtoA2m}BeQ-o!Ku?P+g75iF!0jU1sbL9U=90Co%efj^_klgxRCuXJC z!2I+5R=WJ3E(Y{F7W|)myZ_dIx)_Z$*FOLNGRyyTF<{@i7%kXj^x=%p%FgQ?9#%Le zra|5-I8q!#6oLTrCGx0naW_!weXfqPvkoBzIqmo)F$bSIA$favXp%ei{RjtgW_|>M zEzl7aU6h0Jc|hSlXvBTn6dc^3QF{B+W07U`S>;s+Um7-2JRjvFmzcnjs@uoT*Y{R) zG1{y3Zv6EG`1SON4k+{b-fuAej4vLdJf?ivjf_@;dKdt**>>tUBLLpCInJq*RR1#z zfM{J8%h#hH_j)<~7@iFp8C2fJVRaW8|F;7@Kn|xy-C;tIGX-!0%HiHPXE&5w2zqTl z7|~Hq%vw~wNB%2JBc~Xo4+tIHljBVE3mm|`l@deFfi~|N6~7y}En_pwBcME`iSLQk zxbOiZFgm!rmfw;DF*FWFWXZW>sD^PVi;V(CbpJ99Lxm&=-LD5H|#I@yfsiE!F_H&$haJuzO7_L$0!x+IvFoA;Dwfmdg z0`*rhQuq4`sYm^Sn^-sYB2wowOl36`5ZW70%Ve7!VK z$cH~gT;iud^euEyzWYMJzUJ)hg~~noGyBr-;m&He4FOGR0D*UPL=2^9ydSwybh%^N zY9K(P)@n7PSa3kjl~8yr#+NaO<7%P}m7w3F@tA@r{9-Bu9L5_|%YRg%)#D%^n z?!yXD-X6+go~QlZJOy}E-{CYl+^I1is-oxG{ttsrY?+-TbWH~}BSN}vs-RRIZCG!< z-H|FjJ7$d0p+K(F=Ep+6i3ZSFD}#BXbzU66e70?7vGY{=zy~f}%vA@m96MWpr$K98 zj#fmCkEy=;(#ER`KAIZVQJ@9`#?m^L7vtcIK!nlRNW*MUC7c_Xn}mWhIqU&*J-GD9 zNa=fS*s%E9ydoec!Lk7t>IE>@Jrfr-!&nSy1VOw7hS0Yo%O7}tj0Y1PhpG5(EMJ|z zm+qnYD>sfs0_U0r5f#(w*yQG}>mz+b6Mok8SsXbL?nNtw{UsN6iOI_F)yHj`2ypqL zZvO+~MpfQMwwI1u{o}d6YQlcD(Z=8>43_EBD?iThi{|chAC^VDL&mYMd0xcN<7Qv2y{A)Y9s*rJia(HScsk(grkR=3I zS_&2C`!0evm|&t3WHBQx7_G5TNCA9mLp__?+RD*|(<~u)HA{a(IV^GC=QZor))MhX z+0{UTMIyD?Ais;I862HD2b*>_SBW1!hrtj)(zYRSN=GTS$RjQbxGjxp_ z;Mo+k$;!%gvt+FB`5G3{E)Lu0u9WfPeY=C~Y9|N9^QN&B4GW8)rs`DOFX1u0dVX8Z zWPoUY_j81pE2zEXRi%v#m8J&;ajyG!isk( zZf5`F)*8H?pR9WpIN7joS7T;`>+{LWsNGqV&op|Mv)T@vUf8oM zRt0NHyG-RF5awk@(%HV=?s!YE`*i>6*Ew&tVCBPs^#u4=#7>b#?Df{1X_{$(g#zbi zXsZHMpn2cs`Tw!q)joq=gaf}*Xb#hv-Bg5EyCZUs;bC-y+9Z5u`;VcVP!VCFY2A>d z375I+2hnqoL*f<9r9XUBPFckb z!wvk+xangusYw&m<|xFgNf=poLuI=A{c>v9Zn;l%+n0rt2@top5`p|p0?|5&cJDV+ zMEa&Z^)|zx4vpBNx&-C|!WfhhMRz%r{SucyX(FN2X9^d)=A;9(jx#ut>FoI!^L_X9 zNYUhxXZux~lAnId#~$yRUV80f+r^Sim?aJ+F+ekPcnlizGqe8wLrS!4w0*DtW^LEe z_!6#^jWA$C%v;y@bJiy)tMA-BVFhG%BY|XmSf|l!Ap8M2lm~9WJ`H&-Il`&u_!z;Y z4h7WVvX|h>-BM#`!22AWPbDy9h^Qx_<2DgU(rh8K+sox7h~DGMJm_?usbHymSVtt| z^(loI#ig!38UM=+EJ$65ooQ+L7_2{;K^j+frMDMwc)1RGB?o;Pp$*ayAZ=p$!^ArkHPwio!65 z5>_j>>KW57sUX2YyTws*p2M9Y2Ff4~K!xV6ZVGI8THhMssHMvKm~&BS^ec}lfQd6- z45+wJia2=agYDf3YFiYL@sg4D8)M6b?6YC*2_qh7P1qYb8L<+kG|YR2a0+p%@QeAf zeJM%=Y8~vNe$n(Wmt2yn3v<6-fbqw=uVIL>fLBP zx1Qu{Rya!<{wypfJa6jfp4&mVav`Ryx}*6mRUlX5q1Asi6|igJoQWxou=bnl5Q8bw z17gdK_6au6|5}IbOK>B~DK-r# zb{IbrrO_G3(Aypk|Ir_K#H?P0ojD)9UR4Fk9Bt{35XzEEc<;rdTeI*6{)@`j`)l1P zOkeY3nDZHn_Y`S9I{us=Nz~u|G2HCR!$`z%yBS@gknj8SptIch$$cvhQ=e$%|PGWN}*0a63D!^p5X!EgonbX_CZq2)IUTqrsxp6aXo52f9vlv zejTx-*=)6$kRE?8ixQ~XiBY8;ivOywRM;_3ht&75> zOYucL`UUIHj^xQb;duBhM>@}G<&9ONxWCK=e`eia5U=grn*QC`7?s^$1XRN$OB-r0 zt}$0bG1D(K|4AAnDBL8-pvHx$TgV}IW{s(2dJ0^C3RYg$xLauOwTH*VZ$j1i5fm8M zv%53SSv-KL^*JqJuZLkNZBMW4=erZV+ONB%=!DcMx?&SU5X?xu%yez zT1k8gGW4Pv#?`P{-hNWzn@9f$V)qs_8zHA5C}z~&M;D4ri*X!_kSf!v5cl>G4h2L< zV6>IIskqYZ{TBi+tj(NCkilJo80Ml2V#^?5$>5vghSL0a(15_?gs5^Fla2$ zE0?wyirKOfo$5UM>WrC`TfR46c$~fidx9^K4u8Ni#U%PW9-beG*F~>o7N@_DM)$Yl z>MH@oXQxF*XJ<%QqWN@Z*W7J15*@a#^`Tb8^-C14VdPBr6$(;Z0G9{vFH#7?RY;VV zHKY%AIqca0c|_Nbex$d%qvvuxW$PVn^e=HjQwc=e_8J8OGtr_p%%cb#Aoys8_`DH*9d^K-$n zxnbb3G)?7DK|y~Yo-&?#eHqY0qc~ej_A>jB<%~#TABl$|K#;mKbm```i2|Kb0S-z= zxAy+2jC_-S;+4k5LKIvNR5tHyd}fvxG*#(soiW?8|D|%w$YB6p{O1M(k3u1mG=dPb z9Sa;weQ>@WOF&H-KYjmn|3yO*l8}%m*e3X%M$MkFa!`qoJeGsB^?e>UlBYV-CC()R z*!?rfIqjvBt;Pm_Yh8o=KSava>a0m55gZG;bp`fRe>@NgTg^$f%_xA+fL?swN$bEo z@Wh2Zrb&>HO5IiNb@rPpulvUZ&%}_gd78O;Q{ItxgZY>8-0Jp3kT(XogWvsAcP{hT zp7erIcl2EH`(?a82|W6$nmt^`c!)0ZNl!jD>(=3U*iiejxpGF_b+*$3+IK+OceM6; z0zMJRBeBnxkbdKR6Dj}4v4DTaO#icQ_uu-DW0~;x;{pI+1OLadKzws78qm=y%h;bY z?RFU+tb<2zGgC$Y*d@iya*5&(Qw%3(U8V9UA(a2C%_NK`U-|^e$*ITdCRTfmw z(a(b_2>aio9AG_Xe?^h?@p(x6l0N<&XTQa9E zsApsj0GV$NQZ|?2pt*d->H+L+FXLHA1sxS$-5b{Z4VC^ylyUb;QT1B zT5?6HvyunZJWk_#9v53kiObNGNDRMTY_F|osn8)PHwI&5g;uF!sErr!yXp3hilSKwKm^`1>BMK(Ydh}3`~De&zUJxl zJxo6CKH5-ERY{WQ0y&xX1_J-GvA28s@Y5pb_q;4}9^^%h8bshx1CJ&T2O5-nCdxSm zkdH;vJNYiB-R&pdUROy$AT*kIIFb#1^?JS+v_rT0Wq?5M%6(Mov!XI+M25qoMngwO z^&0kA$mT7H;vNxJXt{_HSw}(iz3OQb^T%NjmZ#;JTZ1f)w|nX1w@stllPZrvU|}q= z5kU;Bjr{O!ErJ)vbI)&u{)t?V4xNz@W%$=DYi!DQ{@q7q%#~+)Y>y@1UN=79J2~JM z>DhOu-u^2RW0+N!*xt)!khm{KUN7&re-OA2YJMc0?Z%Y-YV1||LYK-FB$LL|Ayu!i9IE(rnBozN-NhbWwp?4Ro5CW;lr#ssi;5_ll9qgk@3~mEapl(TrkVGsP&$jUx6;Rf}!`q>xd{oT+6n&{8%k&w@gw-TxZ z)8>aMEXCuwlSHv}(KYHZ+20BsN~+C3wIO3e?>v?C9cO;)up8|&O>LcCCMtVL(UmZ= z?YKN8bF}lqJ-TF+__miA1VL4E+#hw$2Z>NY*tq`e)l)lT8btf@LysSIKv&={Nj0{Kfw;_{ajYC>_e4?@wWPk@rdu zCYcgKCapOYg_+nKV8JIkz8uuxdCi?wEr`=lZki7PB#kV3Y*6`EYGgy9P)3&rB-dql zWGtl3ph{VaXVasvt3g4h{KX_@Va232_n(*W`q2RryByXM0U7bm>SkcxC^bI=9UJZ$t^7@;{w^|}_hDO=y;zJx91173mM~Q?SF7$(p5g8e z+!o5|l2O{#f*5Trk0j0)73 zlDYLM*Ll{Rkv!(}))Vdo*ws`5ZqjumZ?(J{_aGN|7!c8^)aK#^GYfYb>YYVT8Zj;l zZ3jZKIe!C9ci^n>c6W!-kULJ$=5S$c3|67?vFCMB4V(IY5qr6z#&Bi6xcY}%pRNYu ztq>WaRAE9z2BrNo8b`D+xK+S(_jjA@Bed9LK-nv~!&caIn}<>rj?5U_^De|y?z)YA zp@tj{%R&KPLJV&yKNBi8<`^f~BDf3)T^iGC2YO=zLss+dym}#;dDwYhVUlPTatbaB zqVv$rjCO82FRL~`2o+Xmy{F2x>x{_2aH2s$xHFE)WaF|IOg`G`JB#d+1pv`AgZO)XTU;Kx?Y#$|gZRfD( z-vw!ZwQXB*>@yIO1xaP&@evW9hPp9Y#WaloZDSv8E>o<~T#uT_4iE-X1|TePt6LqI ze!^6!Ter2sY?4UmT1m{+rwS_J^Shu|l>ydGM6hIeW9sq=?Kx5e@VX12@+LKF4AFt` zu3bA&J;P6z&8i^QH4S;42*DV^xEAes6mFncrcC zXlxAw(BxiV2ew4=>nHs@)3dt_v|UygFQ{LGh%!%xzfMB#OJidwQsAdP zt|Eg>9BI}~G3r#wF)^yu_N8m&r#QbM?zFPv!G?W#DHj8OybDo(lnBLAjMp)n9L$BI zrv1mkx_*qkNdir4f|tQS1Eme8xM=TSptTeU&8rl5uCYmrT8|z-TK;!4uAiN_Y-|dh zGY2BMk&J#1-oE2RTy;==Rv!I~&iP(lIFm=*<#-LSYT7BH+sHJpQG|BF(aXa2BJc!V zRS%bDDjzYR*EL@&F{LTGafLCO#I%>r9A_KJ=o6vdrdx)wIO8&^^*t=T3UyRTr>Y7I z;Kw&4)L{-y*8JG81dh8{@~^S?+_i|5CJ?{IOd^f391Uwb4Q;xkJ;kL*7J8*PJ0fbd z!&Bb#$s&JeNaqhcu^t8i&mKP|AGxV1cMOAmkInerf{Uqxor`I3Yp93jUg>FQu_q@h zQ{jT>!00-Y7u@J>tSr|^$~Qe11P#PJ4uxi8c3!M_d3N(M11O^68;Mv0=)5x|3N|4HPIo$+{mUqE$&l;c?;vQB zNp^a9lG$KJk4gdZIGry}a>TW%W0s$ehYTp#wYpI8b-bD|WWy`maUHvx9U+b3Eoytv zfTGAfv~tjtDb5UlV7Gq;miJH2K9_MMBc{NXVm=7cv?Nzj?70S9hC?}v4ndm)0~{Yj zEBQxkqYxO5`Fc2v`mRa{q`5`E7{HEvPy`_LApv(e!!VL!rY*`0qcZuVC!PU^xz(&-v7`KQZ%9Q`~0+v8tfZ%ELfOcza^PNCTqBU&s_laJ_v8uFQU-gB_{C+Cu1<+S|1wd7Kig(k3vPh zA5_nC?PwTl_o?)yLa65j@&%f^+Up*HydC}x%>NIwLw?6p|FduR-}(=;w-EeE0|20= z{)gG2+_MkUVWU-su}8mHk8{1QuS`6~jkY@DZsHeG(W)$z6+wLf>#yN9!iyxrXN?s) zg&_5U79mg(>xSTTYY<{mHWthJq%p%S$sh&l;1{7Zj9WSkv~|G8tL^S%w)^D_RYuRx6iNZEUsg+8$VqXLJmQ_LsalFfV?$f{a;vyG!Ul1`iZshssOU}Q)~anpn7&pI8SDOWUOBPcH0fkw^(RNK;_Id+P0*RyMY5So9Y0MG2m2KkP)m7 z6i(1oHB=KV+N6@(BJ0Bh`zePEnZW$GQd0lwO(Gr>#@w${8N+5oeW1^H)U({@k_^F4 zcKj?l2B+7*e%+^s%Z1`i`tZI60vtPGU={(?a`jtvI9~;2(XWch{`}+#n1i( zMyXmmTaj*5Y$z|2>Km7_6%?PO-86;57)91Sei*IK^pQ^yDn*c;gGD8h@lx;v0Gx`#*D`m?i*gHR7LCJC9oqX!tbVhybYo#T=%^ec+Rf;18eRyY)#080K|lu8NW(5f9;Tlg zidkAs8RaZ*ix1rwH%(v$RFq)2*MhPB;Xm1vQQ+qasiU40LJuv#p9u=OS8*m~pG4Q= zEWAPWJIX1ge|Z2o=jKr#J*To zF0`mWH4dZL1hwOa6Z@>?He&&lI4crC^H3M(jNaxd`J!9vE1@8f<6`lYb%M3Bo1c zsk9Jt?{+KIm;PK>9AYqjL;ykYB<;y|C+z(eVtREw59SE&5x zk%w-KSPy@mopNxR&9O9H-9F@z9-|G7m$uW{w-+N0T+&W(TbOECq~sq&L%LW)E^MMkp=> z9!g)Ws&ZFKg%*NEl7NuBrguTW9*7B!U!&~XHTxiVQ zXexexjp$&LDFwU8Y3GrvB}9Usvd!!$yZWQm1YK#YJQ5oO8F~ewC2fSz$%p> z-j=>W?1B9%ayZ-lg+9%0016F1X@8w=Fn-3h$B9uiU+V1}0hnJLtFg(&(tOLS`pQC?Sbo-Kfc?GovS;s)et z8+VxDT4NFPCH)~%G^u{^#;^8mz7>YMwH_z)%GKsJ@RveO7oUfb0c;S2Zbt0ElgmSU zI%q2#ScB+k-c&(ory3&e#vh9H%Wid6N+=ABVRim}Ai>xo0VEosqK?mPeLOw2Kl^|u z0?LB9${b*DNToCqc~w5bw_?HpkXRpE%4ruzc=~uZ;li9OFfappx1aJ%#(pgV!O0W}%0yFZ^@?e+BxQ zlN#`uD-z46i()JOwpwNBQ-+?0!8x0F)-S_2I!!_Q?Wo?N3-(y8?Dr%Fud^?_n&5ss zR3VgYa*v}BXL|}qg#2zu(B@9lZIOF?Nkw z5FGU8<#}bYslY%O26JOko+MP(S&`PnycKoe!y|8ehN)CN7o7TRBVe3eCE zgEEW<3JR?u-`nyf5DfQCuq%6cE%ynJ8kJXuMwB8D{Gq%1Sven>~{GJZbX z=h;XEDp1u>H)Tm#<%hoR)C3WPtFAx-|JiI&xe{{JBB0pj2@dVtv6>b5+H|i77>SCm zIMEgr=uVYe5;*&kd|?!ZIJ?9llZ^R>IRA%!puc0H|Jk?uZ~cdUa>TgC0RZra|Dhk4 zZ^kms%UW3sZKSt7Zp(%P9ahJ>(`Sry-W-h#1{x-k_!$CK77ZMYlz7BnpNK-yoJ}>U zbRDn~F8U9VNmw)j#!>_(6eU4G9*mSZzhz4?-&tDP?Rxt-q22B3vBehpIh@%cDJf%X zJLlT1$H~;Sa#zon`U~`Z{F<400-y|l0Q~e8|045e{7pCG7L8;S4uAr^Qcp1?2DJFM zk;O}c--MKe-OsA4HKe}{>)X@f6dE_BpJ%n=N?*hHzncIk3jd$>?kgy&W?KO83_}tm zXB?vBq-13XBN+uj0cAi0k(^`zm7D}gLzFB-k|asVARtL{R>@h(C@2{PJR`s7)~Wk& z{)bz2Z{0fGRXx3H@7LXHb*<^%pPOTxOp9VsiDb;8Z9b;%;hdv3ZD6pZj@tk<2`@{2Kbd#~(wXC4%VGzTuVch|!VQ~EPSU8H^A zF_#F{LdqVd#pJCY-V8aEY7`E<%1T5EeDNo=!(;j(IuH`q4kEe?k!ULH8&L(TnEf*T+v|*!5j$31 zvAJ+xo|BA|2qU`kh*_qm##dYg1^Gs}NP{45f{5+sEuhP@_Chn-2138A8zR0rqu>Ry z5WZ$r9>Efh`*}BJFGH0B-E-JlV#QHJH3tK?wv$ZR@S$)&s<|D>oG~{ftAezxH&aDj zu2^J>GTzy<5oYV|OrGQV)ePIyVOHHK&BaoI=j$bci;ACxt`x06J09$D$RWMz3^o&G zI$Eg+CD{%Rlo_&a3fXyjTO@m~2~#1=jDx}~9L{3s%uqu&#$xVLTqTYQVKDvP;=$Dy zLG(;YXSYB`5q@(|2jUo7wkog|qwg170NLhgr;LPX%~I30mpR%O2&qi0w7&26xuk5M ze?pqAPMESuEM{1rAm=9xIskI?gp%PFz8~T?1O;8{HP>m8 z+WQha-Try9|CVI^tr;S+<-p-IC*x@`*XM!R8tRkCZdO@m7W5@DXFt&<4^EDF1wFm# znv8mnDmY}GGV?Wth?PM8=L#2>cx-Xwtm%Nf0uwj)ePgsp+!AUMUkkI&OtQhPrT{;} zGV0+uzvOE;w(pA-o-VZ7d@fS!n9pg>6s^>BUOPVExxuVcukkWwD3U@-$f@{ITWy9o zWazoZ=drYHb~Ocos&w3!oSxB@4$<@vu3APwpS|U<78`VDd$NGw@U)mWEgaQU;fq4J z6k3(4IAV22nvQ-NlS^>p@xz;8EPu_N{7INYQliG zy;m@Znwc+jlU$eBM%VnS8)|cAgh)-;j~_}LM zzV0XXvpnZ%yW1{R4y%qUo(p5{R{W-pE>z+AaecliPgLC&>e@xKJK|rrumMz!7z5g5%&4iC?#N_S%?V}DJV zelz~L%|kT^(;-3fI4N|VY09fgvU2>QTXO7WzFaDME3WdxmV*USLX3oYAX=0$c7a9cfPLKzU*|Exet}*fj$}jN7v9XT+?(eT2m;lr(FYrt4fP zEP9FQ8L#Y@X{QG2||JlMbbl@-LOxH$|hqd%mrWUw0Aj zUvdQcn~eM8a=|~-1S#&C@lVGMzuWILp`9}N6#~I0`;#Wlx~2BTn}q1;!#fm~Po9rs zZ%}GYV~34(Uu1cNP`*_0(4i~~T6xKBq?rF2Nr^kN*riHw@2fzuvZF73ccOY2r)X8M zq+W$rsiKhN#1wk8^5JN$IKpKETiaOwcq4?|ft%iS&S`Gy^6bFA*Ff@i`9ak4U0+zf zTb%DXN|HS3Jg!7MX1}z6t9bI1Q$kHXvfOw1aNj<*5QNY5#l;Enms;dD6Lw3J5VM?P ziJ)t|!6X3Pi8L22@{5vj%ygOM+N`$VyP1J-f1jSPy|Z3v3CAAMA@%DFPjmzhF4Ouv zHm42Bs*_YYuY8QHIy-_tU*Uo;@s}FM-o42#9H;d zr)Soe+`mZ5yVg>|P46yJc6>*Cz``e!@AAmxbV(0>@Uh%=FZogEmtp?zww#`7YdPegEtXjMB5-OBS z9^dVHo5%g+{;f7Gf!;aQXftl@&9MeoN#B4%AGMC{yl$l)#fBd&_>`J#eEIQ+1WWDo zRQpJHBems&mZ}&b*fnu$=Qu9y{S~jbYl5Zx<>}M}S3hm-^mx1O_8LR)lo-#hLtvx< zT~8}GlLHXX=hoSpLeG2DNw&5>=up2vPMqz}VCeI1k<;)_m}a?yC);Q4`QgNR@z1PZ zHXasv_$_kLJ;H+%*%Ps%V4<{U+47eZYPy@_&p3-wS+KN!pIA3y+72XTc7`qpL>Hb2 zwuA_b;$_?E3BSquP~}?*FHemozK$m&b47CL(53DdJ-93_?;gce@tZdnu?I%to4 zCl`PjPK$pdj{J$=VHrsTM(wW?FZ_CXe14W+MkRW{zdj+OV)YY=@eB2H}JB)=JjC zuj3FBsTJ$cr!5AeIRaMolxDVbTL#m_`llHTMu(Jt8KEvRd`1N@gg^COsWVC+ zyt_ANKl(9hHfD>bA(N#Kr;vN*DO#nWf;5jSLgc)m3FFF)77}b59|N_ccpku#3B!;t z_gY38ReW}z1irW{ZevAj+dRX466(Zjs6Wsl%@w9}?xLG4n!$-*>NY0m)kug3@+;lW zC!;yiEW`k+n5?~iFKv;=Q>i-hTR%;|fGNDQBht>U#IAf87K+uwEkhYK$>e+|?h+P2 zxm=AxSk@07`x>hLD#;o~9@d!D`#T>+L8PZoAn1sX*}Y$*s&FylpW%ZHcTM}J^u6zm$9Wdf zF|lu-<~Gf1`b~(xy!Y!$gKiS_OdTpTj@-O%aMy3zA!n{)a=%%-Ir;6a$yL9lavK}L z7g1N~9?`uS)9s~3F@L*ilO*RJP8WK+THaE&dbg&$3x8_W@5eKnRl&j4W-&2J`wL6z zq&iHtoDMSsh>z0($F0}gO4nryX%O&mjuLm?EGbQbe(m+`fAQBKWBzu?^Z~TdI>n*69Q$M|inBV^*Jo;4) z)7s++X2srKbJ&yYagx+$>H!{D&E_BR7e5y1)TS(&awuudnB6W3h-fp~+`CAf&FGr3 zrZgN*F7v_A`8WtVNLcT={Zh2QvK;z+HFNsSck z>LfwAj3@APAoi|!K*1x-53w>CPfk3ob{Wp@+a{?9x-n~t-IfO}wX17{RU+S9p9reK zM!xKzE4dnHnHINoUC?J=({<{|)i3(E39dI`G5*9DpuKPR zkp?Px47In&4XSxF*IWIvjlILKZvP~qaaRX*-M(|FAm*JHl!A@lLqXu2(X+81fx{gN z*{lR7A#WdAw=)DCRr`*o6UUX{|ICSv+3fRuSljBA|q+K=Q(%#!d7&`hK z?^tlRU}8tvBPK%@%``3y!1#bFK{^_{kcl$k?LO1ff zaIlTWpV>s|mRk8tTb&k7+o4b{{-V6@ggk$1dSc+?L$AQSpCOr6f(MrzGuQ&z7=MCj^vby(k*J}K5tXBCrrn$O2Kyj}3KZ6uJoIAz~kglyDnhE`*uC7hKg zFQ3+kPDqQ9RZIXno6AvV@N7G%lOMP7dX2~^D9@PO#@OIvvq%=Z&q@CnEEYeZ+6&qV zyT(2;|G*<2uM)d^h`vT(^kf5l|5Z4GsC#S4;mA8)j#rS~9a`vqGs^EKc^STWJr-}= znPQUV+3lTUtL`Z&$)E%1a`g@}o7{Hk_Y0<>a$UJZ(KH}b=eimh2{Q@q7MX^+@=e_6 z_q!7l;(lA@@Acq}rB52lSM+cd1K=wGUkUh1z*hpk67ZFPuLOK0;41-N3HVCDR|38g z@RfkCv<`eF;41-N3HVCDR|38g@RfkC1bijnD*;~#_)5T60=^RPm4L4Vd?ny30bdFD zO2AhFz7p`2fUg96CEzOoUkUh1z*hpk67ZFPuLOK0;41-N>Hp4GDqvdtedy)i9gRWa z9wSeW#uV6~XK>7t$e*JzfLH>=5+IfUu>^=EKr8`b2@p$wSOUZnAeI2J1c)U-ECFH( z5KDkq`Y&QhV5z$GZ^|sEA$`VkPfU*RXC7>(;WeF%tKv@FH z5>S?avh=^AEZvUeBLACZ`>DEgx@^as)HvX*h}XbC_| z|39In41(#^zgYmWqR-^C@mxvs{)nNX(!)9Hj_+x~SA2QR7oHK72y1kk1bA-a?^iKzS6x9=a8 CdEik1 literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/FileAssociation.png b/windows/runner/resources/msix/FileAssociation.png new file mode 100644 index 0000000000000000000000000000000000000000..d0c2947aa4ac78ae4cd3bcfee5dcdbb19598a325 GIT binary patch literal 2294 zcmV$=NQL9Q?rAlbJh=?|XR%LD4Y-#`{G#f>sgcj0; z&`^jWB_#&6sUnw1X%(oDAfeEdghYlq{DatRk8!abyw1G+Ywvw`#?O!v4^XL6m0`-IQve-&83asb-g*rAiJTenRHkiC}R`ZZLP zm+|U(bK-xmH31j^RwSv{B*N;9If>w%1`u2=xPFZkH5Ml+D?l|h^xQdPSeJB9m_+n= zh9~~L4(Dp1uLfB1d^_&h2G{bCejl)=EsYO%B(VS4;r$C>jBQ9{vVH!?SZSxk+Wd&( z|25!T`L)T$+S;4UXTFmI)d~1TV_IHxGQcg1GPreN1{+6LLN@_MK0H_;9$;ncBWJs_ z@R4-z!8QdxXvOtHK>@^PC8^uYfdZ&$X@>8HUs+vMDg7(1^9-=OEnLLbv8C`}OK2As z=9ueiZ0f1mV$TvbaR&=t2(U3T9%N0Fj$ z&3LG>6PXUehUvBf56AqX?HP-@QRgfx0^ofbv_1$>tvc537HcE&AUX)YJ<|dvt}?=t zV^c8sz-G%g9SAk4QrOgkC5=8-G?n6+QEl)v3of(aQwaW5<5|0fj-Nd0)lI&f+Lr?s z-CulLlDZ7_}95i12V~ zul(F+j~CGmQ+z5nT^L%M{s{_6}F~lf*TCqDBl`dM5xWOn4*8>!f|g;vAXFD4mcAW zd{YNDMRS!ih6k!D@taf4*nBn(*8x^ds=UMsxpR*~KrawS3tnKuWfop&yakLinQ7lD z-!2#8swxPZi1z@5r|pZQPrrXisW)ISYTO-dC8jJ z%`AENYq|mGIxu_OXy`?RJq@9sqc*Ag=17X;}mYW$KKdOT5h#)6cz#ZjaFA5qutJE1z=H+kb!evUJnFnu08`IJxtkFBZ?Q<~pG10N28y zafaD50J8|#bC4vs9(CHEE0BVHimN@qTix_;jr>W4Uvlg}R z@wF3rPn%i-LTSeP)z;M_ZX7!b(6EV8*80bTPmGa`$#oyGm=2LH89&H_S^>$DOXwh6z_HyKkP&0EKX28)@orJ2 z`rU$3LcTos7Z^Ak7RVV&cp2jR2@Q9)VPs;YR94F=ZS*%L7rQn?m)Cu9d-lWfTha!A z<&kW%lc&z1_4sw*MgkTic#LrO?RL74dT{VinOTsB-^T|{8aP~ti2#o?_#E&VaQ!=F za5REG7gFt9V`>r0$OhGbUQu$&*&tL{ynpu%AFg&?I5!iKM)EhrIrL(Ts93n6qjkr! zL8rtF+&aZa(B=t9FbV3h9DJx;=8uGKSeX31WEgp>5pzmA6j~wlY6!dcH4m<)A>cfm zk1JrNJSasW>S*1us^FB+pjEA=pS|(}?T2@T6Ek=`oZRK1>N<<#qn#e&ceG;KwA28F zd)?5hqOtbvPow>81$+q#xKe-|EDPsjz_Et=etMzfPg~W-r=XMwL`tlpC%?POeX8^f zcaHgtGpPa#t>Gb1Q3kXh7=_)3JlwOKfomEvUg+5#$41>Y9gbEZ)eCsI=-eE_W&+kF zi}oEXoeEGFfJ~u#iD2MviTrW4F^So%xW2@z_t`*ShsUtIfA|%_);u^Ia5%x{THc@H z=n6#+OgppF_|At{lIHAnp;aqbexn%1RH{tTx)VUN%H15OdnK_(kzHG$hMI)jkLldoceEQfzFyYeU>GGV zkSYhEVw_2vgvv*nl=oL8LtUtQZ{!pJeE_q>oDo&M%RR5ST+@*nEVA8;Y&*%F+dg6< z2P@t=#yRRU4goG=P+9W)-BYQ8~sO5n^TsLYxrAf z?1oZb#ngfG&+U2dpKBjepth=(9VtB(@|>+uV2VJqi0WPl!kqPIrcMJ`Ol^^c=PiKQ z7d4fCrB5=i=3yX3RPuAaR^`s91yQ5|-j%9)t)CA^Q)KA>IEH|SfIsE+FMW!~VJ&%n QfB*mh07*qoM6N<$f)CJ9(*OVf literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/FileAssociation.scale-100.png b/windows/runner/resources/msix/FileAssociation.scale-100.png new file mode 100644 index 0000000000000000000000000000000000000000..d0c2947aa4ac78ae4cd3bcfee5dcdbb19598a325 GIT binary patch literal 2294 zcmV$=NQL9Q?rAlbJh=?|XR%LD4Y-#`{G#f>sgcj0; z&`^jWB_#&6sUnw1X%(oDAfeEdghYlq{DatRk8!abyw1G+Ywvw`#?O!v4^XL6m0`-IQve-&83asb-g*rAiJTenRHkiC}R`ZZLP zm+|U(bK-xmH31j^RwSv{B*N;9If>w%1`u2=xPFZkH5Ml+D?l|h^xQdPSeJB9m_+n= zh9~~L4(Dp1uLfB1d^_&h2G{bCejl)=EsYO%B(VS4;r$C>jBQ9{vVH!?SZSxk+Wd&( z|25!T`L)T$+S;4UXTFmI)d~1TV_IHxGQcg1GPreN1{+6LLN@_MK0H_;9$;ncBWJs_ z@R4-z!8QdxXvOtHK>@^PC8^uYfdZ&$X@>8HUs+vMDg7(1^9-=OEnLLbv8C`}OK2As z=9ueiZ0f1mV$TvbaR&=t2(U3T9%N0Fj$ z&3LG>6PXUehUvBf56AqX?HP-@QRgfx0^ofbv_1$>tvc537HcE&AUX)YJ<|dvt}?=t zV^c8sz-G%g9SAk4QrOgkC5=8-G?n6+QEl)v3of(aQwaW5<5|0fj-Nd0)lI&f+Lr?s z-CulLlDZ7_}95i12V~ zul(F+j~CGmQ+z5nT^L%M{s{_6}F~lf*TCqDBl`dM5xWOn4*8>!f|g;vAXFD4mcAW zd{YNDMRS!ih6k!D@taf4*nBn(*8x^ds=UMsxpR*~KrawS3tnKuWfop&yakLinQ7lD z-!2#8swxPZi1z@5r|pZQPrrXisW)ISYTO-dC8jJ z%`AENYq|mGIxu_OXy`?RJq@9sqc*Ag=17X;}mYW$KKdOT5h#)6cz#ZjaFA5qutJE1z=H+kb!evUJnFnu08`IJxtkFBZ?Q<~pG10N28y zafaD50J8|#bC4vs9(CHEE0BVHimN@qTix_;jr>W4Uvlg}R z@wF3rPn%i-LTSeP)z;M_ZX7!b(6EV8*80bTPmGa`$#oyGm=2LH89&H_S^>$DOXwh6z_HyKkP&0EKX28)@orJ2 z`rU$3LcTos7Z^Ak7RVV&cp2jR2@Q9)VPs;YR94F=ZS*%L7rQn?m)Cu9d-lWfTha!A z<&kW%lc&z1_4sw*MgkTic#LrO?RL74dT{VinOTsB-^T|{8aP~ti2#o?_#E&VaQ!=F za5REG7gFt9V`>r0$OhGbUQu$&*&tL{ynpu%AFg&?I5!iKM)EhrIrL(Ts93n6qjkr! zL8rtF+&aZa(B=t9FbV3h9DJx;=8uGKSeX31WEgp>5pzmA6j~wlY6!dcH4m<)A>cfm zk1JrNJSasW>S*1us^FB+pjEA=pS|(}?T2@T6Ek=`oZRK1>N<<#qn#e&ceG;KwA28F zd)?5hqOtbvPow>81$+q#xKe-|EDPsjz_Et=etMzfPg~W-r=XMwL`tlpC%?POeX8^f zcaHgtGpPa#t>Gb1Q3kXh7=_)3JlwOKfomEvUg+5#$41>Y9gbEZ)eCsI=-eE_W&+kF zi}oEXoeEGFfJ~u#iD2MviTrW4F^So%xW2@z_t`*ShsUtIfA|%_);u^Ia5%x{THc@H z=n6#+OgppF_|At{lIHAnp;aqbexn%1RH{tTx)VUN%H15OdnK_(kzHG$hMI)jkLldoceEQfzFyYeU>GGV zkSYhEVw_2vgvv*nl=oL8LtUtQZ{!pJeE_q>oDo&M%RR5ST+@*nEVA8;Y&*%F+dg6< z2P@t=#yRRU4goG=P+9W)-BYQ8~sO5n^TsLYxrAf z?1oZb#ngfG&+U2dpKBjepth=(9VtB(@|>+uV2VJqi0WPl!kqPIrcMJ`Ol^^c=PiKQ z7d4fCrB5=i=3yX3RPuAaR^`s91yQ5|-j%9)t)CA^Q)KA>IEH|SfIsE+FMW!~VJ&%n QfB*mh07*qoM6N<$f)CJ9(*OVf literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/FileAssociation.scale-125.png b/windows/runner/resources/msix/FileAssociation.scale-125.png new file mode 100644 index 0000000000000000000000000000000000000000..85a96df479dff923415afed01a1c8bfaca9f934c GIT binary patch literal 3163 zcmV-h45agkP)^_FC#dXw@qJma7=^XZR|Vd0L^0zN-lW;N>Be`VbeO30WkoE zAbKFfivhIidU&&T*nr6 z|KS#_7LJpK+W6^~223xl1W#y*DkLskX5*&hvL!a9Xlk}^7Xa3gdeJ;Wwq*ZeBMO{! zlAA0lKSK~^)HnM2BB1t!a+t(0dFwXZ-rS(}&#Ng{CvsrHgALbj?2hExR4eVGIo3r< zE!7UCt9c|6JTvj)hH`Uw-x!#0G!1Odwrvm|Q=k@&E5^T;7OL+I2F$FfgeeFU>Kie3 zOC!JpW>*!#BL_l(bQDO=I2)T-Y^`$V&fDZIyGesQESZ`e42+Ts3dQyq%Xvq(i^uK9 zWs_SJFm4J=F0VpN$3KsHkTcAysX*BoS&aD0F5I*=gGUD1NbhyvF&iHFEJ`-dGOrqY zhWYKWi>w=a07wDw+8a9NVX&ph`Ff$q#27mu+Bp&CZ)t?mB?uF%D3$93T_-RPWKi9OXk#_&_g-*Y3@t6)Wuf1F4E5VJ<2`^PGZ=mEg%x zdV#l|X|9K3`2&#Uv$|mUjP8mpBk#BaZie!q3zvKe-hW&cHd45AYcZzP_d%?X;m5^q zgzQN+9kP^k&yS7EekwlMw#i$1qGnNalsT1x=jap+>XckRzSPAdYql$3j1z9Cs(=ZG zcpE^X0M#}TTqQl}F+WTsQ+HQ?3`%5B`(Zyk(l7)IC)Hua!i{jr_oAo4cI7=PPve)` z?>WyG0mvQ<;Gvj{4=SSl(GCV6_cj^11387#<88=~M`Wp3gR9sOQhLH?MuL^wRwj$_ zRjV<3+j+S5mjiLl0|Rhn`Q!dE@Q?G8)xt@}*IPgGRu$xpaM}&df?5VId5&oEH5>GbmxG?W8>Rmqux71W%_GjL^>#gl7{(oLw z0nXc!CWQ=n5};;4=*~a1ZJ-V%;Obs~1jQ`{B=nvx6LNjCyZZYcvacTGaq5uZ2yff%Ew1WGCy_ZDmz3iBP+x5%<~kS-im$z?fY+jYe%4vr~97;h%L(`PA%qD zgD*&K{f)ueNW#zR-n0`cz-_{mrUp#h(uh~5bjQX8MQS|hg`V+QV+jX8+gz@I5fN^z zd<_<*!te-#M5U#T_1<&eByt<{qmms-OX>h~>c>H2PgRKb%{XIbjIdoaqxy_jEK3eOC}z`>-gMPRIp0(J9o>s3n1{X$DGWEM8Y-F_c< zvL{;ICtq%LLATb%Dz(o%-OmnzQ=d6K)4K~31>h!Nm;g5!s$*s7)1UCCx*h1wk8d*- zpE$D|F%ZUn+<-BS+odicJejTub>3`?hbgCi0G?2S3&)qiD1{Yyn0&`9q&%Qi0G4b- z?CwcU!_JnScx+BtVMoEKu$x~ZQijQ37*OE2UNX7P8Ll35JYsQR*<1Krf%hB36E(e& zaZ(uHoKjD%L<09#Z-Y%K6()`zQ>tN?u76X1QHxerAf-Xs9$*W=kxP%j6D9b;1S_>* zRw8*YX&$PG$Cv@)CYS_ea37ppO0cq-3M6pJFgftsi8U*Gsxo0lqaPM1Zex0tqedb& z(h7)dj!H{EEM(B!cmD)9NTSsx*u)edn`HTr;QXapCm*cF;B{#DHF^iHURX`_ve0qe z6pI=t(?I2VNvsz}TuOLhSrd|0sY+4hoycG~*L*O3y|U5otmAO)6_=w`2xMLI*%Clj zP{^Dm7%Ufiu+twu?UOa-$?BwGFfh#^i?A60mOdRZ#)L2oJoBh#i|KeLzjn?0 z)#XSDu7Ihq5KKwW2O?P_zma4Rcz^SY8=hbMovQ)t2Php$^1~im3f(*n(4K*Z@4Ke1 zD(P}y?NEXQ$Ro@JEE*BHCSrvcGqDiDu&`*!Iv5GU=#k%2<-(5mNC6_!QbY(!wzTKs zqgM>X`SAWdyA8cy*Zx&cNBSj>ggN=+qyROzjNnKgfiQ42umiiW|Dv9XB_8o%z#a>d zl>^CG0@sfmq5{a2;2G%zlpuv>&G(c;K5$16*8epPiWR_P1dlN`WWb8)4#V_is}to~ zmK`ny*o%GX?&t;q@-@H#!5BOSbY&$-yaa4Mrx9h909bZV0*RpB zEO-6xy)HcYNJ;@8if>GO6)UGp#?*!QfdEAa60jBhUUSFsRyb&oKszz$ovZOtw z14J!};pIO#aCwn`4KB+FgUfR_QUVjfw)Fj~;z5zA#l!ZT_XbuiFu|9!Kx~ql_vBNw zk-LCe!Urk6Xc|duTp(bd+>F5&R)he9Iw@~T5b2Hvw)poAU|w$s#v@FTR!Z&$3cYO$ zhu&P(tuH`(I{L^HeB|i6rK>RdLQt=Ugm_b3lj9IT@XBL_u(^vXCZ!=nR|RTkW*45HUkrzrz%^LN zkbe$}Fvt9Jf?>@+M?A1=&&vz-^j=oaNPCfz)uBh8XcWnl*tO12M%6mE7Om}?TF+RV zg@AbAi5yU&?mA)Ni~_jKgTo0nD~4wv^sKsrY8imaNj~fCqvdORpFyCMj(#Vvc^#>9 z0o`)uAj)D4U>-o$*)2t<0@VHWp6**72Xoal0#sBBoumplN4V_<^3{M`?O~tkTrzsZ zXz`lF*T`oo4KN3w0FWDbo;#vRSuY|{Jz^m}I#3tOp1&(L;H>!YkyA@vxI6TWX2p$2 zE|eS3l@Y=9Nps0de}1R>D$D(JgAYo{PXcozz(jjqTDE6|g1i@lBEn(_@@;$RZ!d0+ zJ-e`U=X-J1O|j={N&4Pl5l@oxqtJ2=WN1+aPru#cbS=TG4l_5eiT1uPIGlF{BC-tT zR>Xl*5oC{Q?sdCS>Uf+(#q{8)+ME{(weP&`MG()an!*@*`ziG z*IuL})^%Is{SvIpS06fY_j8^yDfwS2&xuf?v)@g~Ny^=R3byGh5oyQtEl)m^EYbn* zK%nGU)bfuBAH`3*?S#75OA>v1h+qiC8D9T%>aw-lYbR=^vTgXvYlEp{`hM*(^o!~mbb%Ja6=RZa^D=5rk>hAym002ovPDHLkV1i0D BHafP$!CF zT!=P^m@|lD6f+|Vj5sVRaS0$Gn-XZCyIF1Mrn{=&a{uuz@71NcF-UbrophaZ&wa0} zUcLK&_xr!^zqejH=zPhS(+NQ*5S>7D0?`RXClFsMAeeDXe`!E49ki7ZJTuPO|0OWa z=L7=NAlSoWQ=b`NUpjiNH}k2Ff*0@B!IQxh`u_w3%K)KZVxHR&V=?CFK~&k_xHCp{ zC&UGtcw0SG~`j65-0Kl|*y59aaIZ7p5Ta?!Y6144f;b~u0LG!U`o zeEO|mczEgFL>PoGl;hk$?D zy$3Pst&|i`PWD1?tRPALdcAkpBLcKE%owD=)l39 zHXb;y6pq!_Ce8E-8^t`#t}MVg_cde4HNb8Aa$q@e{JgYLgOSn5LxnYHOt1K~*2V6$ z3IGQ+ASYdrIiJA|!g0n%=LXF&%VL>9*hF}`rWTB`2)jvD6|lh&(=2d41po;s37_z! zTf8=vmLiV>zI zI>U4JwdC6+cBT;8AUv_Y9);ZqOapLJ{|dloQQ*wz?MwiTa}}H~15;w@VR}U)Jftwa zwh{v`?1G2(d>s>uUkAmNe)w%z;$wvKz2q@EnA{R9>6%flQ#OpSN96HVQ4eHRf#Q6@ zu3C_O4~*kRA%=oFCz|z)l~>eL0H6Gb?p)7uVEJ<9|E2dvRQ+`fKVCQh)rT&C_RDi?P-CNxyH~f9gGOxlK}^On~CCLaTgE6R-P$QIVb!5Z0E|;Gv`P_-@On5Y6-uT)3{h4}xZ3-oaX2etV;A zlgrCt#aO#wv=K<14pF{y9f+p8D5_k~!z24gMf?_vx2sE^)`9!cb%IGgQ-2d_)5Pm; ziZU+?u@v__wx-28?cCg8hhOjnq*ach%J4k1G@S?e}}O(F!?D z12Avo1LI#(oh9vnQ^@?-%cqKyeLJ#3U`Q1RtA&SI-8Ru+daIR(i^e5X}Xs z#mC`IOIqwRMQ3(p!+Zt1PH018Iz|iU@xwonRwy9r?%)Ndti(|YamQ8 z$YD7H=2o=4?l??})wq6fG~*yv@9NoRxosIG^q$o@c(|$pH}BdD%VL;VQwQz=PkyCH zStfbtH<#g8_KkqTEK+}?9HklU*Ap3>GC>4)$N zYQppx4@dx#9Pmgqf=5gpiyRI(0@%3WX18(ftazT%xa9t)kjyZE z%8wP|TbBSI9;ipn+Op$nj)1FN7iF+0e%@c`!WC{{I0wlTNH40U+%XPa3d(dEgdpf( ziNTkDQ|GP(8gNMgvU5}Qw1C3JS?3@kxfeL zWPaK{qvFVg%aeF~*0m}PLi!6KBn<{vdLRMN?@1;QA?dZ^$zOFDqG}0gqx?_(Hgd9z z;A$yL!k=l}=GZWR$`_wSOD+3&c7$nj95{|w#HP|MK6y ziQGgHT10R%P$&?%J`Qx?8X%bx@|e;6_s7qX+u{m@RK^w_Wm{Q>1y!Ic%v2Ko68E@Qrb2#R#s&p2EUpE4FXzkFOL2 z7J)P1{LQ6O+SZ-lg1DW3TuMZ`5uP8%5QfU)3fw(SZQl!B5ml(@)l92k#q9ggv=5Bg zC#4Ndsl(W@vO5BlfQwXR30yil^-*oZ((QlPeAzb)M)Pd#Q(jzzZbKVz_$>lsqRtB- z%40%6kOLUBG8YZh0Qa(PQkKGWh=kPaGZ+%tudQ-i(Ov^0KnS!D0vZ6N5AXf187$Tf zKI3u>V*1781jJa+?tgRoh3B>&UcM^PUDpt-BX+1tj^N`pGDpNs^$odLD_B6l)Oo&B zg!rogb`PjW*{}-OHj7-uHq*vQ3o0`-#Igt}uHAr?APN8^JA|QXro8=HE&O9;uwyx5 zH!wX0-OF=L;n8DKAHTUWrx?JIZ1*Fqm+|V7Fi!$zAz&n%xUrl8c`jyzBse7>mc!dY zNM3mq-Qz`I7K_|N{tU!)@b>Z6tAbb5_eFsu83CmW0kHq*0c>2_6M0ua?pAz^A}{6&S> zi58rGeSV}Bwq`-p>r@c{c>(D^!h z*> zmk^9lFI;Vn(j|l==n{I-{CYT6F^JhJXr{FQ`u_61Or?bGL#DpDqF^-O*JuY}9kDSZ z#?Z1AA1p86&X5&s?OSJHSP|DVLzV%89HbUgF;Lla#1EEYO?7|)|8N++i%X7k5b4RG z6>5>(T2qIO%S#bwy_60_8W4id(%?s$CC5Pf4ldfVWy>|+*1wA;Gk*QbdOt!;M2q-| z`n=$;MzR2TI}`3&ZrM!I6ycy)1d0TTh3~0U+VE^otb>osCkfrKrEYj!*9IJD@mjHH zZVVnPP0zSYHx@aR0-w_0GbX|JsFhxSqw8=2CDmBbYSa*vZ9&D`ZhjIMx>nU18*0O@ z+*IJc99ZHP4do)7g(AkmnWglD)JzgF1UB&8EWl$va{BGVh;LeW>Dd6bTLzCg@NKSw z)3dC!M+DE*cWQk~rz4|R-b|dQ_9!I?u2E3=9RWmpMTlmI&`t6K1ltB3bB5oa`}~42 zi7N)H+1Dvy8O*S>s735iv)#D66ic2K10kIPLh$rwL(w(ZB>_si6}`9px>>b{o|~uH z+6uW#O8yBd%d873-gXa@!gTQo=Z462dRFnnZtIHoKo*$W0>Af2;V%!isvrISZ1ObxRt*BmGpwocDG-wZiAlt_d zPxTopYR6fEj30w^lk}B&JfS8wtk5bwYx-$>=5TWb%TzfW)(PVH(e?534Li(C12YA9 z1^}n@Kx(#6)n(q$&)C%p-6d>p6W_n;mXG4YZ>`$6qDRi@T}>wt&a!&{-Amrxf6r8H zhpDs;wh=?hX!s&LUU(w?ptcMlpMchxyM~?xAR&G{16UzIP3l^f(g65I;F)Q_KdT0O z9*-c0X<`}2c19p!y6A^$s3HAX@-u?>Gg2Tk4ZiRXrBjA*Ws0h8Y_n&U^Fceul+AOL zpEnR@AK(l8>ip-nP9Qpg=meq@h)y6nf%wd?{{?PgD?Wjwi5dU^002ovPDHLkV1g={ B0n-2g literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/FileAssociation.scale-200.png b/windows/runner/resources/msix/FileAssociation.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..6e227e0d93a3ba71ac71139d0b065c0566d3a24d GIT binary patch literal 6619 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 Z{{fuX(bZCwvjhMD002ovPDHLkV1mmuy6XS{ literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/FileAssociation.scale-400.png b/windows/runner/resources/msix/FileAssociation.scale-400.png new file mode 100644 index 0000000000000000000000000000000000000000..cd04708d5dabf60f06fab82bd01b09de1af42a4d GIT binary patch literal 19362 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 literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-100.png b/windows/runner/resources/msix/Square150x150Logo.scale-100.png new file mode 100644 index 0000000000000000000000000000000000000000..a153498a176ac651fcfde6330929d909d86a51d0 GIT binary patch literal 15349 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 literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-125.png b/windows/runner/resources/msix/Square150x150Logo.scale-125.png new file mode 100644 index 0000000000000000000000000000000000000000..367eafcaaa38d0c1f10503b407ac8873c322032f GIT binary patch literal 22115 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 Z>lHnMti?>5|Na>UkrI;^trs>7{vYaezpelP literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-200.png b/windows/runner/resources/msix/Square150x150Logo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..ed2ea00606ea7e44a3dc31bc549b367aa8c9485f GIT binary patch literal 51677 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 QfB*mh07*qoM6N<$f)CJ9(*OVf literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-125.png b/windows/runner/resources/msix/Square44x44Logo.scale-125.png new file mode 100644 index 0000000000000000000000000000000000000000..85a96df479dff923415afed01a1c8bfaca9f934c GIT binary patch literal 3163 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 BHafP$!CF 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 B0n-2g literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-200.png b/windows/runner/resources/msix/Square44x44Logo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..6e227e0d93a3ba71ac71139d0b065c0566d3a24d GIT binary patch literal 6619 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 Z{{fuX(bZCwvjhMD002ovPDHLkV1mmuy6XS{ literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square44x44Logo.scale-400.png b/windows/runner/resources/msix/Square44x44Logo.scale-400.png new file mode 100644 index 0000000000000000000000000000000000000000..cd04708d5dabf60f06fab82bd01b09de1af42a4d GIT binary patch literal 19362 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 literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/StoreLogo.scale-100.png b/windows/runner/resources/msix/StoreLogo.scale-100.png new file mode 100644 index 0000000000000000000000000000000000000000..60dc64d013af55c2925380050ffcbda41b563e91 GIT binary patch literal 2746 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 AVE_OC literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/StoreLogo.scale-125.png b/windows/runner/resources/msix/StoreLogo.scale-125.png new file mode 100644 index 0000000000000000000000000000000000000000..ea46881a7ab5ac421c6960efde27be11475db328 GIT binary patch literal 3859 zcmV+u5A5)XP)j{000i!NklYkaNkr^KIo`F#kH32kAG~xzxNX%}6tE=l8WevwHy0RZTE)2Tt5f zpHt^nRaaNn_x<1h{eRsm8-K(fdl38}_(AZ4;0M7EfzMZmLD zMTUKf07t>{1aH3h#?sdH76X$PV3D9l`V*skWI6x{b38lQ1lXr2(w13I+?=38w%N@)!BJmB<60w0ZO01tv0 zpOO%LHe*MC`^S#m@EvUd_l;;oN%@h4FbG^9|Jkf-Fz=r4rD|j&vAdc2`fv=0up+nhbv=1Yu4~lltx-Hg;l2qmSEnC2?q{ zg%w|_1Cg)6GZ#K-62J)GE_o&Wj#%^NOYVagnN;wLA;2yYw2HKa$}_)~IIvYpJYT*G zle-~YEpZv``$sjxVnB>(VwPN72OET2cebOl#m7xM+p(x_C`8-|qpt`cO~x5~!g)89 zypUOvxw;O({yYNcWP*=^mWX0sEfak)&X|?s;)Is5F795nHw1p)+4T@94x442-VRU1 z@I*@!&<{-C(~euZ4k)4?Y0kiBh6L`w7Y;n;Lcqm0OO|$KsF$8|ZcCV|aNOOZeS)RS zo>-Pj$5{z@B-+ZU1z~Y(QsuM!cn)ySryF2_Ay%Ng*-BFgjD~-33F9ABcgBJc}&WaBIq}F(uF58+Gvj{Ib?I zfU<4-og*7Vd0Y8DaKu6ikc{%0nZT-<8GN@h4&gA&Zf(N+KmBjCZZ|$4f|Sri#E~eQ z6uTq+s{7=Z9Q16AltwBBd{pKo7bU3IW?=a>y1LH8{7qd3IAQMBYS6hot|ZP1)N!jo zlLHbO3}ke5uFtMLj8v*0bM}qGooh#fNZ{`q*XdLsPZJ@3BlI}l{;Lfc^wS&OFDp!! z_>n1aEb#J&R#W`(m5s3(lM6-c`L5k?(_HtiRX%PQGE#y5sHWx!1rWg_SLL%NAZvE4 z%)?zxTha1G8@K%PnfO`q%itWCQokHNJd+MiSQP~G*cf|q-*)e}Ul+)%H5kN+fLEqg zADc0`P&X#Gud8Xs{8u|MqS;6PK?`$U>%aqFs8^1je{7+}R#)dk1ja@YhgTBUglvEL z5c&^!D65RY7r-xCuf;XZi*t{SkaoI#!RimaS4s<*;c;w1tZ6RyS`Y5 z7!eljOk!xgz`WLW%&ct$FU0njojAvRi!u|j>hbp6{;Zv=A*3)d1zf-za;&x`;1xd!{PG5I8fGLA8PucZl7|EU{eFH7UT)^^q1{J16CzGh91 z@;bRJ@b7OMiA1HPVogMtR=XO$omL9*5Ro}$f8XlA`P0Lcd>lY80Owd`@8h_=FpIpg zU~f8?U;UQD$}cbNeE(SFvy;uHBP6VUDv9Mk*aottnYm#(^h1Mmk@owsRjfP@pgyQpNnPtP%iXcSiwFUp zF^|lmhC}b3R#F1r?#0^n3MKiYm9x3cO<)f2-DDf4ZR}Ej7B@G6xX6i`jN(1*RUlEN zS+@wc)@@W0*A0!U^Eq&LI1uFF&L1p6T5!CZe0$xGem?$ObB%r^XhoGeVNPa$uH?#T zcMN~=hgOWKauc&o!&RRGwjOFn``(6}ZZ9Vl;=p4qO*rF{H2(Y--MG1}9n2Q^X??i@ zcFhW)c|y4?^@^OCwOb)#sfZ7_VU7p;uD$7I)HjYrzYn-1P*-zK)ttNCU9;y^Uy?~5 z+%gbNPT0pU9^oIbZO*s_1ze4mcKh>d8gW4*u;O?7QDqN3qU8<7wwFusWJ?34ceTM! zbIfQ>DH9WBdhhLD_2StXZ9qIy@{lz|nd?3Nempd15&8wt?-Lwia0K8Gsqyw(_q^Qa z5f;vqvjoM-UV;DlFwrL+m*oTJ(EH5#^?x!H+!pw^SFOw_Mx6QI`X4x93Wq@1UtQ!L4 zwj`szyaA0@5MKD#B!*cHS_MQ9P_G&D7Z2e^5NXxV9-W+f!7jOHE70PY0-a#o6M&IA=@(=K=7D^aBFqnQe<< z%5@Eh#ep?X3)8}Sk^bEarmAv?a77xA&;MKW`J{lWe9nn7!U#)iSKBi3f@sNtk_!Or zk=Lx-B0@2x4%+QW*3W(4*kPS>$EatXy0m)AcuRIVRwVBO0*oSp{qhnnyOE=Zda!0G z<;t4{4z7VO_{rrUUao-aNNbSV)Pilw=D5&C_v=+R%ztmk^2MgXN$G1RQB341QZIeo z=@I7dx_;Wjt$)2`^@KiDSmhe9i8`~7G{sMRtC;+<>axjt{_-3-4071v19TN$V8d)qHKU0lT(H# z7`Cr<;8|4=jBw?I%+>?x?T0qJ`MEPAz>6+*in%|-Lg+p)+-W}7w{|r!Mc^B29~23ZV}Rjq>j980c={GgoQc?ZtLq{41xiP#MzZ!otWwR z_gAYfB9Yos>|};&aQ30Qe>jGzv&iVkKT;!@X`l%s_tnFF2}oYnjS+kpn8m^p$fD(u zBe!2Lqn5VL;jG-vx*=uHp-#NFt`;#~50B~V5FGk?z`=kV-h$bz&$lGz0H|o-sez%Jp84hNOKpJ--w5xE$iq$z+;jW*|bQ&*>V<0UV4Bm zWgA~)a9E83t2Au@E6^qof(L=&u*(`MOr3X%s`EooHSr)0{HIEMu63lqf+->tcr};G z48=COpYzedx-kB4Oe#bwLbN=}l*kCmk#$(ch~rmJcA%`h1|HMr_as!#;4wXA7)G6w zHYSw1N2L4I5;G!w#BzdZ*aCIx;nrn+XD?_-e-*H#hdg zvTLoI&R2hI;!&8qPKxtW}0jeWQ(Hw{Q zkI-|}Iq3KA&sfsYxpaZ9^OP!ZPha%?yqN`Mk`VW9clwm?yVFbGE>CZfosD){bCD(* z9@hYU9uPsOEE$2PAMuca3tqinNj!77S{;)p9Rk-#TB?Vbc?83<1c(T5k9@3orTje7 z-DetV+JxLZ@F*6%n{61#ut}aYMg=+tb!lX#7LytE!g&nN!5RgG zcu+<3cb-v?0?t_|c}=X^rj;8@F9cW*Kxrd!nVMc?#z{n`vWf!FBG1Jm?Jn~T)6V#V z*0?XkzW%L;hpe2bF4joj3-Z;};7}cm6g{4brv5H)Z-PEf-s_97uaKk=t=xS2bs4u~ ztM1<^69MZ*I#K^vQ&`9Ipi&e_0}miOO2TXI>L(_3$De+&djDH-R-p)>upXPHghk0P z5e_16zVnQ+sdbYAhn-!SRa?%w&`3OEBpyWC%L6Sw&Xd}Y1D+xyk;|;7 zMpSZ&gsYSBaTR>{xP?s}%eA!gni8c99@($USkSsY`9{fjfTa;eTM=9++H@jsXPoGp z8I-s9=3Eg1R4O>VV&CQ)TI1)=YIuEJL+LrgatYu%=cDC|5w`7L_WFj_={M?nO-GZs z$SY;1416I&bWJ}HO$AAUj8^8Mf(hYe#+m;DWn2cKn)QRR4Qb?^I`cIfJz@Y+kHdck`f~#X0u1%wmz?$6!mB^Cqea-jiu%gh7% z*}ANn6PE27a||;dwD=^vfq9h4&!fK}aGZhsgMat-6Z07aKL~yh{2=&2@P7=S{{{GP VSg`ii;JW|-002ovPDHLkV1jdqZ2tfN literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/StoreLogo.scale-150.png b/windows/runner/resources/msix/StoreLogo.scale-150.png new file mode 100644 index 0000000000000000000000000000000000000000..efa15bde8ca66db87db2016cd973526a3f2c7560 GIT binary patch literal 5133 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 literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/StoreLogo.scale-200.png b/windows/runner/resources/msix/StoreLogo.scale-200.png new file mode 100644 index 0000000000000000000000000000000000000000..ef562943570ab66684c0241bba8c051bd29472c0 GIT binary patch literal 8045 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 Vxm!)eC|>{o002ovPDHLkV1j*VGFJcq literal 0 HcmV?d00001 diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..4e0b0b17 --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,23 @@ + + + + + + + + + + + + PerMonitorV2 + true + + + + + + + + + diff --git a/windows/runner/single_instance.cpp b/windows/runner/single_instance.cpp new file mode 100644 index 00000000..70810a2c --- /dev/null +++ b/windows/runner/single_instance.cpp @@ -0,0 +1,419 @@ +#include "single_instance.h" + +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr DWORD kMaximumActivationBytes = 16 * 1024; +constexpr DWORD kActivationConnectTimeoutMilliseconds = 5000; +constexpr wchar_t kPipePrefix[] = L"\\\\.\\pipe\\BusyMax-Activation-"; +constexpr wchar_t kMutexPrefix[] = L"Local\\BusyMax-SingleInstance-"; + +HANDLE ConnectToActivationPipe(const std::wstring& pipe_name) { + const ULONGLONG deadline = + GetTickCount64() + kActivationConnectTimeoutMilliseconds; + while (true) { + HANDLE pipe = CreateFileW(pipe_name.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (pipe != INVALID_HANDLE_VALUE) return pipe; + + const DWORD error = GetLastError(); + if (error != ERROR_FILE_NOT_FOUND && error != ERROR_PIPE_BUSY) { + return INVALID_HANDLE_VALUE; + } + const ULONGLONG now = GetTickCount64(); + if (now >= deadline) return INVALID_HANDLE_VALUE; + const DWORD remaining = static_cast(deadline - now); + + if (error == ERROR_PIPE_BUSY) { + if (!WaitNamedPipeW(pipe_name.c_str(), remaining)) { + const DWORD wait_error = GetLastError(); + if (wait_error != ERROR_FILE_NOT_FOUND && + wait_error != ERROR_SEM_TIMEOUT) { + return INVALID_HANDLE_VALUE; + } + } + } else { + // A newly elected primary owns the mutex before its listener thread has + // created the first pipe instance. Retry that short startup window. + Sleep(std::min(remaining, 25)); + } + } +} + +bool ReadExact(HANDLE pipe, void* destination, DWORD bytes) { + auto* cursor = static_cast(destination); + DWORD total = 0; + while (total < bytes) { + DWORD read = 0; + if (!ReadFile(pipe, cursor + total, bytes - total, &read, nullptr) || + read == 0) { + return false; + } + total += read; + } + return true; +} + +bool WriteExact(HANDLE pipe, const void* source, DWORD bytes) { + const auto* cursor = static_cast(source); + DWORD total = 0; + while (total < bytes) { + DWORD written = 0; + if (!WriteFile(pipe, cursor + total, bytes - total, &written, nullptr) || + written == 0) { + return false; + } + total += written; + } + return true; +} + +std::wstring CurrentUserSid() { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return L""; + DWORD length = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &length); + std::vector buffer(length); + if (!GetTokenInformation(token, TokenUser, buffer.data(), length, &length)) { + CloseHandle(token); + return L""; + } + CloseHandle(token); + const auto* user = reinterpret_cast(buffer.data()); + LPWSTR sid = nullptr; + if (!ConvertSidToStringSidW(user->User.Sid, &sid)) return L""; + std::wstring value(sid); + LocalFree(sid); + return value; +} + +std::string EscapeJson(const std::string& value) { + std::string result; + result.reserve(value.size() + 8); + for (const unsigned char character : value) { + switch (character) { + case '\\': + result += "\\\\"; + break; + case '"': + result += "\\\""; + break; + case '\n': + result += "\\n"; + break; + case '\r': + result += "\\r"; + break; + case '\t': + result += "\\t"; + break; + default: + if (character < 0x20) return ""; + result.push_back(static_cast(character)); + } + } + return result; +} + +bool EndsWithIcs(const std::string& value) { + if (value.size() < 4) return false; + std::string suffix = value.substr(value.size() - 4); + std::transform(suffix.begin(), suffix.end(), suffix.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return suffix == ".ics"; +} + +bool StartsWithWebcal(const std::string& value) { + if (value.size() < 9) return false; + std::string scheme = value.substr(0, 9); + std::transform(scheme.begin(), scheme.end(), scheme.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return scheme == "webcal://"; +} + +bool ValidUtf8(const std::string& value) { + if (value.empty()) return false; + return MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0) > 0; +} + +bool DecodeJsonString(const std::string& encoded, std::string* decoded) { + decoded->clear(); + decoded->reserve(encoded.size()); + for (size_t index = 0; index < encoded.size(); ++index) { + const unsigned char character = encoded[index]; + if (character < 0x20 || character == '"') return false; + if (character != '\\') { + decoded->push_back(static_cast(character)); + continue; + } + if (++index >= encoded.size()) return false; + switch (encoded[index]) { + case '\\': + case '"': + case '/': + decoded->push_back(encoded[index]); + break; + case 'b': + decoded->push_back('\b'); + break; + case 'f': + decoded->push_back('\f'); + break; + case 'n': + decoded->push_back('\n'); + break; + case 'r': + decoded->push_back('\r'); + break; + case 't': + decoded->push_back('\t'); + break; + default: + // BusyMax's encoder preserves UTF-8 directly and never emits \u + // escapes, so accepting them would unnecessarily widen the IPC + // language beyond messages this runner can produce. + return false; + } + } + return ValidUtf8(*decoded) && decoded->find('\0') == std::string::npos; +} + +bool DecodeValueActivation(const std::string& activation, + const std::string& prefix, + std::string* decoded) { + constexpr char kSuffix[] = "\"}"; + if (activation.size() < prefix.size() + 2 || + activation.compare(0, prefix.size(), prefix) != 0 || + activation.compare(activation.size() - 2, 2, kSuffix) != 0) { + return false; + } + return DecodeJsonString( + activation.substr(prefix.size(), activation.size() - prefix.size() - 2), + decoded); +} + +bool ParseJsonString(const std::string& json, size_t* cursor, + std::string* decoded) { + if (*cursor >= json.size() || json[*cursor] != '"') return false; + const size_t start = ++(*cursor); + while (*cursor < json.size()) { + const unsigned char character = json[*cursor]; + if (character == '"') { + const auto encoded = json.substr(start, *cursor - start); + ++(*cursor); + return DecodeJsonString(encoded, decoded); + } + if (character == '\\') { + ++(*cursor); + if (*cursor >= json.size()) return false; + } else if (character < 0x20) { + return false; + } + ++(*cursor); + } + return false; +} + +bool IsValidNotificationActivation(const std::string& activation) { + constexpr char kPrefix[] = + R"({"version":1,"kind":"notification","action":)"; + constexpr char kPayloadPrefix[] = R"(,"payload":{)"; + if (activation.compare(0, sizeof(kPrefix) - 1, kPrefix) != 0) return false; + + size_t cursor = sizeof(kPrefix) - 1; + std::string action; + if (!ParseJsonString(activation, &cursor, &action) || + (action != "default" && action != "open" && action != "snooze" && + action != "dismiss") || + activation.compare(cursor, sizeof(kPayloadPrefix) - 1, + kPayloadPrefix) != 0) { + return false; + } + cursor += sizeof(kPayloadPrefix) - 1; + + std::set keys; + while (cursor < activation.size() && activation[cursor] != '}') { + std::string key; + std::string value; + if (!ParseJsonString(activation, &cursor, &key) || + cursor >= activation.size() || activation[cursor++] != ':' || + !ParseJsonString(activation, &cursor, &value) || value.empty() || + value.size() > 2048 || + (key != "notificationScheduleId" && key != "itemKind" && + key != "accountId" && key != "sourceId" && key != "itemId") || + !keys.insert(key).second) { + return false; + } + if (cursor < activation.size() && activation[cursor] == ',') { + ++cursor; + if (cursor >= activation.size() || activation[cursor] == '}') { + return false; + } + } else { + break; + } + } + return keys.find("notificationScheduleId") != keys.end() && + cursor + 2 == activation.size() && activation[cursor] == '}' && + activation[cursor + 1] == '}'; +} + +} // namespace + +BusyMaxSingleInstance::BusyMaxSingleInstance() : user_sid_(CurrentUserSid()) { + if (user_sid_.empty()) return; + pipe_name_ = std::wstring(kPipePrefix) + user_sid_; + const std::wstring mutex_name = std::wstring(kMutexPrefix) + user_sid_; + mutex_ = CreateMutexW(nullptr, FALSE, mutex_name.c_str()); + primary_ = mutex_ != nullptr && GetLastError() != ERROR_ALREADY_EXISTS; +} + +BusyMaxSingleInstance::~BusyMaxSingleInstance() { + Stop(); + if (mutex_ != nullptr) CloseHandle(mutex_); +} + +bool BusyMaxSingleInstance::IsPrimary() const { return primary_; } + +bool BusyMaxSingleInstance::ForwardActivation( + const std::string& activation) const { + if (primary_ || !IsValidBusyMaxActivation(activation)) return false; + HANDLE pipe = ConnectToActivationPipe(pipe_name_); + if (pipe == INVALID_HANDLE_VALUE) return false; + ULONG server_process_id = 0; + if (GetNamedPipeServerProcessId(pipe, &server_process_id)) { + AllowSetForegroundWindow(server_process_id); + } + const uint32_t length = static_cast(activation.size()); + const bool written = WriteExact(pipe, &length, sizeof(length)) && + WriteExact(pipe, activation.data(), length); + uint8_t acknowledgment = 0; + const bool acknowledged = + written && ReadExact(pipe, &acknowledgment, sizeof(acknowledgment)); + CloseHandle(pipe); + return acknowledged && acknowledgment == 1; +} + +bool BusyMaxSingleInstance::Start( + std::function on_activation) { + if (!primary_ || listener_.joinable()) return false; + on_activation_ = std::move(on_activation); + listener_ = std::thread(&BusyMaxSingleInstance::Listen, this); + return true; +} + +void BusyMaxSingleInstance::Stop() { + if (!listener_.joinable()) return; + stopping_ = true; + HANDLE pipe = CreateFileW(pipe_name_.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, + nullptr); + if (pipe != INVALID_HANDLE_VALUE) CloseHandle(pipe); + CancelSynchronousIo(static_cast(listener_.native_handle())); + listener_.join(); +} + +void BusyMaxSingleInstance::Listen() { + const std::wstring sddl = L"D:P(A;;GA;;;" + user_sid_ + L")"; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) { + return; + } + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor, + FALSE}; + while (!stopping_) { + HANDLE pipe = CreateNamedPipeW( + pipe_name_.c_str(), PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, sizeof(uint8_t), kMaximumActivationBytes + sizeof(uint32_t), 5000, + &attributes); + if (pipe == INVALID_HANDLE_VALUE) break; + const bool connected = ConnectNamedPipe(pipe, nullptr) || + GetLastError() == ERROR_PIPE_CONNECTED; + uint8_t acknowledgment = 0; + if (connected && !stopping_) { + uint32_t length = 0; + if (ReadExact(pipe, &length, sizeof(length)) && length > 0 && + length <= kMaximumActivationBytes) { + std::string message(length, '\0'); + if (ReadExact(pipe, message.data(), length) && + IsValidBusyMaxActivation(message)) { + on_activation_(std::move(message)); + acknowledgment = 1; + } + } + WriteExact(pipe, &acknowledgment, sizeof(acknowledgment)); + FlushFileBuffers(pipe); + } + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + } + LocalFree(descriptor); +} + +std::string BuildBusyMaxActivation( + const std::vector& arguments) { + for (const auto& argument : arguments) { + // The notification plugin is a local COM server. Keep its cold-start + // process hidden until the validated callback tells Dart whether the + // selected action actually requires visible UI. + if (argument == "--start-minimized" || + argument == "----AppNotificationActivationServer") { + return R"({"version":1,"kind":"startMinimized"})"; + } + } + for (const auto& argument : arguments) { + if (EndsWithIcs(argument) && argument.size() <= 32767) { + const auto escaped = EscapeJson(argument); + if (!escaped.empty()) { + return "{\"version\":1,\"kind\":\"icsFile\",\"value\":\"" + + escaped + "\"}"; + } + } + if (StartsWithWebcal(argument) && argument.size() <= 8192) { + const auto escaped = EscapeJson(argument); + if (!escaped.empty()) { + return "{\"version\":1,\"kind\":\"webCal\",\"value\":\"" + + escaped + "\"}"; + } + } + } + return R"({"version":1,"kind":"normalLaunch"})"; +} + +bool IsValidBusyMaxActivation(const std::string& activation) { + if (activation.empty() || activation.size() > kMaximumActivationBytes || + !ValidUtf8(activation)) { + return false; + } + if (activation == R"({"version":1,"kind":"normalLaunch"})" || + activation == R"({"version":1,"kind":"startMinimized"})") { + return true; + } + std::string value; + if (DecodeValueActivation( + activation, R"({"version":1,"kind":"icsFile","value":")", + &value)) { + return value.size() <= 32767 && EndsWithIcs(value); + } + if (DecodeValueActivation( + activation, R"({"version":1,"kind":"webCal","value":")", + &value)) { + return value.size() <= 8192 && StartsWithWebcal(value); + } + return IsValidNotificationActivation(activation); +} diff --git a/windows/runner/single_instance.h b/windows/runner/single_instance.h new file mode 100644 index 00000000..20aa2922 --- /dev/null +++ b/windows/runner/single_instance.h @@ -0,0 +1,41 @@ +#ifndef RUNNER_SINGLE_INSTANCE_H_ +#define RUNNER_SINGLE_INSTANCE_H_ + +#include + +#include +#include +#include +#include +#include + +class BusyMaxSingleInstance { + public: + BusyMaxSingleInstance(); + ~BusyMaxSingleInstance(); + + BusyMaxSingleInstance(const BusyMaxSingleInstance&) = delete; + BusyMaxSingleInstance& operator=(const BusyMaxSingleInstance&) = delete; + + bool IsPrimary() const; + bool ForwardActivation(const std::string& activation) const; + bool Start(std::function on_activation); + void Stop(); + + private: + void Listen(); + + HANDLE mutex_ = nullptr; + std::wstring pipe_name_; + std::wstring user_sid_; + bool primary_ = false; + std::atomic stopping_ = false; + std::function on_activation_; + std::thread listener_; +}; + +std::string BuildBusyMaxActivation( + const std::vector& arguments); +bool IsValidBusyMaxActivation(const std::string& activation); + +#endif // RUNNER_SINGLE_INSTANCE_H_ diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 00000000..3cb71466 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/windows/runner/windows_startup_task.cpp b/windows/runner/windows_startup_task.cpp new file mode 100644 index 00000000..5ee814ce --- /dev/null +++ b/windows/runner/windows_startup_task.cpp @@ -0,0 +1,118 @@ +#include "windows_startup_task.h" + +#include +#include +#include + +namespace { + +constexpr wchar_t kStartupTaskId[] = L"BusyMaxStartupTask"; + +bool HasPackageIdentity() { + UINT32 length = 0; + const LONG result = GetCurrentPackageFullName(&length, nullptr); + return result == ERROR_INSUFFICIENT_BUFFER; +} + +std::string StateName( + winrt::Windows::ApplicationModel::StartupTaskState state) { + using State = winrt::Windows::ApplicationModel::StartupTaskState; + switch (state) { + case State::Enabled: + return "enabled"; + case State::Disabled: + return "disabled"; + case State::DisabledByUser: + return "disabledByUser"; + case State::DisabledByPolicy: + return "disabledByPolicy"; + default: + return "unavailable"; + } +} + +} // namespace + +void GetBusyMaxStartupTaskStateAsync(BusyMaxStartupTaskStateCallback callback) { + if (!HasPackageIdentity()) { + callback("unavailable"); + return; + } + try { + auto operation = + winrt::Windows::ApplicationModel::StartupTask::GetAsync(kStartupTaskId); + operation.Completed( + [callback]( + const auto& completed, + winrt::Windows::Foundation::AsyncStatus status) mutable { + if (status != winrt::Windows::Foundation::AsyncStatus::Completed) { + callback("unavailable"); + return; + } + try { + callback(StateName(completed.GetResults().State())); + } catch (...) { + callback("unavailable"); + } + }); + } catch (...) { + callback("unavailable"); + } +} + +void SetBusyMaxStartupTaskEnabledAsync( + bool enabled, + BusyMaxStartupTaskChangeCallback callback) { + if (!HasPackageIdentity()) { + callback(false, "BusyMax is not running with package identity."); + return; + } + try { + auto operation = + winrt::Windows::ApplicationModel::StartupTask::GetAsync(kStartupTaskId); + operation.Completed( + [enabled, callback]( + const auto& completed, + winrt::Windows::Foundation::AsyncStatus status) mutable { + if (status != winrt::Windows::Foundation::AsyncStatus::Completed) { + callback(false, "Windows StartupTask lookup failed."); + return; + } + try { + const auto task = completed.GetResults(); + if (!enabled) { + task.Disable(); + callback(true, ""); + return; + } + auto request = task.RequestEnableAsync(); + request.Completed( + [callback]( + const auto& enabled_operation, + winrt::Windows::Foundation::AsyncStatus enabled_status) { + if (enabled_status != + winrt::Windows::Foundation::AsyncStatus::Completed) { + callback(false, "Windows did not enable the StartupTask."); + return; + } + try { + const auto state = enabled_operation.GetResults(); + callback( + state == winrt::Windows::ApplicationModel:: + StartupTaskState::Enabled, + state == winrt::Windows::ApplicationModel:: + StartupTaskState::Enabled + ? "" + : "Windows did not enable the StartupTask."); + } catch (...) { + callback(false, "Windows StartupTask operation failed."); + } + }); + } catch (...) { + callback(false, "Windows StartupTask operation failed."); + } + }); + } catch (...) { + callback(false, "Windows StartupTask operation failed."); + } +} diff --git a/windows/runner/windows_startup_task.h b/windows/runner/windows_startup_task.h new file mode 100644 index 00000000..0459360d --- /dev/null +++ b/windows/runner/windows_startup_task.h @@ -0,0 +1,16 @@ +#ifndef RUNNER_WINDOWS_STARTUP_TASK_H_ +#define RUNNER_WINDOWS_STARTUP_TASK_H_ + +#include +#include + +using BusyMaxStartupTaskStateCallback = std::function; +using BusyMaxStartupTaskChangeCallback = + std::function; + +void GetBusyMaxStartupTaskStateAsync(BusyMaxStartupTaskStateCallback callback); +void SetBusyMaxStartupTaskEnabledAsync( + bool enabled, + BusyMaxStartupTaskChangeCallback callback); + +#endif // RUNNER_WINDOWS_STARTUP_TASK_H_ From 4ff59f1064ca397467cfa2aa40c80cb944217b28 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 16:28:12 -0700 Subject: [PATCH 24/69] Hardened vendored timezone and tray plugins with checked native calls, deterministic cleanup, health reporting, and Windows native tests. Added single-instance startup acknowledgement, bounded forwarding, strict activation validation, and executable C++ tests. --- .github/workflows/flutter-windows.yml | 7 + .gitignore | 4 + docs/windows_development.md | 12 +- docs/windows_packaging.md | 46 +- docs/windows_release_checklist.md | 6 +- lib/l10n/app_ar.arb | 3 + lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 3 + lib/l10n/app_es.arb | 3 + lib/l10n/app_et.arb | 3 + lib/l10n/app_fa.arb | 3 + lib/l10n/app_fi.arb | 3 + lib/l10n/app_fr.arb | 3 + lib/l10n/app_hi.arb | 3 + lib/l10n/app_it.arb | 3 + lib/l10n/app_ja.arb | 3 + lib/l10n/app_ko.arb | 3 + lib/l10n/app_pt_PT.arb | 3 + lib/l10n/app_ru.arb | 3 + lib/l10n/app_vi.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/l10n/app_zh_Hans.arb | 3 + lib/l10n/app_zh_Hant.arb | 3 + lib/l10n/generated/app_localizations.dart | 18 + lib/l10n/generated/app_localizations_ar.dart | 11 + lib/l10n/generated/app_localizations_de.dart | 12 + lib/l10n/generated/app_localizations_en.dart | 12 + lib/l10n/generated/app_localizations_es.dart | 12 + lib/l10n/generated/app_localizations_et.dart | 12 + lib/l10n/generated/app_localizations_fa.dart | 12 + lib/l10n/generated/app_localizations_fi.dart | 12 + lib/l10n/generated/app_localizations_fr.dart | 12 + lib/l10n/generated/app_localizations_hi.dart | 12 + lib/l10n/generated/app_localizations_it.dart | 12 + lib/l10n/generated/app_localizations_ja.dart | 11 + lib/l10n/generated/app_localizations_ko.dart | 11 + lib/l10n/generated/app_localizations_pt.dart | 24 + lib/l10n/generated/app_localizations_ru.dart | 12 + lib/l10n/generated/app_localizations_vi.dart | 12 + lib/l10n/generated/app_localizations_zh.dart | 33 ++ lib/main_linux.dart | 18 +- lib/main_windows.dart | 151 ++++- lib/src/app/app_bootstrap.dart | 9 + lib/src/core/secrets/secret_store.dart | 72 ++- lib/src/dav/sync/dav_account_sync_engine.dart | 6 +- .../desktop_notification_backend.dart | 20 + .../oauth/oauth_loopback_flow.dart | 7 +- .../linux_secret_storage_presentation.dart | 9 + .../windows/windows_notification_backend.dart | 175 +++++- .../windows_notification_id_store.dart | 158 +++++ .../windows_secret_storage_presentation.dart | 9 + .../windows/windows_time_zone_source.dart | 48 +- .../windows/windows_tray_service.dart | 62 ++ .../windows/windows_window_service.dart | 3 + .../ui/windows/windows_desktop_runtime.dart | 21 +- .../windows/windows_diagnostics_dialog.dart | 11 + lib/src/ui/windows/windows_settings_page.dart | 34 ++ .../notification_scheduler_test.dart | 27 + .../oauth/loopback_flow_test.dart | 62 ++ .../oauth/oauth_token_store_test.dart | 65 +++ .../windows_notification_backend_test.dart | 259 ++++++++ .../windows_notification_id_store_test.dart | 86 +++ ...indows_notification_readiness_ui_test.dart | 46 ++ .../windows_time_zone_source_test.dart | 27 + .../windows_tray_close_policy_test.dart | 28 + .../flutter_timezone/windows/CMakeLists.txt | 2 + .../windows/flutter_timezone_plugin.cpp | 210 +++---- .../windows/flutter_timezone_plugin.h | 12 +- .../test/flutter_timezone_plugin_test.cpp | 205 +++++++ .../windows/timezone_native_api.cpp | 206 +++++++ .../windows/timezone_native_api.h | 74 +++ .../tray_manager/windows/CMakeLists.txt | 31 +- .../windows/test/tray_native_api_test.cpp | 212 +++++++ .../windows/tray_manager_plugin.cpp | 551 +++++++++--------- .../tray_manager/windows/tray_native_api.cpp | 416 +++++++++++++ .../tray_manager/windows/tray_native_api.h | 124 ++++ tools/windows/build_release.ps1 | 20 +- tools/windows/common.ps1 | 108 +++- tools/windows/create_test_certificate.ps1 | 10 +- tools/windows/install_test_package.ps1 | 32 +- tools/windows/package_store.ps1 | 63 +- tools/windows/render_manifest.ps1 | 3 +- tools/windows/run_native_tests.ps1 | 24 + tools/windows/run_wack.ps1 | 57 +- tools/windows/tests/store_config.Tests.ps1 | 113 ++++ tools/windows/tests/wack_report.Tests.ps1 | 61 ++ tools/windows/validate_manifest.ps1 | 44 +- tools/windows/validate_package_contents.ps1 | 97 ++- tools/windows/validate_release_config.ps1 | 3 +- tools/windows/wack_report.ps1 | 131 +++++ windows/CMakeLists.txt | 1 + windows/runner/CMakeLists.txt | 12 + windows/runner/flutter_window.cpp | 20 + windows/runner/main.cpp | 58 +- windows/runner/single_instance.cpp | 253 +++++++- windows/runner/single_instance.h | 37 +- windows/runner/tests/single_instance_test.cpp | 181 ++++++ 97 files changed, 4552 insertions(+), 601 deletions(-) create mode 100644 lib/src/platform/linux/linux_secret_storage_presentation.dart create mode 100644 lib/src/platform/windows/windows_notification_id_store.dart create mode 100644 lib/src/platform/windows/windows_secret_storage_presentation.dart create mode 100644 test/platform/windows/windows_notification_backend_test.dart create mode 100644 test/platform/windows/windows_notification_id_store_test.dart create mode 100644 test/platform/windows/windows_notification_readiness_ui_test.dart create mode 100644 test/platform/windows/windows_tray_close_policy_test.dart create mode 100644 third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp create mode 100644 third_party/flutter_timezone/windows/timezone_native_api.cpp create mode 100644 third_party/flutter_timezone/windows/timezone_native_api.h create mode 100644 third_party/tray_manager/windows/test/tray_native_api_test.cpp create mode 100644 third_party/tray_manager/windows/tray_native_api.cpp create mode 100644 third_party/tray_manager/windows/tray_native_api.h create mode 100644 tools/windows/run_native_tests.ps1 create mode 100644 tools/windows/tests/store_config.Tests.ps1 create mode 100644 tools/windows/tests/wack_report.Tests.ps1 create mode 100644 tools/windows/wack_report.ps1 create mode 100644 windows/runner/tests/single_instance_test.cpp diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 11487931..86c51ee7 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -25,6 +25,12 @@ jobs: shell: pwsh run: flutter config --enable-windows-desktop + - name: Install pinned Pester + shell: pwsh + run: >- + Install-Module Pester -RequiredVersion 5.7.1 + -Scope CurrentUser -Force -AllowClobber + - name: Create explicitly non-production CI identity shell: pwsh run: | @@ -62,5 +68,6 @@ jobs: path: | build/windows/store/*.msix build/windows/store/staging/AppxManifest.xml + build/windows/store/*inventory.json build/windows/test-results/** if-no-files-found: error diff --git a/.gitignore b/.gitignore index 0b4f83d0..036b4cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ app.*.map.json config/windows_store.local.json *.pfx *.cer +*.p12 +*.pem +*.key +*.crt diff --git a/docs/windows_development.md b/docs/windows_development.md index 960b7c2a..af76b4bf 100644 --- a/docs/windows_development.md +++ b/docs/windows_development.md @@ -11,6 +11,7 @@ Flutter and Dart must come from the repository's pinned Flutter 3.44.4 SDK. - Visual Studio 2022 with **Desktop development with C++**, including the x64 MSVC toolchain and CMake tools. - Windows SDK `10.0.26100.0` or newer. +- Pester 5 (CI pins 5.7.1) for packaging and WACK parser contracts. - Developer Mode for unpackaged development, or a local test certificate for installed MSIX testing. @@ -37,9 +38,10 @@ flutter run -d windows -t lib/main_windows.dart ` ``` An unpackaged build accurately reports Windows StartupTask as unavailable. It -does not create a registry or Startup-folder fallback. Notification retrieval -and cancellation also require installed package identity; display and live -callbacks can still be exercised unpackaged. +does not create a registry or Startup-folder fallback. Windows notifications +are also deliberately unavailable without installed package identity, and the +Settings page shows that limitation. Display, cancellation, and action +callbacks must be exercised from a locally installed test-signed MSIX. Linux continues to use: @@ -89,10 +91,10 @@ On 2026-08-31, the post-port working tree was validated on Linux with Flutter | --- | --- | | `flutter gen-l10n` | Passed; every supported catalog generated. | | `dart run build_runner build --delete-conflicting-outputs` | Passed; generated Drift content remained consistent and the schema version remained 13. The pinned build runner reported that the legacy delete-conflicting option is ignored. | -| `dart format --output=none --set-exit-if-changed .` | Passed; 452 files checked, zero changes required. | +| `dart format --output=none --set-exit-if-changed .` | Passed; 458 files checked, zero changes required. | | `flutter analyze` | Passed; no issues found. | | `dart run tool/check_platform_boundaries.dart` | Passed. | -| `flutter test --reporter compact` | Passed; 1,540 tests passed, 10 skipped, zero failed. | +| `flutter test --reporter compact` | Passed; 1,566 tests passed, 10 skipped, zero failed. | | `flutter build linux --release -t lib/main_linux.dart` | Passed; produced `build/linux/x64/release/bundle/busymax`. | These results establish Linux and platform-neutral source health only. They do diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md index 1cb19754..4aafb6a6 100644 --- a/docs/windows_packaging.md +++ b/docs/windows_packaging.md @@ -30,6 +30,22 @@ component of 0, and components no greater than 65535. If a previous version is provided, the new value must be greater. CI deliberately uses non-production `1.0.0.0`; it is not an owner production version. +The scripts enforce three non-interchangeable validation modes: + +- `ProductionStore` is selected by the normal release build. It requires the + owner's final identity and `production=true`. +- `CiNonProduction` is selected only by `-Ci`. It requires + `production=false` and the exact committed CI identity used by the Windows + workflow. An owner or production configuration is rejected. +- `LocalTestSigning` is selected by the certificate and installation scripts. + It accepts the owner's complete production identity so the local certificate + subject can exactly match the Publisher rendered into the package. It never + applies CI identity rules. + +Every mode rejects placeholders, fake data, development backends, invalid +Store versions, and incomplete inputs. Production and local-test-signing modes +also require production HTTPS and OAuth configuration. + ## Build From a 64-bit Windows 11 PowerShell prompt: @@ -42,9 +58,10 @@ From a 64-bit Windows 11 PowerShell prompt: The script checks Windows, Flutter 3.44.4, Visual Studio x64 C++ tools, and a Windows 11 SDK; resolves dependencies; generates localization and Drift code; checks committed generation, formatting, analysis, tests, architecture -boundaries, and release inputs; builds `lib/main_windows.dart`; stages release -runtime files; renders and validates the manifest; packs the MSIX; and prints -its path, metadata, and SHA-256. It performs no network deployment. +boundaries, PowerShell contracts, native runner/plugin tests, and release +inputs; builds `lib/main_windows.dart`; stages release runtime files; renders +and validates the manifest; packs the MSIX; and prints its path, metadata, and +SHA-256. It performs no network deployment. `MaxVersionTested` is taken from the Windows SDK selected by the prerequisite check, so the manifest cannot claim a newer SDK than the release builder used. @@ -92,8 +109,11 @@ For installed-package testing only: The password is prompted, never stored. PFX/CER files and the signed copy stay under ignored build output. The installer prints a package-specific uninstall -command for cleanup. The unsigned Store-mode artifact must not depend on this -development certificate. +command for cleanup. Both scripts validate in `LocalTestSigning` mode. They +verify the generated/imported certificate subject against the configured +Publisher, and the installer unpacks the actual MSIX and verifies the same +subject against its `AppxManifest.xml` before signing. The unsigned Store-mode +artifact must not depend on this development certificate. ## Validation @@ -108,6 +128,13 @@ and `webcal` registration set, capabilities, and placeholder removal. data, fake data, debug symbols, Linux/Snap content, extra executables, and template branding while checking required Flutter assets and DLLs. +Validation is not limited to staging. After `MakeAppx` packs the artifact, the +packaging script unpacks that exact MSIX into a new clean directory, reruns the +manifest/content checks, writes a per-file SHA-256 inventory, and compares the +unpacked inventory with the deterministic staging inventory (excluding only +the package metadata files produced by `MakeAppx`). Both inventories are CI +artifacts. + After installing a release-equivalent locally signed package, run: ```powershell @@ -118,3 +145,12 @@ After installing a release-equivalent locally signed package, run: Retain the XML report. Any native runner, dependency, manifest, or packaging change invalidates an earlier WACK result and requires a repeat. See the [release checklist](windows_release_checklist.md) for behavioral coverage. + +`run_wack.ps1` parses both `OVERALL_RESULT` and every per-test `RESULT`; a zero +process exit code or a report file by itself is not accepted. Missing, empty, +malformed, result-free, or failing reports stop the release. When WACK emits a +warning, create a local JSON object whose keys exactly match the warning names +printed by the script and whose values are the written release dispositions, +then pass it with `-WarningDispositionPath`. The script refuses warnings with +missing, placeholder, or empty dispositions and emits `wack-summary.json` +beside the complete XML report. diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md index d38a9e86..b9710c2d 100644 --- a/docs/windows_release_checklist.md +++ b/docs/windows_release_checklist.md @@ -17,6 +17,9 @@ the release candidate. - [ ] CI creates only the explicitly non-production unsigned test MSIX and uploads it and test reports as CI artifacts; no deployment job exists. - [ ] Manifest and package-content validators pass. +- [ ] Executable runner, timezone, and tray native tests pass on Windows. +- [ ] The packed MSIX is unpacked to a clean directory; the exact final + manifest, runtime inventory, and per-file SHA-256 report pass validation. ## Installed package lifecycle @@ -84,7 +87,8 @@ the release candidate. assets, or missing Flutter/plugin/SQLite runtime DLL. - [ ] Clean Windows test environment without build tools installs and runs. - [ ] WACK finishes with zero failures; every warning is investigated and its - report is retained. Repeat after package-affecting changes. + written disposition, complete XML report, and parsed summary are + retained. Repeat after package-affecting changes. ## Release record diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 4bbc073c..27cfa7f7 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -419,6 +419,9 @@ "quietHoursStart": "بداية ساعات الهدوء", "quietHoursEnd": "نهاية ساعات الهدوء", "notifications": "الإشعارات", + "windowsNotificationsUnavailable": "إشعارات Windows غير متاحة", + "windowsNotificationsUnpackaged": "لا يمكن لتشغيل التطوير غير المحزّم هذا استخدام إشعارات Windows. ثبّت حزمة MSIX الموقّعة للاختبار لاختبار التذكيرات.", + "windowsNotificationsInstalledFailure": "تعذّر على BusyMax تهيئة إشعارات Windows. لن تظهر التذكيرات حتى تُحل مشكلة التثبيت هذه.", "appearance": "المظهر", "theme": "السمة", "themeSystem": "النظام", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 286fd00c..3121fb06 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -461,6 +461,9 @@ "quietHoursStart": "Beginn der Ruhezeit", "quietHoursEnd": "Ende der Ruhezeit", "notifications": "Benachrichtigungen", + "windowsNotificationsUnavailable": "Windows-Benachrichtigungen sind nicht verfügbar", + "windowsNotificationsUnpackaged": "Diese nicht paketierte Entwicklungsausführung kann keine Windows-Benachrichtigungen verwenden. Installieren Sie das testsignierte MSIX, um Erinnerungen zu testen.", + "windowsNotificationsInstalledFailure": "BusyMax konnte Windows-Benachrichtigungen nicht initialisieren. Erinnerungen werden erst angezeigt, wenn dieses Installationsproblem behoben ist.", "appearance": "Darstellung", "theme": "Design", "themeSystem": "Systemstandard", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f343c286..7c50746b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -901,6 +901,9 @@ "quietHoursStart": "Quiet hours start", "quietHoursEnd": "Quiet hours end", "notifications": "Notifications", + "windowsNotificationsUnavailable": "Windows notifications are unavailable", + "windowsNotificationsUnpackaged": "This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.", + "windowsNotificationsInstalledFailure": "BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.", "appearance": "Appearance", "theme": "Theme", "themeSystem": "System", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9655d5f4..484579af 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -461,6 +461,9 @@ "quietHoursStart": "Inicio del horario de silencio", "quietHoursEnd": "Fin del horario de silencio", "notifications": "Notificaciones", + "windowsNotificationsUnavailable": "Las notificaciones de Windows no están disponibles", + "windowsNotificationsUnpackaged": "Esta ejecución de desarrollo sin empaquetar no puede usar las notificaciones de Windows. Instala el MSIX firmado para pruebas para comprobar los recordatorios.", + "windowsNotificationsInstalledFailure": "BusyMax no pudo inicializar las notificaciones de Windows. Los recordatorios no aparecerán hasta que se resuelva este problema de instalación.", "appearance": "Apariencia", "theme": "Tema", "themeSystem": "Sistema", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index c1369c9b..f97cc89f 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -527,6 +527,9 @@ "quietHoursStart": "Vaikse aja algus", "quietHoursEnd": "Vaikse aja lõpp", "notifications": "Teavitused", + "windowsNotificationsUnavailable": "Windowsi teavitused pole saadaval", + "windowsNotificationsUnpackaged": "See pakendamata arendusversioon ei saa Windowsi teavitusi kasutada. Meeldetuletuste testimiseks paigalda testallkirjastatud MSIX.", + "windowsNotificationsInstalledFailure": "BusyMax ei saanud Windowsi teavitusi lähtestada. Meeldetuletusi ei kuvata enne, kui see paigaldusprobleem on lahendatud.", "appearance": "Välimus", "theme": "Kujundus", "themeSystem": "Süsteem", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 30d8dc6d..437d3396 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -424,6 +424,9 @@ "quietHoursStart": "شروع ساعات سکوت", "quietHoursEnd": "پایان ساعات سکوت", "notifications": "اعلان‌ها", + "windowsNotificationsUnavailable": "اعلان‌های Windows در دسترس نیستند", + "windowsNotificationsUnpackaged": "این اجرای توسعه‌ای بسته‌بندی‌نشده نمی‌تواند از اعلان‌های Windows استفاده کند. برای آزمایش یادآورها، MSIX امضاشدهٔ آزمایشی را نصب کنید.", + "windowsNotificationsInstalledFailure": "BusyMax نتوانست اعلان‌های Windows را راه‌اندازی کند. تا برطرف شدن این مشکل نصب، یادآورها نمایش داده نمی‌شوند.", "appearance": "ظاهر", "theme": "پوسته", "themeSystem": "سیستم", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 22deabf8..c406a27c 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -419,6 +419,9 @@ "quietHoursStart": "Hiljaisen ajan alku", "quietHoursEnd": "Hiljaisen ajan loppu", "notifications": "Ilmoitukset", + "windowsNotificationsUnavailable": "Windows-ilmoitukset eivät ole käytettävissä", + "windowsNotificationsUnpackaged": "Tämä paketoimaton kehitysajo ei voi käyttää Windows-ilmoituksia. Testaa muistutukset asentamalla testivarmenteella allekirjoitettu MSIX.", + "windowsNotificationsInstalledFailure": "BusyMax ei voinut alustaa Windows-ilmoituksia. Muistutuksia ei näytetä, ennen kuin tämä asennusongelma on ratkaistu.", "appearance": "Ulkoasu", "theme": "Teema", "themeSystem": "Järjestelmä", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0750730f..120ae93d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -461,6 +461,9 @@ "quietHoursStart": "Début de la période de silence", "quietHoursEnd": "Fin de la période de silence", "notifications": "Alertes", + "windowsNotificationsUnavailable": "Les notifications Windows sont indisponibles", + "windowsNotificationsUnpackaged": "Cette exécution de développement non empaquetée ne peut pas utiliser les notifications Windows. Installez le MSIX signé pour les tests afin de vérifier les rappels.", + "windowsNotificationsInstalledFailure": "BusyMax n’a pas pu initialiser les notifications Windows. Les rappels ne s’afficheront pas tant que ce problème d’installation ne sera pas résolu.", "appearance": "Apparence", "theme": "Thème", "themeSystem": "Système", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 1d07a771..ab45c469 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -419,6 +419,9 @@ "quietHoursStart": "शांत समय की शुरुआत", "quietHoursEnd": "शांत समय की समाप्ति", "notifications": "सूचनाएँ", + "windowsNotificationsUnavailable": "Windows सूचनाएँ उपलब्ध नहीं हैं", + "windowsNotificationsUnpackaged": "यह बिना पैकेज वाला डेवलपमेंट रन Windows सूचनाओं का उपयोग नहीं कर सकता। रिमाइंडर जाँचने के लिए परीक्षण-हस्ताक्षरित MSIX इंस्टॉल करें।", + "windowsNotificationsInstalledFailure": "BusyMax Windows सूचनाएँ शुरू नहीं कर सका। यह इंस्टॉलेशन समस्या हल होने तक रिमाइंडर दिखाई नहीं देंगे।", "appearance": "दिखावट", "theme": "थीम", "themeSystem": "सिस्टम", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index fbb56c76..40604746 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -419,6 +419,9 @@ "quietHoursStart": "Inizio delle ore di silenzio", "quietHoursEnd": "Fine delle ore di silenzio", "notifications": "Notifiche", + "windowsNotificationsUnavailable": "Le notifiche di Windows non sono disponibili", + "windowsNotificationsUnpackaged": "Questa esecuzione di sviluppo non pacchettizzata non può usare le notifiche di Windows. Installa il pacchetto MSIX firmato per i test per verificare i promemoria.", + "windowsNotificationsInstalledFailure": "BusyMax non ha potuto inizializzare le notifiche di Windows. I promemoria non appariranno finché il problema di installazione non sarà risolto.", "appearance": "Aspetto", "theme": "Tema", "themeSystem": "Sistema", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index bbcb7185..ec705fd6 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -419,6 +419,9 @@ "quietHoursStart": "通知停止の開始時刻", "quietHoursEnd": "通知停止の終了時刻", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知を利用できません", + "windowsNotificationsUnpackaged": "このパッケージ化されていない開発実行では Windows 通知を使用できません。リマインダーをテストするには、テスト署名済み MSIX をインストールしてください。", + "windowsNotificationsInstalledFailure": "BusyMax は Windows 通知を初期化できませんでした。このインストールの問題が解決するまで、リマインダーは表示されません。", "appearance": "外観", "theme": "テーマ", "themeSystem": "システム", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 67746959..e1e69b05 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -419,6 +419,9 @@ "quietHoursStart": "방해 금지 시작 시간", "quietHoursEnd": "방해 금지 종료 시간", "notifications": "알림", + "windowsNotificationsUnavailable": "Windows 알림을 사용할 수 없음", + "windowsNotificationsUnpackaged": "패키징되지 않은 이 개발 실행에서는 Windows 알림을 사용할 수 없습니다. 미리 알림을 테스트하려면 테스트 서명된 MSIX를 설치하세요.", + "windowsNotificationsInstalledFailure": "BusyMax에서 Windows 알림을 초기화하지 못했습니다. 이 설치 문제가 해결될 때까지 미리 알림이 표시되지 않습니다.", "appearance": "화면 모양", "theme": "테마", "themeSystem": "시스템", diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index e66dd5b2..e89498ad 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -419,6 +419,9 @@ "quietHoursStart": "Início do período de silêncio", "quietHoursEnd": "Fim do período de silêncio", "notifications": "Notificações", + "windowsNotificationsUnavailable": "As notificações do Windows não estão disponíveis", + "windowsNotificationsUnpackaged": "Esta execução de desenvolvimento sem pacote não pode usar as notificações do Windows. Instale o MSIX assinado para teste para verificar os lembretes.", + "windowsNotificationsInstalledFailure": "O BusyMax não conseguiu inicializar as notificações do Windows. Os lembretes não serão apresentados até este problema de instalação ser resolvido.", "appearance": "Aspeto", "theme": "Tema", "themeSystem": "Sistema", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 732ee771..20e04777 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -419,6 +419,9 @@ "quietHoursStart": "Начало периода", "quietHoursEnd": "Конец периода", "notifications": "Уведомления", + "windowsNotificationsUnavailable": "Уведомления Windows недоступны", + "windowsNotificationsUnpackaged": "Эта незапакованная сборка для разработки не может использовать уведомления Windows. Для проверки напоминаний установите тестово подписанный MSIX.", + "windowsNotificationsInstalledFailure": "BusyMax не удалось инициализировать уведомления Windows. Напоминания не будут отображаться, пока проблема установки не будет устранена.", "appearance": "Внешний вид", "theme": "Тема", "themeSystem": "Системная", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index a5808789..2194bcef 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -419,6 +419,9 @@ "quietHoursStart": "Bắt đầu giờ yên tĩnh", "quietHoursEnd": "Kết thúc giờ yên tĩnh", "notifications": "Thông báo", + "windowsNotificationsUnavailable": "Thông báo Windows không khả dụng", + "windowsNotificationsUnpackaged": "Bản chạy phát triển chưa đóng gói này không thể dùng thông báo Windows. Hãy cài đặt MSIX được ký để thử nghiệm nhằm kiểm tra lời nhắc.", + "windowsNotificationsInstalledFailure": "BusyMax không thể khởi tạo thông báo Windows. Lời nhắc sẽ không xuất hiện cho đến khi sự cố cài đặt này được khắc phục.", "appearance": "Giao diện", "theme": "Chủ đề", "themeSystem": "Hệ thống", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cb70669f..fed5c131 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -419,6 +419,9 @@ "quietHoursStart": "免打扰开始时间", "quietHoursEnd": "免打扰结束时间", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知不可用", + "windowsNotificationsUnpackaged": "此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。", + "windowsNotificationsInstalledFailure": "BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。", "appearance": "外观", "theme": "主题", "themeSystem": "系统", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index b7ff6065..58f64fdf 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -419,6 +419,9 @@ "quietHoursStart": "免打扰开始时间", "quietHoursEnd": "免打扰结束时间", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知不可用", + "windowsNotificationsUnpackaged": "此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。", + "windowsNotificationsInstalledFailure": "BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。", "appearance": "外观", "theme": "主题", "themeSystem": "系统", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 3c5a8c1a..55f039de 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -419,6 +419,9 @@ "quietHoursStart": "勿擾開始時間", "quietHoursEnd": "勿擾結束時間", "notifications": "通知", + "windowsNotificationsUnavailable": "Windows 通知無法使用", + "windowsNotificationsUnpackaged": "此未封裝的開發版本無法使用 Windows 通知。請安裝測試簽署的 MSIX 以測試提醒。", + "windowsNotificationsInstalledFailure": "BusyMax 無法初始化 Windows 通知。在解決此安裝問題之前,提醒不會顯示。", "appearance": "外觀", "theme": "主題", "themeSystem": "系統", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index b92d659c..88e47216 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3112,6 +3112,24 @@ abstract class AppLocalizations { /// **'Notifications'** String get notifications; + /// No description provided for @windowsNotificationsUnavailable. + /// + /// In en, this message translates to: + /// **'Windows notifications are unavailable'** + String get windowsNotificationsUnavailable; + + /// No description provided for @windowsNotificationsUnpackaged. + /// + /// In en, this message translates to: + /// **'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.'** + String get windowsNotificationsUnpackaged; + + /// No description provided for @windowsNotificationsInstalledFailure. + /// + /// In en, this message translates to: + /// **'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.'** + String get windowsNotificationsInstalledFailure; + /// No description provided for @appearance. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index dbe43ff9..e1014fd1 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1878,6 +1878,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String get notifications => 'الإشعارات'; + @override + String get windowsNotificationsUnavailable => 'إشعارات Windows غير متاحة'; + + @override + String get windowsNotificationsUnpackaged => + 'لا يمكن لتشغيل التطوير غير المحزّم هذا استخدام إشعارات Windows. ثبّت حزمة MSIX الموقّعة للاختبار لاختبار التذكيرات.'; + + @override + String get windowsNotificationsInstalledFailure => + 'تعذّر على BusyMax تهيئة إشعارات Windows. لن تظهر التذكيرات حتى تُحل مشكلة التثبيت هذه.'; + @override String get appearance => 'المظهر'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 6d8ecd9f..d09db222 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1812,6 +1812,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notifications => 'Benachrichtigungen'; + @override + String get windowsNotificationsUnavailable => + 'Windows-Benachrichtigungen sind nicht verfügbar'; + + @override + String get windowsNotificationsUnpackaged => + 'Diese nicht paketierte Entwicklungsausführung kann keine Windows-Benachrichtigungen verwenden. Installieren Sie das testsignierte MSIX, um Erinnerungen zu testen.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax konnte Windows-Benachrichtigungen nicht initialisieren. Erinnerungen werden erst angezeigt, wenn dieses Installationsproblem behoben ist.'; + @override String get appearance => 'Darstellung'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index f99be421..617d31e9 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1828,6 +1828,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifications => 'Notifications'; + @override + String get windowsNotificationsUnavailable => + 'Windows notifications are unavailable'; + + @override + String get windowsNotificationsUnpackaged => + 'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.'; + @override String get appearance => 'Appearance'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 45f2069d..37743cf5 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1815,6 +1815,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get notifications => 'Notificaciones'; + @override + String get windowsNotificationsUnavailable => + 'Las notificaciones de Windows no están disponibles'; + + @override + String get windowsNotificationsUnpackaged => + 'Esta ejecución de desarrollo sin empaquetar no puede usar las notificaciones de Windows. Instala el MSIX firmado para pruebas para comprobar los recordatorios.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax no pudo inicializar las notificaciones de Windows. Los recordatorios no aparecerán hasta que se resuelva este problema de instalación.'; + @override String get appearance => 'Apariencia'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index c25ddbcc..663c7058 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1796,6 +1796,18 @@ class AppLocalizationsEt extends AppLocalizations { @override String get notifications => 'Teavitused'; + @override + String get windowsNotificationsUnavailable => + 'Windowsi teavitused pole saadaval'; + + @override + String get windowsNotificationsUnpackaged => + 'See pakendamata arendusversioon ei saa Windowsi teavitusi kasutada. Meeldetuletuste testimiseks paigalda testallkirjastatud MSIX.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax ei saanud Windowsi teavitusi lähtestada. Meeldetuletusi ei kuvata enne, kui see paigaldusprobleem on lahendatud.'; + @override String get appearance => 'Välimus'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index de896827..da5511eb 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1833,6 +1833,18 @@ class AppLocalizationsFa extends AppLocalizations { @override String get notifications => 'اعلان‌ها'; + @override + String get windowsNotificationsUnavailable => + 'اعلان‌های Windows در دسترس نیستند'; + + @override + String get windowsNotificationsUnpackaged => + 'این اجرای توسعه‌ای بسته‌بندی‌نشده نمی‌تواند از اعلان‌های Windows استفاده کند. برای آزمایش یادآورها، MSIX امضاشدهٔ آزمایشی را نصب کنید.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax نتوانست اعلان‌های Windows را راه‌اندازی کند. تا برطرف شدن این مشکل نصب، یادآورها نمایش داده نمی‌شوند.'; + @override String get appearance => 'ظاهر'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index d7698989..118c5b3c 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1825,6 +1825,18 @@ class AppLocalizationsFi extends AppLocalizations { @override String get notifications => 'Ilmoitukset'; + @override + String get windowsNotificationsUnavailable => + 'Windows-ilmoitukset eivät ole käytettävissä'; + + @override + String get windowsNotificationsUnpackaged => + 'Tämä paketoimaton kehitysajo ei voi käyttää Windows-ilmoituksia. Testaa muistutukset asentamalla testivarmenteella allekirjoitettu MSIX.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax ei voinut alustaa Windows-ilmoituksia. Muistutuksia ei näytetä, ennen kuin tämä asennusongelma on ratkaistu.'; + @override String get appearance => 'Ulkoasu'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 333deacc..b21b3429 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1812,6 +1812,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get notifications => 'Alertes'; + @override + String get windowsNotificationsUnavailable => + 'Les notifications Windows sont indisponibles'; + + @override + String get windowsNotificationsUnpackaged => + 'Cette exécution de développement non empaquetée ne peut pas utiliser les notifications Windows. Installez le MSIX signé pour les tests afin de vérifier les rappels.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax n’a pas pu initialiser les notifications Windows. Les rappels ne s’afficheront pas tant que ce problème d’installation ne sera pas résolu.'; + @override String get appearance => 'Apparence'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index d2ca1c51..25c1bb25 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1815,6 +1815,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get notifications => 'सूचनाएँ'; + @override + String get windowsNotificationsUnavailable => + 'Windows सूचनाएँ उपलब्ध नहीं हैं'; + + @override + String get windowsNotificationsUnpackaged => + 'यह बिना पैकेज वाला डेवलपमेंट रन Windows सूचनाओं का उपयोग नहीं कर सकता। रिमाइंडर जाँचने के लिए परीक्षण-हस्ताक्षरित MSIX इंस्टॉल करें।'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax Windows सूचनाएँ शुरू नहीं कर सका। यह इंस्टॉलेशन समस्या हल होने तक रिमाइंडर दिखाई नहीं देंगे।'; + @override String get appearance => 'दिखावट'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 9e279463..2a39f396 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1827,6 +1827,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get notifications => 'Notifiche'; + @override + String get windowsNotificationsUnavailable => + 'Le notifiche di Windows non sono disponibili'; + + @override + String get windowsNotificationsUnpackaged => + 'Questa esecuzione di sviluppo non pacchettizzata non può usare le notifiche di Windows. Installa il pacchetto MSIX firmato per i test per verificare i promemoria.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax non ha potuto inizializzare le notifiche di Windows. I promemoria non appariranno finché il problema di installazione non sarà risolto.'; + @override String get appearance => 'Aspetto'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 7aa5c499..6083ba6f 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1782,6 +1782,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get notifications => '通知'; + @override + String get windowsNotificationsUnavailable => 'Windows 通知を利用できません'; + + @override + String get windowsNotificationsUnpackaged => + 'このパッケージ化されていない開発実行では Windows 通知を使用できません。リマインダーをテストするには、テスト署名済み MSIX をインストールしてください。'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax は Windows 通知を初期化できませんでした。このインストールの問題が解決するまで、リマインダーは表示されません。'; + @override String get appearance => '外観'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index b4201284..ec3b9424 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1785,6 +1785,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notifications => '알림'; + @override + String get windowsNotificationsUnavailable => 'Windows 알림을 사용할 수 없음'; + + @override + String get windowsNotificationsUnpackaged => + '패키징되지 않은 이 개발 실행에서는 Windows 알림을 사용할 수 없습니다. 미리 알림을 테스트하려면 테스트 서명된 MSIX를 설치하세요.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax에서 Windows 알림을 초기화하지 못했습니다. 이 설치 문제가 해결될 때까지 미리 알림이 표시되지 않습니다.'; + @override String get appearance => '화면 모양'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 97312c16..f304dbde 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1828,6 +1828,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get notifications => 'Notifications'; + @override + String get windowsNotificationsUnavailable => + 'Windows notifications are unavailable'; + + @override + String get windowsNotificationsUnpackaged => + 'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.'; + @override String get appearance => 'Appearance'; @@ -4489,6 +4501,18 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get notifications => 'Notificações'; + @override + String get windowsNotificationsUnavailable => + 'As notificações do Windows não estão disponíveis'; + + @override + String get windowsNotificationsUnpackaged => + 'Esta execução de desenvolvimento sem pacote não pode usar as notificações do Windows. Instale o MSIX assinado para teste para verificar os lembretes.'; + + @override + String get windowsNotificationsInstalledFailure => + 'O BusyMax não conseguiu inicializar as notificações do Windows. Os lembretes não serão apresentados até este problema de instalação ser resolvido.'; + @override String get appearance => 'Aspeto'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 67601e98..22ecd413 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1832,6 +1832,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get notifications => 'Уведомления'; + @override + String get windowsNotificationsUnavailable => + 'Уведомления Windows недоступны'; + + @override + String get windowsNotificationsUnpackaged => + 'Эта незапакованная сборка для разработки не может использовать уведомления Windows. Для проверки напоминаний установите тестово подписанный MSIX.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax не удалось инициализировать уведомления Windows. Напоминания не будут отображаться, пока проблема установки не будет устранена.'; + @override String get appearance => 'Внешний вид'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 48bfa36b..1468dbee 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1813,6 +1813,18 @@ class AppLocalizationsVi extends AppLocalizations { @override String get notifications => 'Thông báo'; + @override + String get windowsNotificationsUnavailable => + 'Thông báo Windows không khả dụng'; + + @override + String get windowsNotificationsUnpackaged => + 'Bản chạy phát triển chưa đóng gói này không thể dùng thông báo Windows. Hãy cài đặt MSIX được ký để thử nghiệm nhằm kiểm tra lời nhắc.'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax không thể khởi tạo thông báo Windows. Lời nhắc sẽ không xuất hiện cho đến khi sự cố cài đặt này được khắc phục.'; + @override String get appearance => 'Giao diện'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 21ea0962..d7072bc7 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1768,6 +1768,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notifications => '通知'; + @override + String get windowsNotificationsUnavailable => 'Windows 通知不可用'; + + @override + String get windowsNotificationsUnpackaged => + '此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。'; + @override String get appearance => '外观'; @@ -4328,6 +4339,17 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get notifications => '通知'; + @override + String get windowsNotificationsUnavailable => 'Windows 通知不可用'; + + @override + String get windowsNotificationsUnpackaged => + '此未打包的开发版本无法使用 Windows 通知。请安装测试签名的 MSIX 以测试提醒。'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax 无法初始化 Windows 通知。在解决此安装问题之前,提醒不会显示。'; + @override String get appearance => '外观'; @@ -6889,6 +6911,17 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get notifications => '通知'; + @override + String get windowsNotificationsUnavailable => 'Windows 通知無法使用'; + + @override + String get windowsNotificationsUnpackaged => + '此未封裝的開發版本無法使用 Windows 通知。請安裝測試簽署的 MSIX 以測試提醒。'; + + @override + String get windowsNotificationsInstalledFailure => + 'BusyMax 無法初始化 Windows 通知。在解決此安裝問題之前,提醒不會顯示。'; + @override String get appearance => '外觀'; diff --git a/lib/main_linux.dart b/lib/main_linux.dart index 82c68979..516dd396 100644 --- a/lib/main_linux.dart +++ b/lib/main_linux.dart @@ -11,14 +11,17 @@ import 'src/app/busymax_app.dart'; import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; import 'src/core/secrets/portal_encrypted_secret_store.dart'; +import 'src/core/secrets/secret_store.dart'; import 'src/core/time/linux_gweather_location_source.dart'; import 'src/core/time/local_time_zone.dart'; import 'src/core/time/time_zone_catalog.dart'; import 'src/demo/demo_profile.dart'; +import 'src/features/notifications/desktop_notification_backend.dart'; import 'src/platform/common/desktop_services.dart'; import 'src/platform/external_calendar_open_service.dart'; import 'src/platform/gtk_font_service.dart'; import 'src/platform/linux/linux_notification_backend.dart'; +import 'src/platform/linux/linux_secret_storage_presentation.dart'; import 'src/platform/linux_autostart_service.dart'; import 'src/platform/linux_header_bar_service.dart'; import 'src/platform/linux_window_service.dart'; @@ -89,10 +92,21 @@ Future main(List arguments) async { ref.onDispose(() => unawaited(backend.close())); return backend; }), + desktopNotificationReadinessProvider.overrideWith( + (ref) => const DesktopNotificationReadiness( + DesktopNotificationReadinessState.available, + ), + ), localTimeZoneSourceProvider.overrideWithValue(timeZoneSource), localTimeZoneProvider.overrideWithValue(localTimeZone), - if (Platform.environment['SNAP']?.isNotEmpty ?? false) - secretStoreProvider.overrideWith((ref) => PortalEncryptedSecretStore()), + secretStoreProvider.overrideWith( + (ref) => Platform.environment['SNAP']?.isNotEmpty ?? false + ? PortalEncryptedSecretStore() + : SecureSecretStore( + ref.watch(secureStorageProvider), + presentation: linuxSecretStoragePresentation, + ), + ), ]; final demoProfile = buildConfig.useFakeProviderData ? await BusyMaxDemoProfile.create() diff --git a/lib/main_windows.dart b/lib/main_windows.dart index afebecb7..c7f89ada 100644 --- a/lib/main_windows.dart +++ b/lib/main_windows.dart @@ -1,19 +1,23 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:system_theme/system_theme.dart'; +import 'package:logging/logging.dart'; import 'src/app/app_bootstrap.dart'; import 'src/app/windows/windows_busymax_app.dart'; import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; +import 'src/core/secrets/secret_store.dart'; import 'src/demo/demo_profile.dart'; import 'src/features/notifications/desktop_notification_backend.dart'; import 'src/platform/common/desktop_services.dart'; import 'src/platform/windows/windows_activation_service.dart'; import 'src/platform/windows/windows_autostart_service.dart'; import 'src/platform/windows/windows_notification_backend.dart'; +import 'src/platform/windows/windows_secret_storage_presentation.dart'; import 'src/platform/windows/windows_system_appearance_source.dart'; import 'src/platform/windows/windows_time_zone_source.dart'; import 'src/platform/windows/windows_window_service.dart'; @@ -21,6 +25,9 @@ import 'src/platform/windows/windows_window_service.dart'; Future main(List arguments) async { final binding = WidgetsFlutterBinding.ensureInitialized(); binding.deferFirstFrame(); + // Platform initialization failures must be captured, but only through the + // repository's redacting logger. + configureLogging(); final buildConfig = BuildConfig.fromEnvironment(); if (arguments.contains(busyMaxNotificationForwarderArgument)) { await _runNotificationActivationForwarder(buildConfig); @@ -43,21 +50,92 @@ Future main(List arguments) async { final appearanceSource = WindowsSystemAppearanceSource(); DesktopNotificationBackend notificationBackend = const DisabledDesktopNotificationBackend(); - if (buildConfig.windowsAppUserModelId.trim().isNotEmpty) { + DesktopNotificationReadiness? notificationReadiness; + bool packaged; + try { + packaged = await WindowsNotificationBackend.hasPackageIdentity(); + } on Object { + packaged = false; + final installedFailure = buildConfig.windowsStoreMode; + notificationReadiness = DesktopNotificationReadiness( + installedFailure + ? DesktopNotificationReadinessState.failedInstalledPackage + : DesktopNotificationReadinessState.unavailableUnpackaged, + diagnosticCode: 'windows-notifications/package-identity-query-failed', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification package identity could not be queried.'); + } + if (notificationReadiness != null) { + // The failed identity probe above already selected the only safe backend. + } else if (!packaged) { + final installedFailure = buildConfig.windowsStoreMode; + notificationReadiness = DesktopNotificationReadiness( + installedFailure + ? DesktopNotificationReadinessState.failedInstalledPackage + : DesktopNotificationReadinessState.unavailableUnpackaged, + diagnosticCode: installedFailure + ? 'windows-notifications/package-identity-missing' + : 'windows-notifications/unpackaged', + ); + final logger = Logger('busymax.windows.notifications'); + if (installedFailure) { + logger.severe( + 'Windows notification package identity is missing from a Store build.', + ); + } else { + logger.warning( + 'Windows notifications are unavailable without package identity.', + ); + } + } else if (buildConfig.windowsAppUserModelId.trim().isEmpty) { + notificationReadiness = const DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + diagnosticCode: 'windows-notifications/aumid-missing', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification initialization failed: AUMID is missing.'); + } else { try { notificationBackend = await WindowsNotificationBackend.create( appUserModelId: buildConfig.windowsAppUserModelId, onActivation: activationService.accept, ); + notificationReadiness = const DesktopNotificationReadiness( + DesktopNotificationReadinessState.available, + ); + } on WindowsNotificationInitializationException catch (error) { + notificationReadiness = DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + diagnosticCode: 'windows-notifications/${error.code}', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification initialization failed: ${error.code}.'); + notificationBackend = const DisabledDesktopNotificationBackend(); } on Object { + notificationReadiness = const DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + diagnosticCode: 'windows-notifications/initialization-failed', + ); + Logger( + 'busymax.windows.notifications', + ).severe('Windows notification initialization failed.'); notificationBackend = const DisabledDesktopNotificationBackend(); } } - configureLogging(); final overrides = [ buildConfigProvider.overrideWithValue(buildConfig), localSettingsStoreProvider.overrideWithValue(settingsStore), + secretStoreProvider.overrideWith( + (ref) => SecureSecretStore( + ref.watch(secureStorageProvider), + presentation: windowsSecretStoragePresentation, + ), + ), initialAppSettingsProvider.overrideWithValue(initialSettings), desktopWindowServiceProvider.overrideWithValue( const WindowsWindowService(), @@ -73,6 +151,9 @@ Future main(List arguments) async { ref.onDispose(() => unawaited(notificationBackend.close())); return notificationBackend; }), + desktopNotificationReadinessProvider.overrideWith( + (ref) => notificationReadiness!, + ), systemAppearanceSourceProvider.overrideWith((ref) { ref.onDispose(() => unawaited(appearanceSource.dispose())); return appearanceSource; @@ -100,25 +181,64 @@ Future main(List arguments) async { Future _runNotificationActivationForwarder( BuildConfig buildConfig, ) async { + final logger = Logger('busymax.windows.notification_forwarder'); final completed = Completer(); var finishing = false; WindowsNotificationBackend? backend; + Timer? activationTimeout; + const windowService = WindowsWindowService(); + + Future reportFailure() async { + try { + await windowService.reportNotificationActivationFailure(); + } on Object { + logger.severe('Notification activation failure could not be displayed.'); + } + } Future finish([DesktopActivation? activation]) async { if (finishing) return; finishing = true; + activationTimeout?.cancel(); try { if (activation != null) { - await forwardWindowsActivationToPrimary(activation); + final forwarded = await forwardWindowsActivationToPrimary(activation); + if (!forwarded) { + logger.severe('Notification activation forwarding failed.'); + exitCode = 75; + await reportFailure(); + } } await backend?.close(); } finally { if (!completed.isCompleted) completed.complete(); - await const WindowsWindowService().quitApp(); + await windowService.quitApp(); } } + final bool packaged; + try { + packaged = await WindowsNotificationBackend.hasPackageIdentity(); + } on Object { + logger.severe('Notification package identity could not be queried.'); + exitCode = 70; + await reportFailure(); + await finish(); + return; + } + if (!packaged) { + logger.severe( + 'Notification activation cannot run without package identity.', + ); + exitCode = 78; + await reportFailure(); + await finish(); + return; + } if (buildConfig.windowsAppUserModelId.trim().isEmpty) { + logger.severe('Notification activation cannot run without an AUMID.'); + exitCode = 78; + await reportFailure(); await finish(); return; } @@ -126,12 +246,31 @@ Future _runNotificationActivationForwarder( backend = await WindowsNotificationBackend.create( appUserModelId: buildConfig.windowsAppUserModelId, onActivation: (activation) => unawaited(finish(activation)), + activationForwarderOnly: true, + ); + } on WindowsNotificationInitializationException catch (error) { + logger.severe( + 'Notification activation initialization failed: ${error.code}.', ); + exitCode = 70; + await reportFailure(); + await finish(); + return; } on Object { + logger.severe('Notification activation initialization failed.'); + exitCode = 70; + await reportFailure(); await finish(); return; } - final timeout = Timer(const Duration(seconds: 15), () => unawaited(finish())); + activationTimeout = Timer(const Duration(seconds: 15), () { + logger.severe('Notification activation timed out.'); + exitCode = 75; + unawaited(() async { + await reportFailure(); + await finish(); + }()); + }); await completed.future; - timeout.cancel(); + activationTimeout.cancel(); } diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 57e31ec9..d61dcf8c 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -231,6 +231,11 @@ final desktopNotificationBackendProvider = Provider( (ref) => const DisabledDesktopNotificationBackend(), ); +final desktopNotificationReadinessProvider = + StateProvider( + (ref) => const DesktopNotificationReadiness.initializing(), + ); + final desktopNotificationServiceProvider = Provider( (ref) { final settings = ref.watch(appSettingsControllerProvider); @@ -246,6 +251,10 @@ final desktopWindowServiceProvider = Provider( (ref) => const NoOpDesktopWindowService(), ); +/// Sanitized platform adapter health only; never contains menu labels or user +/// content. +final desktopTrayDiagnosticProvider = StateProvider((ref) => null); + final linuxWindowServiceProvider = desktopWindowServiceProvider; final desktopActivationServiceProvider = Provider( diff --git a/lib/src/core/secrets/secret_store.dart b/lib/src/core/secrets/secret_store.dart index c329b18a..c0797c62 100644 --- a/lib/src/core/secrets/secret_store.dart +++ b/lib/src/core/secrets/secret_store.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -311,12 +310,36 @@ extension OAuthSecretStoreAccess on SecretStore { } } +class SecretStoragePresentation { + const SecretStoragePresentation({ + required this.backendDomain, + required this.runtimeBackend, + required this.unavailableMessage, + }); + + static const generic = SecretStoragePresentation( + backendDomain: 'busymax.secure_storage', + runtimeBackend: 'platform-credential-storage', + unavailableMessage: + 'Secure credential storage is temporarily unavailable. Check your ' + 'operating system credential storage and try again.', + ); + + final String backendDomain; + final String runtimeBackend; + final String unavailableMessage; +} + class SecureSecretStore implements SecretStore { - SecureSecretStore(this._storage, {RedactingLogger? logger}) - : _logger = logger ?? RedactingLogger(Logger('SecureSecretStore')); + SecureSecretStore( + this._storage, { + RedactingLogger? logger, + this.presentation = SecretStoragePresentation.generic, + }) : _logger = logger ?? RedactingLogger(Logger('SecureSecretStore')); final FlutterSecureStorage _storage; final RedactingLogger _logger; + final SecretStoragePresentation presentation; var _loggedRuntime = false; static const activeAccountKey = 'busymax.secret.active_account_id'; @@ -453,8 +476,7 @@ class SecureSecretStore implements SecretStore { } _loggedRuntime = true; _logger.info( - 'Secret storage runtime: backend=flutter-secure-storage ' - 'snap=${_isRunningInSnap()} secret_backend=${_secretBackendLabel()}', + 'Secret storage runtime: backend=${presentation.runtimeBackend}', ); } @@ -464,11 +486,11 @@ class SecureSecretStore implements SecretStore { ) { _logger.warning( 'Secret storage $operation failed: ' - '${sanitizedFlutterSecureStorageError(error)}', + '${sanitizedFlutterSecureStorageError(error, backendDomain: presentation.backendDomain)}', ); - return const SecretStoreException( + return SecretStoreException( 'SecretStoreUnavailable', - secretStorageUnavailableMessage, + presentation.unavailableMessage, ); } } @@ -542,12 +564,26 @@ class SecretStoreCredentialMismatchException extends SecretStoreException { } const secretStorageUnavailableMessage = - 'Secure credential storage is locked. Unlock your system keyring and try again.'; + 'Secure credential storage is temporarily unavailable. Check your operating system credential storage and try again.'; + +String sanitizedFlutterSecureStorageError( + PlatformException error, { + String backendDomain = 'busymax.secure_storage', +}) { + String safeIdentifier(String value, String fallback) { + final normalized = value.trim(); + return normalized.length <= 96 && + RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(normalized) + ? normalized + : fallback; + } -String sanitizedFlutterSecureStorageError(PlatformException error) { - final message = redactForLog(error.message).replaceAll(RegExp(r'\s+'), ' '); - return 'domain=flutter_secure_storage_linux code=${error.code} ' - 'message=${message.trim()} details_type=${error.details.runtimeType}'; + // Platform messages and details are intentionally omitted. Backends may + // include key names, paths, or credential-provider output in those fields. + final domain = safeIdentifier(backendDomain, 'busymax.secure_storage'); + final code = safeIdentifier(error.code, 'backend-error'); + return 'domain=$domain code=$code ' + 'details_type=${error.details.runtimeType}'; } SecretRecord _decodeCredential(String serialized) { @@ -596,13 +632,3 @@ const _legacyOAuthFieldNames = [ 'token_type', 'scope', ]; - -bool _isRunningInSnap() => Platform.environment['SNAP']?.isNotEmpty ?? false; - -String _secretBackendLabel() { - final backend = Platform.environment['SECRET_BACKEND']; - if (backend == null || backend.isEmpty) { - return ''; - } - return backend == 'file' ? 'file' : ''; -} diff --git a/lib/src/dav/sync/dav_account_sync_engine.dart b/lib/src/dav/sync/dav_account_sync_engine.dart index 84b10218..549a46bd 100644 --- a/lib/src/dav/sync/dav_account_sync_engine.dart +++ b/lib/src/dav/sync/dav_account_sync_engine.dart @@ -368,12 +368,10 @@ final class DavAccountSyncEngine { DavException _mapSecretStoreFailure(SecretStoreException error) { if (error.code == 'SecretStoreUnavailable') { - return const DavException( + return DavException( kind: DavErrorKind.network, code: 'DavCredentialStoreUnavailable', - safeMessage: - 'Secure credential storage is temporarily unavailable. Unlock ' - 'the system keyring and try again.', + safeMessage: error.message, ); } return const DavException( diff --git a/lib/src/features/notifications/desktop_notification_backend.dart b/lib/src/features/notifications/desktop_notification_backend.dart index e2f5a2f1..a02cab0e 100644 --- a/lib/src/features/notifications/desktop_notification_backend.dart +++ b/lib/src/features/notifications/desktop_notification_backend.dart @@ -1,5 +1,25 @@ import 'package:flutter/foundation.dart'; +enum DesktopNotificationReadinessState { + initializing, + available, + unavailableUnpackaged, + failedInstalledPackage, +} + +@immutable +final class DesktopNotificationReadiness { + const DesktopNotificationReadiness(this.state, {this.diagnosticCode}); + + const DesktopNotificationReadiness.initializing() + : this(DesktopNotificationReadinessState.initializing); + + final DesktopNotificationReadinessState state; + final String? diagnosticCode; + + bool get canNotify => state == DesktopNotificationReadinessState.available; +} + typedef DesktopNotificationActionHandler = Future Function(String action, Map? payload); diff --git a/lib/src/google_tasks/oauth/oauth_loopback_flow.dart b/lib/src/google_tasks/oauth/oauth_loopback_flow.dart index 44abf7cf..391f37ab 100644 --- a/lib/src/google_tasks/oauth/oauth_loopback_flow.dart +++ b/lib/src/google_tasks/oauth/oauth_loopback_flow.dart @@ -363,12 +363,7 @@ Uri _redactedAuthorizationUri(Uri uri) { } Future _defaultAuthorizationLauncher(Uri authorizationUri) async { - if (await launchUrl(authorizationUri, mode: LaunchMode.externalApplication)) { - return true; - } - - await Process.start('xdg-open', [authorizationUri.toString()]); - return true; + return launchUrl(authorizationUri, mode: LaunchMode.externalApplication); } Future _writeBrowserResponse(HttpResponse response) async { diff --git a/lib/src/platform/linux/linux_secret_storage_presentation.dart b/lib/src/platform/linux/linux_secret_storage_presentation.dart new file mode 100644 index 00000000..7a9809db --- /dev/null +++ b/lib/src/platform/linux/linux_secret_storage_presentation.dart @@ -0,0 +1,9 @@ +import '../../core/secrets/secret_store.dart'; + +const linuxSecretStoragePresentation = SecretStoragePresentation( + backendDomain: 'busymax.secure_storage.linux', + runtimeBackend: 'linux-keyring', + unavailableMessage: + 'Secure credential storage is temporarily unavailable. Unlock your ' + 'system keyring and try again.', +); diff --git a/lib/src/platform/windows/windows_notification_backend.dart b/lib/src/platform/windows/windows_notification_backend.dart index 96e555fe..437ff806 100644 --- a/lib/src/platform/windows/windows_notification_backend.dart +++ b/lib/src/platform/windows/windows_notification_backend.dart @@ -1,9 +1,12 @@ import 'dart:convert'; +import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../../features/notifications/desktop_notification_backend.dart'; import '../common/desktop_services.dart'; +import 'windows_notification_id_store.dart'; +import 'windows_window_service.dart'; const busyMaxToastActivatorClsid = '7B854A6D-8B2A-45A5-B998-1F51EC5A81D7'; const busyMaxNotificationActivationServerArgument = @@ -12,15 +15,29 @@ const busyMaxNotificationForwarderArgument = '--busymax-notification-forwarder'; final class WindowsNotificationBackend implements DesktopNotificationBackend { WindowsNotificationBackend._({ - required FlutterLocalNotificationsPlugin plugin, + required WindowsNotificationPluginAdapter plugin, required void Function(DesktopActivation activation) onActivation, + required WindowsNotificationIdStore? idStore, }) : _plugin = plugin, - _onActivation = onActivation; + _onActivation = onActivation, + _idStore = idStore; + + static Future hasPackageIdentity({ + MethodChannel channel = const MethodChannel(windowsDesktopChannelName), + }) async { + try { + return await channel.invokeMethod('hasPackageIdentity') ?? false; + } on MissingPluginException { + return false; + } + } static Future create({ required String appUserModelId, required void Function(DesktopActivation activation) onActivation, - FlutterLocalNotificationsPlugin? plugin, + WindowsNotificationPluginAdapter? plugin, + WindowsNotificationIdStore? idStore, + bool activationForwarderOnly = false, }) async { if (appUserModelId.trim().isEmpty) { throw ArgumentError.value( @@ -29,31 +46,62 @@ final class WindowsNotificationBackend implements DesktopNotificationBackend { 'A stable AUMID is required for Windows notifications.', ); } - final notifications = plugin ?? FlutterLocalNotificationsPlugin(); + WindowsNotificationIdStore? notificationIds; + if (!activationForwarderOnly) { + notificationIds = + idStore ?? await WindowsNotificationIdStore.openDefault(); + await notificationIds.initialize(); + } + final notifications = + plugin ?? + FlutterWindowsNotificationPluginAdapter( + FlutterLocalNotificationsPlugin(), + ); late final WindowsNotificationBackend backend; backend = WindowsNotificationBackend._( plugin: notifications, onActivation: onActivation, + idStore: notificationIds, ); - await notifications.initialize( - settings: InitializationSettings( - windows: WindowsInitializationSettings( - appName: 'BusyMax', - appUserModelId: appUserModelId, - guid: busyMaxToastActivatorClsid, + try { + final initialized = await notifications.initialize( + settings: InitializationSettings( + windows: WindowsInitializationSettings( + appName: 'BusyMax', + appUserModelId: appUserModelId, + guid: busyMaxToastActivatorClsid, + ), ), - ), - onDidReceiveNotificationResponse: backend._handleResponse, - ); - final launch = await notifications.getNotificationAppLaunchDetails(); - if (launch?.didNotificationLaunchApp ?? false) { - backend._handleResponse(launch!.notificationResponse!); + onDidReceiveNotificationResponse: backend._handleResponse, + ); + if (initialized != true) { + throw const WindowsNotificationInitializationException( + 'toast-registration-failed', + ); + } + final launch = await notifications.getNotificationAppLaunchDetails(); + if (launch?.didNotificationLaunchApp ?? false) { + final response = launch?.notificationResponse; + if (response == null) { + throw const WindowsNotificationInitializationException( + 'cold-activation-invalid', + ); + } + backend._handleResponse(response); + } + } on WindowsNotificationInitializationException { + rethrow; + } on Object { + throw const WindowsNotificationInitializationException( + 'initialization-failed', + ); } return backend; } - final FlutterLocalNotificationsPlugin _plugin; + final WindowsNotificationPluginAdapter _plugin; final void Function(DesktopActivation activation) _onActivation; + final WindowsNotificationIdStore? _idStore; @override Future notify( @@ -94,8 +142,15 @@ final class WindowsNotificationBackend implements DesktopNotificationBackend { ...requestPayload, 'stableId': request.stableId, }; + final idStore = _idStore; + if (idStore == null) { + throw StateError( + 'The notification activation forwarder cannot display notifications.', + ); + } + final notificationId = await idStore.idFor(request.stableId); await _plugin.show( - id: _notificationId(request.stableId), + id: notificationId, title: request.title, body: request.body, payload: jsonEncode(payload), @@ -165,12 +220,82 @@ final class WindowsNotificationBackend implements DesktopNotificationBackend { @override Future cancel(String stableId) async { - await _plugin.cancel(id: _notificationId(stableId)); + final idStore = _idStore; + if (idStore == null) { + throw StateError( + 'The notification activation forwarder cannot cancel notifications.', + ); + } + await _plugin.cancel(id: await idStore.idFor(stableId)); } @override Future close() async { - _plugin + await _plugin.dispose(); + } +} + +abstract interface class WindowsNotificationPluginAdapter { + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + }); + + Future getNotificationAppLaunchDetails(); + + Future show({ + required int id, + String? title, + String? body, + NotificationDetails? notificationDetails, + String? payload, + }); + + Future cancel({required int id}); + + Future dispose(); +} + +final class FlutterWindowsNotificationPluginAdapter + implements WindowsNotificationPluginAdapter { + FlutterWindowsNotificationPluginAdapter(this.plugin); + + final FlutterLocalNotificationsPlugin plugin; + + @override + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + }) => plugin.initialize( + settings: settings, + onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, + ); + + @override + Future getNotificationAppLaunchDetails() => + plugin.getNotificationAppLaunchDetails(); + + @override + Future show({ + required int id, + String? title, + String? body, + NotificationDetails? notificationDetails, + String? payload, + }) => plugin.show( + id: id, + title: title, + body: body, + notificationDetails: notificationDetails, + payload: payload, + ); + + @override + Future cancel({required int id}) => plugin.cancel(id: id); + + @override + Future dispose() async { + plugin .resolvePlatformSpecificImplementation< FlutterLocalNotificationsWindows >() @@ -178,10 +303,8 @@ final class WindowsNotificationBackend implements DesktopNotificationBackend { } } -int _notificationId(String stableId) { - var hash = 0x811c9dc5; - for (final byte in utf8.encode(stableId)) { - hash = ((hash ^ byte) * 0x01000193) & 0x7fffffff; - } - return hash; +final class WindowsNotificationInitializationException implements Exception { + const WindowsNotificationInitializationException(this.code); + + final String code; } diff --git a/lib/src/platform/windows/windows_notification_id_store.dart b/lib/src/platform/windows/windows_notification_id_store.dart new file mode 100644 index 00000000..cb4ba6a4 --- /dev/null +++ b/lib/src/platform/windows/windows_notification_id_store.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +/// A persistent one-to-one mapping between BusyMax schedule identifiers and +/// the signed 31-bit integer key required by the Windows notification API. +/// Entries are intentionally retained after cancellation so replacement and +/// cancellation remain stable for the lifetime of a BusyMax profile. +final class WindowsNotificationIdStore { + WindowsNotificationIdStore(this.file); + + static const maximumId = 0x7fffffff; + final File file; + final _ids = {}; + bool _loaded = false; + int _nextId = 1; + Future _serial = Future.value(); + + static Future openDefault() async { + final directory = await getApplicationSupportDirectory(); + return WindowsNotificationIdStore( + File(path.join(directory.path, 'windows_notification_ids.json')), + ); + } + + Future initialize() { + final completer = Completer(); + _serial = _serial.then((_) async { + try { + await _load(); + completer.complete(); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } + + Future idFor(String notificationScheduleId) { + if (notificationScheduleId.isEmpty || + notificationScheduleId.length > 2048) { + throw ArgumentError.value( + notificationScheduleId, + 'notificationScheduleId', + 'The notification schedule identifier is invalid.', + ); + } + final completer = Completer(); + _serial = _serial.then((_) async { + try { + await _load(); + final existing = _ids[notificationScheduleId]; + if (existing != null) { + completer.complete(existing); + return; + } + if (_nextId > maximumId) { + throw StateError('The Windows notification ID space is exhausted.'); + } + final id = _nextId++; + _ids[notificationScheduleId] = id; + await _persist(); + completer.complete(id); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } + + Future _load() async { + if (_loaded) return; + final backup = File('${file.path}.bak'); + if (!await file.exists() && await backup.exists()) { + await backup.rename(file.path); + } + if (!await file.exists()) { + _loaded = true; + return; + } + final Object? decoded; + try { + decoded = jsonDecode(await file.readAsString()); + } on Object { + throw const FormatException( + 'The Windows notification ID store is invalid.', + ); + } + if (decoded is! Map || decoded['version'] != 1) { + throw const FormatException( + 'The Windows notification ID store has an unsupported format.', + ); + } + final rawIds = decoded['ids']; + if (rawIds is! Map) { + throw const FormatException( + 'The Windows notification ID store has no ID map.', + ); + } + final loadedIds = {}; + final used = {}; + for (final entry in rawIds.entries) { + final id = entry.value; + if (entry.key.isEmpty || + entry.key.length > 2048 || + id is! int || + id < 1 || + id > maximumId || + !used.add(id)) { + throw const FormatException( + 'The Windows notification ID store contains invalid IDs.', + ); + } + loadedIds[entry.key] = id; + } + final storedNext = decoded['nextId']; + final minimumNext = used.isEmpty + ? 1 + : used.reduce((left, right) => left > right ? left : right) + 1; + if (storedNext is! int || + storedNext < minimumNext || + storedNext > maximumId + 1) { + throw const FormatException( + 'The Windows notification ID store has an invalid sequence.', + ); + } + _ids + ..clear() + ..addAll(loadedIds); + _nextId = storedNext; + _loaded = true; + } + + Future _persist() async { + await file.parent.create(recursive: true); + final temporary = File('${file.path}.new'); + final backup = File('${file.path}.bak'); + if (await temporary.exists()) await temporary.delete(); + await temporary.writeAsString( + jsonEncode({'version': 1, 'nextId': _nextId, 'ids': _ids}), + flush: true, + ); + if (await backup.exists()) await backup.delete(); + if (await file.exists()) await file.rename(backup.path); + try { + await temporary.rename(file.path); + } on Object { + if (!await file.exists() && await backup.exists()) { + await backup.rename(file.path); + } + rethrow; + } + if (await backup.exists()) await backup.delete(); + } +} diff --git a/lib/src/platform/windows/windows_secret_storage_presentation.dart b/lib/src/platform/windows/windows_secret_storage_presentation.dart new file mode 100644 index 00000000..d86359b0 --- /dev/null +++ b/lib/src/platform/windows/windows_secret_storage_presentation.dart @@ -0,0 +1,9 @@ +import '../../core/secrets/secret_store.dart'; + +const windowsSecretStoragePresentation = SecretStoragePresentation( + backendDomain: 'busymax.secure_storage.windows', + runtimeBackend: 'windows-credential-storage', + unavailableMessage: + 'Windows credential storage is temporarily unavailable. Check your ' + 'Windows credentials and try again.', +); diff --git a/lib/src/platform/windows/windows_time_zone_source.dart b/lib/src/platform/windows/windows_time_zone_source.dart index b04c5290..149c9a02 100644 --- a/lib/src/platform/windows/windows_time_zone_source.dart +++ b/lib/src/platform/windows/windows_time_zone_source.dart @@ -1,4 +1,5 @@ import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:timezone/data/latest_all.dart' as time_zone_data; import 'package:timezone/timezone.dart' as time_zone; @@ -14,16 +15,21 @@ final class WindowsLocalTimeZoneSource implements LocalTimeZoneSource { final Future Function() _load; final _logger = Logger('busymax.windows.timezone'); String? _lastUnrecognizedIdentifier; + String? _lastFailureCode; String? get lastUnrecognizedIdentifier => _lastUnrecognizedIdentifier; @override - String? get diagnostic => switch (_lastUnrecognizedIdentifier) { - null => null, - '' => 'local-time-zone/unavailable; fallback=Etc/UTC', - final identifier => - 'local-time-zone/unrecognized=$identifier; fallback=Etc/UTC', - }; + String? get diagnostic { + final failureCode = _lastFailureCode; + if (failureCode != null) { + return 'local-time-zone/$failureCode; fallback=Etc/UTC'; + } + final identifier = _lastUnrecognizedIdentifier; + return identifier == null + ? null + : 'local-time-zone/unrecognized=$identifier; fallback=Etc/UTC'; + } @override Future currentIanaTimeZone() async { @@ -33,17 +39,43 @@ final class WindowsLocalTimeZoneSource implements LocalTimeZoneSource { time_zone_data.initializeTimeZones(); if (time_zone.timeZoneDatabase.locations.containsKey(identifier)) { _lastUnrecognizedIdentifier = null; + _lastFailureCode = null; return identifier == 'UTC' ? 'Etc/UTC' : identifier; } final mapped = windowsToIanaTimeZones[identifier]; if (mapped != null) { _lastUnrecognizedIdentifier = null; + _lastFailureCode = null; return mapped; } - _lastUnrecognizedIdentifier = identifier; + _lastUnrecognizedIdentifier = + identifier.length <= 128 && + RegExp(r'^[A-Za-z0-9_+./ -]+$').hasMatch(identifier) + ? identifier + : ''; + _lastFailureCode = null; _logger.warning('Unrecognized Windows time zone; using Etc/UTC.'); + } on PlatformException catch (error) { + const permittedCodes = { + 'windows-timezone-query-failed', + 'windows-timezone-key-invalid', + 'windows-region-unavailable', + 'windows-region-empty', + 'windows-region-encoding-failed', + 'icu-timezone-mapping-failed', + 'icu-timezone-mapping-missing', + 'icu-timezone-mapping-too-long', + 'icu-timezone-encoding-failed', + 'timezone-native-api-unavailable', + }; + _lastUnrecognizedIdentifier = null; + _lastFailureCode = permittedCodes.contains(error.code) + ? error.code + : 'unavailable'; + _logger.warning('Windows time zone is unavailable; using Etc/UTC.'); } on Object { - _lastUnrecognizedIdentifier = ''; + _lastUnrecognizedIdentifier = null; + _lastFailureCode = 'unavailable'; _logger.warning('Windows time zone is unavailable; using Etc/UTC.'); } return 'Etc/UTC'; diff --git a/lib/src/platform/windows/windows_tray_service.dart b/lib/src/platform/windows/windows_tray_service.dart index 1234b70e..0764058d 100644 --- a/lib/src/platform/windows/windows_tray_service.dart +++ b/lib/src/platform/windows/windows_tray_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; import 'package:tray_manager/tray_manager.dart'; import '../../features/tray/domain/tray_presentation.dart'; @@ -19,6 +21,7 @@ final class WindowsTrayService with TrayListener implements DesktopTrayService { WindowsTrayService({ required Future Function() loadPresentation, required Future Function(WindowsTrayCommand command) onCommand, + this.onUnavailable, this.iconPath = 'assets/windows/busymax_tray.ico', this.offlineIconPath = 'assets/windows/busymax_tray_offline.ico', }) : _loadPresentation = loadPresentation, @@ -26,14 +29,21 @@ final class WindowsTrayService with TrayListener implements DesktopTrayService { final Future Function() _loadPresentation; final Future Function(WindowsTrayCommand command) _onCommand; + final Future Function(String? errorCode)? onUnavailable; final String iconPath; final String offlineIconPath; bool _available = false; bool _listenerAttached = false; + bool _healthHandlerAttached = false; + String? _lastFailure; + final _logger = Logger('busymax.windows.tray'); + static const _healthChannel = MethodChannel('busymax/windows/tray_health'); @override bool get isAvailable => _available; + String? get lastFailure => _lastFailure; + @override Future start() async { if (_available) return true; @@ -42,14 +52,22 @@ final class WindowsTrayService with TrayListener implements DesktopTrayService { trayManager.addListener(this); _listenerAttached = true; } + if (!_healthHandlerAttached) { + _healthChannel.setMethodCallHandler(_handleHealthEvent); + _healthHandlerAttached = true; + } await trayManager.setIcon(iconPath); await trayManager.setToolTip('BusyMax'); _available = true; + _lastFailure = null; await refresh(); return _available; } on Object { + _lastFailure = 'tray-initialization-failed'; + _logger.warning('The Windows tray could not be initialized.'); _removeListener(); _available = false; + await onUnavailable?.call(_lastFailure); return false; } } @@ -71,6 +89,8 @@ final class WindowsTrayService with TrayListener implements DesktopTrayService { presentation.offline ? offlineIconPath : iconPath, ); } on Object { + _lastFailure = 'tray-refresh-failed'; + _logger.warning('The Windows tray could not be refreshed.'); _removeListener(); _available = false; try { @@ -78,9 +98,32 @@ final class WindowsTrayService with TrayListener implements DesktopTrayService { } on Object { // Explorer may already have discarded the old tray host. } + await onUnavailable?.call(_lastFailure); } } + Future _handleHealthEvent(MethodCall call) async { + if (call.method != 'onTrayAvailabilityChanged') return null; + final arguments = call.arguments; + if (arguments is! Map) return null; + final available = arguments['available'] == true; + _available = available; + if (available) { + _lastFailure = null; + return null; + } + final error = arguments['errorCode']; + _lastFailure = + error is String && + error.length <= 96 && + RegExp(r'^tray-[a-z0-9-]+$').hasMatch(error) + ? error + : 'tray-native-recovery-failed'; + _logger.warning('The Windows tray became unavailable.'); + await onUnavailable?.call(_lastFailure); + return null; + } + Menu _menu(BusyMaxTrayMenuPresentation value) { MenuItem item( String key, @@ -155,6 +198,21 @@ final class WindowsTrayService with TrayListener implements DesktopTrayService { unawaited(_onCommand(WindowsTrayCommand.open)); } + @override + void onTrayIconRightMouseDown() { + unawaited(_showContextMenu()); + } + + Future _showContextMenu() async { + try { + await trayManager.popUpContextMenu(); + } on Object { + _lastFailure = 'tray-menu-display-failed'; + _logger.warning('The Windows tray menu could not be displayed.'); + await onUnavailable?.call(_lastFailure); + } + } + @override void onTrayMenuItemClick(MenuItem menuItem) { // MenuItem callbacks are used so the adapter also works on plugin builds @@ -170,6 +228,10 @@ final class WindowsTrayService with TrayListener implements DesktopTrayService { // Quit must not be blocked by an unavailable Explorer tray host. } _available = false; + if (_healthHandlerAttached) { + _healthChannel.setMethodCallHandler(null); + _healthHandlerAttached = false; + } } void _removeListener() { diff --git a/lib/src/platform/windows/windows_window_service.dart b/lib/src/platform/windows/windows_window_service.dart index 4355990a..f9801c5e 100644 --- a/lib/src/platform/windows/windows_window_service.dart +++ b/lib/src/platform/windows/windows_window_service.dart @@ -11,6 +11,9 @@ final class WindowsWindowService implements DesktopWindowService { final MethodChannel _channel; + Future reportNotificationActivationFailure() => + _channel.invokeMethod('reportNotificationActivationFailure'); + @override Future hideWindow() => _channel.invokeMethod('hide'); diff --git a/lib/src/ui/windows/windows_desktop_runtime.dart b/lib/src/ui/windows/windows_desktop_runtime.dart index 86719820..6f91f8dc 100644 --- a/lib/src/ui/windows/windows_desktop_runtime.dart +++ b/lib/src/ui/windows/windows_desktop_runtime.dart @@ -12,6 +12,11 @@ import '../../platform/windows/windows_tray_service.dart'; import 'windows_event_editor_dialog.dart'; import 'windows_task_editor_dialog.dart'; +bool shouldWindowsHideOnClose({ + required bool runInBackgroundWhenClosed, + required bool trayAvailable, +}) => runInBackgroundWhenClosed && trayAvailable; + class WindowsDesktopRuntime extends ConsumerStatefulWidget { const WindowsDesktopRuntime({ required this.child, @@ -102,6 +107,7 @@ class _WindowsDesktopRuntimeState extends ConsumerState { if (!needsTray) { await window.setHideOnClose(false); await _tray?.stop(); + ref.read(desktopTrayDiagnosticProvider.notifier).state = null; _refreshTimer?.cancel(); _refreshTimer = null; return; @@ -114,6 +120,14 @@ class _WindowsDesktopRuntimeState extends ConsumerState { return _formatter!.format(presentation); }, onCommand: _handleCommand, + onUnavailable: (errorCode) async { + ref.read(desktopTrayDiagnosticProvider.notifier).state = + errorCode ?? 'tray-unavailable'; + // Explorer recovery and native tray failures must never strand an + // otherwise healthy process with no reachable window. + await window.setHideOnClose(false); + await window.showWindow(); + }, ); var available = tray.isAvailable || await tray.start(); if (available) { @@ -121,13 +135,16 @@ class _WindowsDesktopRuntimeState extends ConsumerState { available = tray.isAvailable; } await window.setHideOnClose( - settings.runInBackgroundWhenClosed && available, + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: settings.runInBackgroundWhenClosed, + trayAvailable: available, + ), ); if (!available) { // Never strand a process without either a visible window or tray. await window.showWindow(); } else { - // The presentation and online/offline icon were refreshed above. + ref.read(desktopTrayDiagnosticProvider.notifier).state = null; } _refreshTimer ??= Timer.periodic(const Duration(minutes: 1), (_) { if (mounted) { diff --git a/lib/src/ui/windows/windows_diagnostics_dialog.dart b/lib/src/ui/windows/windows_diagnostics_dialog.dart index 2796f611..6190b4e3 100644 --- a/lib/src/ui/windows/windows_diagnostics_dialog.dart +++ b/lib/src/ui/windows/windows_diagnostics_dialog.dart @@ -35,6 +35,10 @@ class _WindowsDiagnosticsDialog extends ConsumerWidget { final timeZoneDiagnostic = ref .watch(localTimeZoneSourceProvider) .diagnostic; + final notificationReadiness = ref.watch( + desktopNotificationReadinessProvider, + ); + final trayDiagnostic = ref.watch(desktopTrayDiagnosticProvider); return ContentDialog( title: Text(l10n.diagnostics), constraints: const BoxConstraints(maxWidth: 760, maxHeight: 700), @@ -43,6 +47,13 @@ class _WindowsDiagnosticsDialog extends ConsumerWidget { SelectableText(Platform.operatingSystemVersion), SelectableText(timeZone), if (timeZoneDiagnostic != null) SelectableText(timeZoneDiagnostic), + SelectableText( + 'windows-notifications/${notificationReadiness.state.name}', + ), + if (notificationReadiness.diagnosticCode case final code?) + SelectableText(code), + if (trayDiagnostic != null) + SelectableText('windows-tray/$trayDiagnostic'), const SizedBox(height: 16), Text( l10n.googleTasksApi, diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart index 534cb147..04533605 100644 --- a/lib/src/ui/windows/windows_settings_page.dart +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -10,6 +10,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_bootstrap.dart'; import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/notifications/desktop_notification_backend.dart'; import '../../l10n/app_locale.dart'; import '../../platform/common/desktop_services.dart'; import '../../providers/busy_provider.dart'; @@ -38,6 +39,9 @@ class WindowsSettingsPage extends ConsumerWidget { final accounts = ref.watch(accountManagementStreamProvider); final subscriptions = ref.watch(webCalSubscriptionsProvider); final config = ref.watch(buildConfigProvider); + final notificationReadiness = ref.watch( + desktopNotificationReadinessProvider, + ); return ScaffoldPage.scrollable( header: PageHeader(title: Text(l10n.settings)), children: [ @@ -188,6 +192,10 @@ class WindowsSettingsPage extends ConsumerWidget { ), const SizedBox(height: 20), _SectionTitle(l10n.notifications), + if (!notificationReadiness.canNotify) ...[ + WindowsNotificationReadinessInfoBar(readiness: notificationReadiness), + const SizedBox(height: 8), + ], Card( child: Column( children: [ @@ -515,6 +523,32 @@ class WindowsSettingsPage extends ConsumerWidget { } } +class WindowsNotificationReadinessInfoBar extends StatelessWidget { + const WindowsNotificationReadinessInfoBar({ + required this.readiness, + super.key, + }); + + final DesktopNotificationReadiness readiness; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final unpackaged = + readiness.state == + DesktopNotificationReadinessState.unavailableUnpackaged; + return InfoBar( + title: Text(l10n.windowsNotificationsUnavailable), + content: Text( + unpackaged + ? l10n.windowsNotificationsUnpackaged + : l10n.windowsNotificationsInstalledFailure, + ), + severity: unpackaged ? InfoBarSeverity.warning : InfoBarSeverity.error, + ); + } +} + Future _setWindowsAutostart( BuildContext context, WidgetRef ref, diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart index 16f4c34b..111b3a50 100644 --- a/test/features/notifications/notification_scheduler_test.dart +++ b/test/features/notifications/notification_scheduler_test.dart @@ -323,6 +323,33 @@ void main() { expect(backend.notifications, hasLength(1)); }); + test('duplicate Windows activation is idempotent', () async { + await _insertDueTaskNotification(database, now); + await scheduler.checkNow(); + final row = await database + .select(database.notificationSchedule) + .getSingle(); + + await scheduler.handleActivation( + notificationScheduleId: row.id, + action: 'snooze', + ); + final first = await database + .select(database.notificationSchedule) + .getSingle(); + now = now.add(const Duration(minutes: 1)); + await scheduler.handleActivation( + notificationScheduleId: row.id, + action: 'snooze', + ); + final duplicate = await database + .select(database.notificationSchedule) + .getSingle(); + + expect(duplicate.snoozedUntilUtc, first.snoozedUntilUtc); + expect(duplicate.updatedAtLocal, first.updatedAtLocal); + }); + test('does not notify for a signed-out account', () async { await database .into(database.accounts) diff --git a/test/google_tasks/oauth/loopback_flow_test.dart b/test/google_tasks/oauth/loopback_flow_test.dart index 98591850..e0f16c67 100644 --- a/test/google_tasks/oauth/loopback_flow_test.dart +++ b/test/google_tasks/oauth/loopback_flow_test.dart @@ -130,6 +130,68 @@ void main() { ), ); }); + + for (final platform in ['linux', 'windows']) { + test( + '$platform browser launch false returns visible OAuth error', + () async { + final flow = OAuthLoopbackFlow( + authorizationLauncher: (_) async => false, + ); + await expectLater( + flow.start( + authorizationEndpoint: _authorizationEndpoint, + clientId: 'client-id', + scope: googleTasksOAuthScope, + browserLaunchFailureMessage: 'Could not open the system browser.', + ), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'OAuthBrowserLaunchFailed', + ) + .having( + (error) => error.message, + 'message', + 'Could not open the system browser.', + ), + ), + ); + }, + ); + + test('$platform browser launch exception is sanitized', () async { + final flow = OAuthLoopbackFlow( + authorizationLauncher: (_) => throw StateError('private shell error'), + ); + await expectLater( + flow.start( + authorizationEndpoint: _authorizationEndpoint, + clientId: 'client-id', + scope: googleTasksOAuthScope, + browserLaunchFailureMessage: 'Could not open the system browser.', + ), + throwsA( + isA() + .having((error) => error.code, 'code', 'OAuthBrowserLaunchFailed') + .having( + (error) => error.message, + 'message', + isNot(contains('private shell error')), + ), + ), + ); + }); + } + + test('Windows-reachable OAuth implementation contains no xdg-open', () { + final source = File( + 'lib/src/google_tasks/oauth/oauth_loopback_flow.dart', + ).readAsStringSync(); + expect(source, isNot(contains('xdg-open'))); + }); } Future<_StartedFlow> _startFlow({ diff --git a/test/google_tasks/oauth/oauth_token_store_test.dart b/test/google_tasks/oauth/oauth_token_store_test.dart index ad9f4dd4..82a151a8 100644 --- a/test/google_tasks/oauth/oauth_token_store_test.dart +++ b/test/google_tasks/oauth/oauth_token_store_test.dart @@ -5,6 +5,8 @@ import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/core/secrets/portal_encrypted_secret_store.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/platform/linux/linux_secret_storage_presentation.dart'; +import 'package:busymax/src/platform/windows/windows_secret_storage_presentation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -52,6 +54,69 @@ void main() { }, ); + test( + 'platform secure-storage guidance never crosses OS boundaries', + () async { + Future failure( + SecretStoragePresentation presentation, + ) async { + final store = SecureSecretStore( + _ThrowingSecureStorage( + PlatformException( + code: 'Unavailable', + message: 'password=must-not-appear', + ), + ), + presentation: presentation, + ); + try { + await store.readActiveAccountId(); + fail('Expected secure storage failure.'); + } on SecretStoreException catch (error) { + return error; + } + } + + final windows = await failure(windowsSecretStoragePresentation); + expect(windows.message, contains('Windows credential storage')); + expect(windows.message.toLowerCase(), isNot(contains('keyring'))); + expect(windows.message.toLowerCase(), isNot(contains('portal'))); + expect(windows.message.toLowerCase(), isNot(contains('snap'))); + + final linux = await failure(linuxSecretStoragePresentation); + expect(linux.message.toLowerCase(), contains('system keyring')); + }, + ); + + test('secure-storage diagnostics are platform-owned and redacted', () { + final error = PlatformException( + code: 'BackendFailure', + message: 'password=credential-value', + details: {'token': 'another-secret'}, + ); + final windows = sanitizedFlutterSecureStorageError( + error, + backendDomain: windowsSecretStoragePresentation.backendDomain, + ); + final linux = sanitizedFlutterSecureStorageError( + error, + backendDomain: linuxSecretStoragePresentation.backendDomain, + ); + expect(windows, contains('domain=busymax.secure_storage.windows')); + expect(windows, isNot(contains('credential-value'))); + expect(windows, isNot(contains('another-secret'))); + expect(windows, isNot(contains('flutter_secure_storage_linux'))); + expect(linux, contains('domain=busymax.secure_storage.linux')); + expect(linux, isNot(contains('credential-value'))); + + final unsafeCode = sanitizedFlutterSecureStorageError( + PlatformException(code: 'token=credential-value'), + backendDomain: windowsSecretStoragePresentation.backendDomain, + ); + expect(unsafeCode, contains('code=backend-error')); + expect(unsafeCode, isNot(contains('credential-value'))); + }); + test( 'credential records are versioned, typed, and redacted in diagnostics', () { diff --git a/test/platform/windows/windows_notification_backend_test.dart b/test/platform/windows/windows_notification_backend_test.dart new file mode 100644 index 00000000..a8f11ad1 --- /dev/null +++ b/test/platform/windows/windows_notification_backend_test.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymax/src/features/notifications/desktop_notification_backend.dart'; +import 'package:busymax/src/platform/common/desktop_services.dart'; +import 'package:busymax/src/platform/windows/windows_notification_backend.dart'; +import 'package:busymax/src/platform/windows/windows_notification_id_store.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory directory; + + setUp(() async { + directory = await Directory.systemTemp.createTemp( + 'busymax-windows-notifications-', + ); + }); + + tearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + WindowsNotificationIdStore ids() => + WindowsNotificationIdStore(File('${directory.path}/ids.json')); + + test('missing AUMID fails initialization', () async { + await expectLater( + WindowsNotificationBackend.create( + appUserModelId: '', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin(), + idStore: ids(), + ), + throwsArgumentError, + ); + }); + + test('native initialization false is a typed visible failure', () async { + await expectLater( + WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin(initializeResult: false), + idStore: ids(), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'toast-registration-failed', + ), + ), + ); + }); + + test('initialization exception is converted to a sanitized code', () async { + await expectLater( + WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin( + initializeError: StateError('private native detail'), + ), + idStore: ids(), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'initialization-failed', + ), + ), + ); + }); + + test( + 'installed package identity is queried through the runner bridge', + () async { + const channel = MethodChannel('busymax/test/package-identity'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'hasPackageIdentity'); + return true; + }); + expect( + await WindowsNotificationBackend.hasPackageIdentity(channel: channel), + isTrue, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + expect( + await WindowsNotificationBackend.hasPackageIdentity(channel: channel), + isFalse, + ); + }, + ); + + test('activation forwarder opens no application storage', () async { + final corruptStore = ids(); + await corruptStore.file.writeAsString('{not-json'); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: _FakeNotificationsPlugin(), + idStore: corruptStore, + activationForwarderOnly: true, + ); + await expectLater( + backend.notify(_request('must-not-write')), + throwsStateError, + ); + expect(await corruptStore.file.readAsString(), '{not-json'); + }); + + test('display and cancellation use the same stable persisted ID', () async { + final plugin = _FakeNotificationsPlugin(); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: plugin, + idStore: ids(), + ); + await backend.notify(_request('schedule-1')); + await backend.cancel('schedule-1'); + expect(plugin.shownIds, hasLength(1)); + expect(plugin.cancelledIds, [plugin.shownIds.single]); + + final restartedPlugin = _FakeNotificationsPlugin(); + final restarted = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: (_) {}, + plugin: restartedPlugin, + idStore: ids(), + ); + await restarted.notify(_request('schedule-1')); + expect(restartedPlugin.shownIds.single, plugin.shownIds.single); + }); + + test('body activation uses validated common activation model', () async { + final activations = []; + final plugin = _FakeNotificationsPlugin(); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: activations.add, + plugin: plugin, + idStore: ids(), + ); + await backend.notify(_request('schedule-body')); + plugin.response!( + NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: plugin.payloads.single, + ), + ); + expect(activations, hasLength(1)); + expect(activations.single.kind, DesktopActivationKind.notification); + expect(activations.single.action, 'default'); + expect( + activations.single.payload?['notificationScheduleId'], + 'schedule-body', + ); + }); + + test('Open, Snooze, and Dismiss route through one validated path', () async { + final activations = []; + final plugin = _FakeNotificationsPlugin(); + final backend = await WindowsNotificationBackend.create( + appUserModelId: 'BusyStack.BusyMax_test', + onActivation: activations.add, + plugin: plugin, + idStore: ids(), + ); + await backend.notify(_request('schedule-actions')); + final actions = plugin.details.single.windows!.actions; + for (final expected in ['open', 'snooze', 'dismiss']) { + final action = actions.singleWhere( + (candidate) => jsonDecode(candidate.arguments)['action'] == expected, + ); + plugin.response!( + NotificationResponse( + notificationResponseType: + NotificationResponseType.selectedNotificationAction, + payload: action.arguments, + ), + ); + } + expect(activations.map((activation) => activation.action), [ + 'open', + 'snooze', + 'dismiss', + ]); + }); +} + +BusyMaxNotificationRequest _request(String id) => BusyMaxNotificationRequest( + stableId: id, + title: 'Reminder', + payload: {'notificationScheduleId': id, 'itemKind': 'event'}, + actions: const [ + BusyMaxNotificationAction('open', 'Open'), + BusyMaxNotificationAction('snooze', 'Snooze'), + BusyMaxNotificationAction('dismiss', 'Dismiss'), + ], +); + +final class _FakeNotificationsPlugin + implements WindowsNotificationPluginAdapter { + _FakeNotificationsPlugin({ + this.initializeResult = true, + this.initializeError, + }); + + final bool initializeResult; + final Object? initializeError; + DidReceiveNotificationResponseCallback? response; + final shownIds = []; + final cancelledIds = []; + final payloads = []; + final details = []; + + @override + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + }) async { + if (initializeError case final error?) throw error; + response = onDidReceiveNotificationResponse; + return initializeResult; + } + + @override + Future + getNotificationAppLaunchDetails() async => + const NotificationAppLaunchDetails(false); + + @override + Future show({ + required int id, + String? title, + String? body, + NotificationDetails? notificationDetails, + String? payload, + }) async { + shownIds.add(id); + payloads.add(payload); + details.add(notificationDetails!); + } + + @override + Future cancel({required int id}) async { + cancelledIds.add(id); + } + + @override + Future dispose() async {} +} diff --git a/test/platform/windows/windows_notification_id_store_test.dart b/test/platform/windows/windows_notification_id_store_test.dart new file mode 100644 index 00000000..e8732566 --- /dev/null +++ b/test/platform/windows/windows_notification_id_store_test.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:busymax/src/platform/windows/windows_notification_id_store.dart'; + +void main() { + late Directory directory; + late File file; + + setUp(() async { + directory = await Directory.systemTemp.createTemp( + 'busymax-notification-ids-', + ); + file = File('${directory.path}/ids.json'); + }); + + tearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + test('assigns a unique ID to every schedule identifier', () async { + final store = WindowsNotificationIdStore(file); + final ids = await Future.wait([ + store.idFor('schedule-a'), + store.idFor('schedule-b'), + store.idFor('schedule-c'), + ]); + expect(ids.toSet(), hasLength(3)); + expect(ids, everyElement(inInclusiveRange(1, 0x7fffffff))); + }); + + test('keeps IDs stable in memory and after restart', () async { + final first = WindowsNotificationIdStore(file); + final id = await first.idFor('schedule-stable'); + expect(await first.idFor('schedule-stable'), id); + + final restarted = WindowsNotificationIdStore(file); + expect(await restarted.idFor('schedule-stable'), id); + expect(await restarted.idFor('schedule-new'), isNot(id)); + }); + + test('rejects persisted collisions instead of overwriting a toast', () async { + await file.writeAsString( + jsonEncode({ + 'version': 1, + 'nextId': 2, + 'ids': {'schedule-a': 1, 'schedule-b': 1}, + }), + ); + final store = WindowsNotificationIdStore(file); + await expectLater(store.idFor('schedule-a'), throwsFormatException); + }); + + test('serializes concurrent allocation without collisions', () async { + final store = WindowsNotificationIdStore(file); + final ids = await Future.wait([ + for (var index = 0; index < 100; index++) store.idFor('schedule-$index'), + ]); + expect(ids.toSet(), hasLength(100)); + }); + + test( + 'recovers the last committed map after interrupted replacement', + () async { + final first = WindowsNotificationIdStore(file); + final id = await first.idFor('schedule-before-crash'); + final backup = File('${file.path}.bak'); + await file.rename(backup.path); + + final restarted = WindowsNotificationIdStore(file); + await restarted.initialize(); + expect(await restarted.idFor('schedule-before-crash'), id); + expect(await file.exists(), isTrue); + expect(await backup.exists(), isFalse); + }, + ); + + test('initialization rejects a corrupt persisted map', () async { + await file.writeAsString('{not-json'); + final store = WindowsNotificationIdStore(file); + await expectLater(store.initialize(), throwsFormatException); + await expectLater(store.idFor('schedule-a'), throwsFormatException); + }); +} diff --git a/test/platform/windows/windows_notification_readiness_ui_test.dart b/test/platform/windows/windows_notification_readiness_ui_test.dart new file mode 100644 index 00000000..145867f4 --- /dev/null +++ b/test/platform/windows/windows_notification_readiness_ui_test.dart @@ -0,0 +1,46 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/features/notifications/desktop_notification_backend.dart'; +import 'package:busymax/src/ui/windows/windows_settings_page.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final testCase in <({DesktopNotificationReadiness readiness, String message})>[ + ( + readiness: const DesktopNotificationReadiness( + DesktopNotificationReadinessState.unavailableUnpackaged, + ), + message: + 'This unpackaged development run cannot use Windows notifications. Install the test-signed MSIX to test reminders.', + ), + ( + readiness: const DesktopNotificationReadiness( + DesktopNotificationReadinessState.failedInstalledPackage, + ), + message: + 'BusyMax could not initialize Windows notifications. Reminders will not appear until this installation problem is resolved.', + ), + ]) { + testWidgets( + 'notification failure is visible: ${testCase.readiness.state}', + (tester) async { + await tester.pumpWidget( + FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: WindowsNotificationReadinessInfoBar( + readiness: testCase.readiness, + ), + ), + ); + + expect( + find.text('Windows notifications are unavailable'), + findsOneWidget, + ); + expect(find.text(testCase.message), findsOneWidget); + expect(find.byType(InfoBar), findsOneWidget); + }, + ); + } +} diff --git a/test/platform/windows/windows_time_zone_source_test.dart b/test/platform/windows/windows_time_zone_source_test.dart index 0c9bc713..02f058a8 100644 --- a/test/platform/windows/windows_time_zone_source_test.dart +++ b/test/platform/windows/windows_time_zone_source_test.dart @@ -1,5 +1,6 @@ import 'package:busymax/src/platform/windows/windows_time_zone_source.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:timezone/data/latest_all.dart' as time_zone_data; import 'package:timezone/timezone.dart' as time_zone; @@ -36,6 +37,32 @@ void main() { ); }); + test('exposes only allowlisted native failure diagnostics', () async { + final mappedFailure = WindowsLocalTimeZoneSource( + load: () async => throw PlatformException( + code: 'icu-timezone-mapping-failed', + message: 'sensitive native detail', + ), + ); + final unknownFailure = WindowsLocalTimeZoneSource( + load: () async => throw PlatformException( + code: 'native-secret-C:/Users/albert', + message: 'sensitive native detail', + ), + ); + + expect(await mappedFailure.currentIanaTimeZone(), 'Etc/UTC'); + expect( + mappedFailure.diagnostic, + 'local-time-zone/icu-timezone-mapping-failed; fallback=Etc/UTC', + ); + expect(await unknownFailure.currentIanaTimeZone(), 'Etc/UTC'); + expect( + unknownFailure.diagnostic, + 'local-time-zone/unavailable; fallback=Etc/UTC', + ); + }); + test('mapped zones retain DST, skipped, ambiguous, and offset behavior', () { final pacific = time_zone.getLocation( windowsToIanaTimeZones['Pacific Standard Time']!, diff --git a/test/platform/windows/windows_tray_close_policy_test.dart b/test/platform/windows/windows_tray_close_policy_test.dart new file mode 100644 index 00000000..ee247074 --- /dev/null +++ b/test/platform/windows/windows_tray_close_policy_test.dart @@ -0,0 +1,28 @@ +import 'package:busymax/src/ui/windows/windows_desktop_runtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('close can hide only when background mode and a usable tray exist', () { + expect( + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: true, + trayAvailable: true, + ), + isTrue, + ); + expect( + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: true, + trayAvailable: false, + ), + isFalse, + ); + expect( + shouldWindowsHideOnClose( + runInBackgroundWhenClosed: false, + trayAvailable: true, + ), + isFalse, + ); + }); +} diff --git a/third_party/flutter_timezone/windows/CMakeLists.txt b/third_party/flutter_timezone/windows/CMakeLists.txt index 0c30f7e8..6515daf1 100644 --- a/third_party/flutter_timezone/windows/CMakeLists.txt +++ b/third_party/flutter_timezone/windows/CMakeLists.txt @@ -20,6 +20,8 @@ set(PLUGIN_NAME "flutter_timezone_plugin") list(APPEND PLUGIN_SOURCES "flutter_timezone_plugin.cpp" "flutter_timezone_plugin.h" + "timezone_native_api.cpp" + "timezone_native_api.h" ) # Define the plugin library target. Its name must not be changed (see comment diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp b/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp index 7031e3e3..46e13d37 100644 --- a/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin.cpp @@ -1,146 +1,84 @@ #include "flutter_timezone_plugin.h" -// This must be included before many other Windows headers. -#include - -// By default, the icu.h header uses char16_t to represent UTF-16 code units. Windows, however, uses -// wchar_t, so we define UCHAR_TYPE to get the type we want. -#define UCHAR_TYPE wchar_t -#include - #include #include #include #include -#include +#include namespace flutter_timezone { - // static - void FlutterTimezonePlugin::RegisterWithRegistrar( - flutter::PluginRegistrarWindows* registrar) { - auto channel = - std::make_unique>( - registrar->messenger(), "flutter_timezone", - &flutter::StandardMethodCodec::GetInstance()); - - auto plugin = std::make_unique(); - - channel->SetMethodCallHandler( - [plugin_pointer = plugin.get()](const auto& call, auto result) { - plugin_pointer->HandleMethodCall(call, std::move(result)); - }); - - registrar->AddPlugin(std::move(plugin)); - } - - FlutterTimezonePlugin::FlutterTimezonePlugin() {} - - FlutterTimezonePlugin::~FlutterTimezonePlugin() {} - - void FlutterTimezonePlugin::HandleMethodCall( - const flutter::MethodCall& method_call, - std::unique_ptr> result) { - - if (method_call.method_name().compare(kGetLocalTimezone) == 0) { - GetLocalTimezone(result); - } - else if (method_call.method_name().compare(kGetAvailableTimezones) == 0) { - GetAvailableTimezones(result); - } - else { - result->NotImplemented(); - } - } - - ///

- /// Gets the local time zone as an IANA identifier. - /// - /// - /// If the local windows time zone cannot be matched with a valid IANA one, "Etc/Unknown" is - /// returned. - /// - void FlutterTimezonePlugin::GetLocalTimezone( - std::unique_ptr>& result) { - // This entire function body could be replaced with a call to `ucal_getHostTimeZone`. - // However, that function as only added in ICU 65, which is only available on Windows 11. - // Once we drop support for older Windows versions, this should be replaced. - - // Get the current Windows time zone - DYNAMIC_TIME_ZONE_INFORMATION tzInfo; - GetDynamicTimeZoneInformation(&tzInfo); - - // Get the user's region - wchar_t geoBuffer[4]; - GetUserDefaultGeoName(geoBuffer, ARRAYSIZE(geoBuffer)); - - // Convert the `geoBuffer` to a single byte string, which is fine since the contents are - // supposed to be basic ASCII. - std::wstring geoW(geoBuffer); -#pragma warning(suppress : 4244) - std::string geo(geoW.begin(), geoW.end()); - - // Map the (Windows Time Zone, Region) pair to an IANA time zone ID - UErrorCode status = U_ZERO_ERROR; - UChar buffer[128]; - auto length = ucal_getTimeZoneIDForWindowsID( - tzInfo.TimeZoneKeyName, - -1, - geo.c_str(), - buffer, - ARRAYSIZE(buffer), - &status); - - if (length == 0) { - // No mapping found between Windows and IANA time zone ids - result->Success(flutter::EncodableValue(std::string(UCAL_UNKNOWN_ZONE_ID))); - } - - std::wstring tz(buffer); -#pragma warning(suppress : 4244) - result->Success(flutter::EncodableValue(std::string(tz.begin(), tz.end()))); - } - - /// - /// Gets all the available canonical IANA time zones. - /// - /// A vector of timezones as EncodableValue's. - void FlutterTimezonePlugin::GetAvailableTimezones( - std::unique_ptr>& result) { - UErrorCode status = U_ZERO_ERROR; - - // open an enumeration for any kind of available timezone without country/offset filtering. - auto tzEnumeration = ucal_openTimeZoneIDEnumeration( - USystemTimeZoneType::UCAL_ZONE_TYPE_CANONICAL, - nullptr, - nullptr, - &status); - - if (U_FAILURE(status)) { - result->Error(std::string(u_errorName(status)), "Could not fetch available timezones."); - } - - auto count = uenum_count(tzEnumeration, &status); - - std::vector timezones{}; - timezones.reserve(count); - - for (auto i = 0; i < count; i++) { - auto buffer = uenum_next(tzEnumeration, nullptr, &status); - - if (U_FAILURE(status)) { - // Failed to read the current value, try the next one. - continue; - } - - timezones.push_back(flutter::EncodableValue(std::string(buffer))); - } - - // close the enumeration - uenum_close(tzEnumeration); - - result->Success(flutter::EncodableList(timezones)); - } - -} // namespace flutter_timezone +void FlutterTimezonePlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "flutter_timezone", + &flutter::StandardMethodCodec::GetInstance()); + auto plugin = std::make_unique(); + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + registrar->AddPlugin(std::move(plugin)); +} + +FlutterTimezonePlugin::FlutterTimezonePlugin() + : owned_api_(std::make_unique()), + api_(owned_api_.get()) {} + +FlutterTimezonePlugin::FlutterTimezonePlugin(TimezoneNativeApi* api) + : api_(api) {} + +FlutterTimezonePlugin::~FlutterTimezonePlugin() = default; + +void FlutterTimezonePlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + if (method_call.method_name() == kGetLocalTimezone) { + GetLocalTimezone(std::move(result)); + return; + } + if (method_call.method_name() == kGetAvailableTimezones) { + GetAvailableTimezones(std::move(result)); + return; + } + result->NotImplemented(); +} + +void FlutterTimezonePlugin::GetLocalTimezone( + std::unique_ptr> result) { + if (api_ == nullptr) { + result->Error("timezone-native-api-unavailable", + "The Windows time-zone service is unavailable."); + return; + } + const TimezoneOperationResult operation = ResolveLocalTimezone(*api_); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(operation.value)); +} + +void FlutterTimezonePlugin::GetAvailableTimezones( + std::unique_ptr> result) { + if (api_ == nullptr) { + result->Error("timezone-native-api-unavailable", + "The Windows time-zone service is unavailable."); + return; + } + const TimezoneOperationResult operation = EnumerateTimezones(*api_); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + flutter::EncodableList values; + values.reserve(operation.values.size()); + for (const std::string& value : operation.values) { + values.emplace_back(value); + } + result->Success(flutter::EncodableValue(values)); +} + +} // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/flutter_timezone_plugin.h b/third_party/flutter_timezone/windows/flutter_timezone_plugin.h index 8bcd32a4..f97f6bd5 100644 --- a/third_party/flutter_timezone/windows/flutter_timezone_plugin.h +++ b/third_party/flutter_timezone/windows/flutter_timezone_plugin.h @@ -6,6 +6,8 @@ #include +#include "timezone_native_api.h" + namespace flutter_timezone { constexpr auto kGetLocalTimezone = "getLocalTimezone"; constexpr auto kGetAvailableTimezones = "getAvailableTimezones"; @@ -16,6 +18,9 @@ namespace flutter_timezone { FlutterTimezonePlugin(); + // Visible for native tests. The API must outlive the plugin. + explicit FlutterTimezonePlugin(TimezoneNativeApi* api); + virtual ~FlutterTimezonePlugin(); // Disallow copy and assign. @@ -28,8 +33,11 @@ namespace flutter_timezone { std::unique_ptr> result); private: - void GetLocalTimezone(std::unique_ptr>& result); - void GetAvailableTimezones(std::unique_ptr>& result); + void GetLocalTimezone(std::unique_ptr> result); + void GetAvailableTimezones(std::unique_ptr> result); + + std::unique_ptr owned_api_; + TimezoneNativeApi* api_ = nullptr; }; } // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp b/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp new file mode 100644 index 00000000..fc21d43e --- /dev/null +++ b/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp @@ -0,0 +1,205 @@ +#include "flutter_timezone_plugin.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace flutter_timezone { +namespace { + +class FakeTimezoneApi final : public TimezoneNativeApi { + public: + DWORD query_result = TIME_ZONE_ID_STANDARD; + int geo_result = 3; + int32_t mapping_result = 17; + UErrorCode mapping_status = U_ZERO_ERROR; + UErrorCode open_status = U_ZERO_ERROR; + UErrorCode count_status = U_ZERO_ERROR; + UErrorCode next_status = U_ZERO_ERROR; + int32_t count_result = 2; + bool return_enumeration = true; + int close_count = 0; + size_t next_index = 0; + std::wstring windows_id = L"Pacific Standard Time"; + std::wstring mapped_id = L"America/Vancouver"; + std::vector enumerated = {"Etc/UTC", "America/Vancouver"}; + + DWORD GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) override { + if (information != nullptr && query_result != TIME_ZONE_ID_INVALID) { + wcsncpy_s(information->TimeZoneKeyName, + _countof(information->TimeZoneKeyName), windows_id.c_str(), + _TRUNCATE); + } + return query_result; + } + + int GetUserDefaultGeoName(wchar_t* buffer, int capacity) override { + if (geo_result > 0 && buffer != nullptr && capacity >= geo_result) { + buffer[0] = L'C'; + buffer[1] = L'A'; + buffer[2] = L'\0'; + } + return geo_result; + } + + int32_t GetTimeZoneIdForWindowsId(const UChar*, + int32_t, + const char*, + UChar* result, + int32_t result_capacity, + UErrorCode* status) override { + *status = mapping_status; + if (mapping_result > 0 && U_SUCCESS(*status) && result != nullptr && + result_capacity > mapping_result) { + std::copy_n(mapped_id.data(), mapping_result, result); + } + return mapping_result; + } + + UEnumeration* OpenTimeZoneIdEnumeration(UErrorCode* status) override { + *status = open_status; + return return_enumeration ? reinterpret_cast(this) : nullptr; + } + + int32_t EnumerationCount(UEnumeration*, UErrorCode* status) override { + *status = count_status; + return count_result; + } + + const char* EnumerationNext(UEnumeration*, + int32_t* result_length, + UErrorCode* status) override { + *status = next_status; + if (U_FAILURE(*status) || next_index >= enumerated.size()) { + *result_length = 0; + return nullptr; + } + const std::string& current = enumerated[next_index++]; + *result_length = static_cast(current.size()); + return current.data(); + } + + void CloseEnumeration(UEnumeration*) override { ++close_count; } +}; + +struct CompletionCounts { + int success = 0; + int error = 0; + int not_implemented = 0; + + int total() const { return success + error + not_implemented; } +}; + +CompletionCounts Invoke(FlutterTimezonePlugin& plugin, + const std::string& method) { + CompletionCounts counts{}; + flutter::MethodCall call(method, nullptr); + auto result = std::make_unique< + flutter::MethodResultFunctions>( + [&counts](const flutter::EncodableValue*) { ++counts.success; }, + [&counts](const std::string&, const std::string&, + const flutter::EncodableValue*) { ++counts.error; }, + [&counts]() { ++counts.not_implemented; }); + plugin.HandleMethodCall(call, std::move(result)); + return counts; +} + +TEST(FlutterTimezonePluginTest, MapsWindowsTimezoneToIana) { + FakeTimezoneApi api; + const auto result = ResolveLocalTimezone(api); + EXPECT_TRUE(result.succeeded); + EXPECT_EQ(result.value, "America/Vancouver"); +} + +TEST(FlutterTimezonePluginTest, RejectsUnavailableGeographicalInformation) { + FakeTimezoneApi api; + api.geo_result = 0; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "windows-region-unavailable"); +} + +TEST(FlutterTimezonePluginTest, RejectsFailedWindowsTimezoneQuery) { + FakeTimezoneApi api; + api.query_result = TIME_ZONE_ID_INVALID; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "windows-timezone-query-failed"); +} + +TEST(FlutterTimezonePluginTest, RejectsMissingOrZeroLengthIcuMapping) { + FakeTimezoneApi api; + api.mapping_result = 0; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-mapping-missing"); +} + +TEST(FlutterTimezonePluginTest, RejectsIcuErrorStatus) { + FakeTimezoneApi api; + api.mapping_status = U_INTERNAL_PROGRAM_ERROR; + const auto result = ResolveLocalTimezone(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-mapping-failed"); +} + +TEST(FlutterTimezonePluginTest, EnumerationOpenFailureDoesNotUseOrCloseHandle) { + FakeTimezoneApi api; + api.return_enumeration = false; + api.open_status = U_MEMORY_ALLOCATION_ERROR; + const auto result = EnumerateTimezones(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-enumeration-open-failed"); + EXPECT_EQ(api.close_count, 0); +} + +TEST(FlutterTimezonePluginTest, EnumerationOpenErrorClosesReturnedHandle) { + FakeTimezoneApi api; + api.open_status = U_INTERNAL_PROGRAM_ERROR; + const auto result = EnumerateTimezones(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-enumeration-open-failed"); + EXPECT_EQ(api.close_count, 1); +} + +TEST(FlutterTimezonePluginTest, EnumerationReadFailureClosesHandleExactlyOnce) { + FakeTimezoneApi api; + api.next_status = U_INTERNAL_PROGRAM_ERROR; + const auto result = EnumerateTimezones(api); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "icu-timezone-enumeration-read-failed"); + EXPECT_EQ(api.close_count, 1); +} + +TEST(FlutterTimezonePluginTest, EveryMethodPathCompletesExactlyOnce) { + FakeTimezoneApi success_api; + FlutterTimezonePlugin success_plugin(&success_api); + EXPECT_EQ(Invoke(success_plugin, kGetLocalTimezone).total(), 1); + EXPECT_EQ(Invoke(success_plugin, kGetAvailableTimezones).total(), 1); + + FakeTimezoneApi failure_api; + failure_api.mapping_status = U_INTERNAL_PROGRAM_ERROR; + failure_api.open_status = U_INTERNAL_PROGRAM_ERROR; + FlutterTimezonePlugin failure_plugin(&failure_api); + const CompletionCounts local = Invoke(failure_plugin, kGetLocalTimezone); + const CompletionCounts available = + Invoke(failure_plugin, kGetAvailableTimezones); + EXPECT_EQ(local.total(), 1); + EXPECT_EQ(local.error, 1); + EXPECT_EQ(available.total(), 1); + EXPECT_EQ(available.error, 1); + + const CompletionCounts unknown = Invoke(success_plugin, "unknown"); + EXPECT_EQ(unknown.total(), 1); + EXPECT_EQ(unknown.not_implemented, 1); +} + +} // namespace +} // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/timezone_native_api.cpp b/third_party/flutter_timezone/windows/timezone_native_api.cpp new file mode 100644 index 00000000..13dc9a59 --- /dev/null +++ b/third_party/flutter_timezone/windows/timezone_native_api.cpp @@ -0,0 +1,206 @@ +#include "timezone_native_api.h" + +#include +#include +#include + +namespace flutter_timezone { +namespace { + +TimezoneOperationResult Failure(const char* code, const char* message) { + TimezoneOperationResult result{}; + result.error_code = code; + result.error_message = message; + return result; +} + +bool WideToUtf8(const wchar_t* value, + int32_t length, + std::string* converted) { + if (value == nullptr || converted == nullptr || length <= 0) { + return false; + } + const int required = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, + length, nullptr, 0, nullptr, nullptr); + if (required <= 0) { + return false; + } + converted->assign(static_cast(required), '\0'); + return WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, length, + converted->data(), required, nullptr, nullptr) == + required; +} + +class EnumerationOwner { + public: + EnumerationOwner(TimezoneNativeApi& api, UEnumeration* value) + : api_(api), value_(value) {} + ~EnumerationOwner() { + if (value_ != nullptr) { + api_.CloseEnumeration(value_); + } + } + UEnumeration* get() const { return value_; } + + EnumerationOwner(const EnumerationOwner&) = delete; + EnumerationOwner& operator=(const EnumerationOwner&) = delete; + + private: + TimezoneNativeApi& api_; + UEnumeration* value_; +}; + +} // namespace + +DWORD WindowsTimezoneNativeApi::GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) { + return ::GetDynamicTimeZoneInformation(information); +} + +int WindowsTimezoneNativeApi::GetUserDefaultGeoName(wchar_t* buffer, + int capacity) { + return ::GetUserDefaultGeoName(buffer, capacity); +} + +int32_t WindowsTimezoneNativeApi::GetTimeZoneIdForWindowsId( + const UChar* windows_id, + int32_t windows_id_length, + const char* region, + UChar* result, + int32_t result_capacity, + UErrorCode* status) { + return ucal_getTimeZoneIDForWindowsID(windows_id, windows_id_length, region, + result, result_capacity, status); +} + +UEnumeration* WindowsTimezoneNativeApi::OpenTimeZoneIdEnumeration( + UErrorCode* status) { + return ucal_openTimeZoneIDEnumeration(UCAL_ZONE_TYPE_CANONICAL, nullptr, + nullptr, status); +} + +int32_t WindowsTimezoneNativeApi::EnumerationCount( + UEnumeration* enumeration, + UErrorCode* status) { + return uenum_count(enumeration, status); +} + +const char* WindowsTimezoneNativeApi::EnumerationNext( + UEnumeration* enumeration, + int32_t* result_length, + UErrorCode* status) { + return uenum_next(enumeration, result_length, status); +} + +void WindowsTimezoneNativeApi::CloseEnumeration(UEnumeration* enumeration) { + uenum_close(enumeration); +} + +TimezoneOperationResult ResolveLocalTimezone(TimezoneNativeApi& api) { + DYNAMIC_TIME_ZONE_INFORMATION information{}; + if (api.GetDynamicTimeZoneInformation(&information) == + TIME_ZONE_ID_INVALID) { + return Failure("windows-timezone-query-failed", + "Windows could not provide its current time zone."); + } + + const size_t key_length = wcsnlen_s(information.TimeZoneKeyName, + _countof(information.TimeZoneKeyName)); + if (key_length == 0 || + key_length >= _countof(information.TimeZoneKeyName) || + key_length > static_cast(std::numeric_limits::max())) { + return Failure("windows-timezone-key-invalid", + "Windows returned an invalid time-zone key."); + } + + std::array geo_buffer{}; + const int geo_length = api.GetUserDefaultGeoName( + geo_buffer.data(), static_cast(geo_buffer.size())); + if (geo_length <= 0 || geo_length > static_cast(geo_buffer.size())) { + return Failure("windows-region-unavailable", + "Windows could not provide the current geographic region."); + } + // The Win32 length includes the terminator. Never infer a length by reading + // beyond what the API reported. + const int explicit_geo_length = + geo_buffer[static_cast(geo_length - 1)] == L'\0' + ? geo_length - 1 + : geo_length; + if (explicit_geo_length <= 0) { + return Failure("windows-region-empty", + "Windows returned an empty geographic region."); + } + std::string region; + if (!WideToUtf8(geo_buffer.data(), explicit_geo_length, ®ion)) { + return Failure("windows-region-encoding-failed", + "Windows returned an invalid geographic region."); + } + + std::array mapped{}; + UErrorCode status = U_ZERO_ERROR; + const int32_t mapped_length = api.GetTimeZoneIdForWindowsId( + information.TimeZoneKeyName, static_cast(key_length), + region.c_str(), mapped.data(), static_cast(mapped.size()), + &status); + if (U_FAILURE(status)) { + return Failure("icu-timezone-mapping-failed", + "ICU could not map the Windows time zone."); + } + if (mapped_length <= 0) { + return Failure("icu-timezone-mapping-missing", + "No IANA mapping exists for the Windows time zone."); + } + if (mapped_length >= static_cast(mapped.size())) { + return Failure("icu-timezone-mapping-too-long", + "The mapped IANA time zone exceeded the safe limit."); + } + + std::string identifier; + if (!WideToUtf8(mapped.data(), mapped_length, &identifier)) { + return Failure("icu-timezone-encoding-failed", + "ICU returned an invalid IANA time zone."); + } + TimezoneOperationResult result{}; + result.succeeded = true; + result.value = std::move(identifier); + return result; +} + +TimezoneOperationResult EnumerateTimezones(TimezoneNativeApi& api) { + UErrorCode status = U_ZERO_ERROR; + UEnumeration* raw_enumeration = api.OpenTimeZoneIdEnumeration(&status); + if (raw_enumeration == nullptr) { + return Failure("icu-timezone-enumeration-open-failed", + "ICU could not open the time-zone catalog."); + } + EnumerationOwner enumeration(api, raw_enumeration); + if (U_FAILURE(status)) { + return Failure("icu-timezone-enumeration-open-failed", + "ICU could not open the time-zone catalog."); + } + + status = U_ZERO_ERROR; + const int32_t count = api.EnumerationCount(enumeration.get(), &status); + if (U_FAILURE(status) || count < 0) { + return Failure("icu-timezone-enumeration-count-failed", + "ICU could not count the available time zones."); + } + + TimezoneOperationResult result{}; + result.values.reserve(static_cast(count)); + for (int32_t index = 0; index < count; ++index) { + status = U_ZERO_ERROR; + int32_t value_length = 0; + const char* value = + api.EnumerationNext(enumeration.get(), &value_length, &status); + if (U_FAILURE(status) || value == nullptr || value_length <= 0) { + return Failure("icu-timezone-enumeration-read-failed", + "ICU could not read the time-zone catalog."); + } + result.values.emplace_back(value, static_cast(value_length)); + } + result.succeeded = true; + return result; +} + +} // namespace flutter_timezone diff --git a/third_party/flutter_timezone/windows/timezone_native_api.h b/third_party/flutter_timezone/windows/timezone_native_api.h new file mode 100644 index 00000000..2cc36733 --- /dev/null +++ b/third_party/flutter_timezone/windows/timezone_native_api.h @@ -0,0 +1,74 @@ +#ifndef FLUTTER_TIMEZONE_TIMEZONE_NATIVE_API_H_ +#define FLUTTER_TIMEZONE_TIMEZONE_NATIVE_API_H_ + +// Windows must precede ICU headers. +#include + +#define UCHAR_TYPE wchar_t +#include + +#include +#include +#include + +namespace flutter_timezone { + +struct TimezoneOperationResult { + bool succeeded = false; + std::string value; + std::vector values; + std::string error_code; + std::string error_message; +}; + +// Kept behind an interface so every Win32 and ICU failure can be exercised by +// native tests without changing process or machine state. +class TimezoneNativeApi { + public: + virtual ~TimezoneNativeApi() = default; + + virtual DWORD GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) = 0; + virtual int GetUserDefaultGeoName(wchar_t* buffer, int capacity) = 0; + virtual int32_t GetTimeZoneIdForWindowsId( + const UChar* windows_id, + int32_t windows_id_length, + const char* region, + UChar* result, + int32_t result_capacity, + UErrorCode* status) = 0; + virtual UEnumeration* OpenTimeZoneIdEnumeration(UErrorCode* status) = 0; + virtual int32_t EnumerationCount(UEnumeration* enumeration, + UErrorCode* status) = 0; + virtual const char* EnumerationNext(UEnumeration* enumeration, + int32_t* result_length, + UErrorCode* status) = 0; + virtual void CloseEnumeration(UEnumeration* enumeration) = 0; +}; + +class WindowsTimezoneNativeApi final : public TimezoneNativeApi { + public: + DWORD GetDynamicTimeZoneInformation( + DYNAMIC_TIME_ZONE_INFORMATION* information) override; + int GetUserDefaultGeoName(wchar_t* buffer, int capacity) override; + int32_t GetTimeZoneIdForWindowsId(const UChar* windows_id, + int32_t windows_id_length, + const char* region, + UChar* result, + int32_t result_capacity, + UErrorCode* status) override; + UEnumeration* OpenTimeZoneIdEnumeration(UErrorCode* status) override; + int32_t EnumerationCount(UEnumeration* enumeration, + UErrorCode* status) override; + const char* EnumerationNext(UEnumeration* enumeration, + int32_t* result_length, + UErrorCode* status) override; + void CloseEnumeration(UEnumeration* enumeration) override; +}; + +TimezoneOperationResult ResolveLocalTimezone(TimezoneNativeApi& api); +TimezoneOperationResult EnumerateTimezones(TimezoneNativeApi& api); + +} // namespace flutter_timezone + +#endif // FLUTTER_TIMEZONE_TIMEZONE_NATIVE_API_H_ diff --git a/third_party/tray_manager/windows/CMakeLists.txt b/third_party/tray_manager/windows/CMakeLists.txt index 0ba71115..99b41362 100644 --- a/third_party/tray_manager/windows/CMakeLists.txt +++ b/third_party/tray_manager/windows/CMakeLists.txt @@ -8,6 +8,8 @@ set(PLUGIN_NAME "tray_manager_plugin") add_library(${PLUGIN_NAME} SHARED "tray_manager_plugin.cpp" + "tray_native_api.cpp" + "tray_native_api.h" ) apply_standard_settings(${PLUGIN_NAME}) set_target_properties(${PLUGIN_NAME} PROPERTIES @@ -16,10 +18,37 @@ target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) target_compile_definitions(${PLUGIN_NAME} PRIVATE _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING) target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) +target_link_libraries(${PLUGIN_NAME} PRIVATE + flutter flutter_wrapper_plugin "shell32.lib" "user32.lib") # List of absolute paths to libraries that should be bundled with the plugin set(tray_manager_bundled_libraries "" PARENT_SCOPE ) + +if (${include_${PROJECT_NAME}_tests}) + set(TEST_RUNNER "${PROJECT_NAME}_test") + enable_testing() + include(FetchContent) + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip + ) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) + FetchContent_MakeAvailable(googletest) + add_executable(${TEST_RUNNER} + "test/tray_native_api_test.cpp" + "tray_native_api.cpp" + "tray_native_api.h" + ) + apply_standard_settings(${TEST_RUNNER}) + target_include_directories(${TEST_RUNNER} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" + ) + target_link_libraries(${TEST_RUNNER} PRIVATE + gtest_main gmock "shell32.lib" "user32.lib") + include(GoogleTest) + gtest_discover_tests(${TEST_RUNNER}) +endif() diff --git a/third_party/tray_manager/windows/test/tray_native_api_test.cpp b/third_party/tray_manager/windows/test/tray_native_api_test.cpp new file mode 100644 index 00000000..ea3b5127 --- /dev/null +++ b/third_party/tray_manager/windows/test/tray_native_api_test.cpp @@ -0,0 +1,212 @@ +#include "tray_native_api.h" + +#include + +#include +#include + +namespace tray_manager { +namespace { + +class FakeTrayApi final : public TrayNativeApi { + public: + HICON loaded_icon = reinterpret_cast(static_cast(0x100)); + bool destroy_result = true; + bool add_result = true; + bool modify_result = true; + bool delete_result = true; + bool version_result = true; + HRESULT rect_result = S_OK; + std::vector destroyed; + std::vector notify_calls; + std::wstring loaded_path; + std::wstring modified_tooltip; + HMENU created_menu = reinterpret_cast(static_cast(0x300)); + bool append_result = true; + bool destroy_menu_result = true; + bool cursor_result = true; + bool foreground_result = true; + bool track_result = true; + int destroyed_menus = 0; + + HICON LoadIconFile(const wchar_t* path, int, int) override { + loaded_path = path == nullptr ? L"" : path; + return loaded_icon; + } + + bool DestroyIconHandle(HICON icon) override { + destroyed.push_back(icon); + return destroy_result; + } + + bool NotifyIcon(DWORD message, NOTIFYICONDATAW* data) override { + notify_calls.push_back(message); + if (message == NIM_MODIFY && data != nullptr) { + modified_tooltip = data->szTip; + } + if (message == NIM_ADD) return add_result; + if (message == NIM_MODIFY) return modify_result; + if (message == NIM_DELETE) return delete_result; + if (message == NIM_SETVERSION) return version_result; + return false; + } + + HRESULT NotifyIconGetRect(const NOTIFYICONIDENTIFIER*, + RECT* rect) override { + if (SUCCEEDED(rect_result) && rect != nullptr) { + *rect = {1, 2, 11, 22}; + } + return rect_result; + } + + HMENU CreatePopupMenuHandle() override { return created_menu; } + bool AppendMenuItem(HMENU, UINT, UINT_PTR, const wchar_t*) override { + return append_result; + } + bool DestroyMenuHandle(HMENU) override { + ++destroyed_menus; + return destroy_menu_result; + } + bool GetCursorPosition(POINT* point) override { + if (cursor_result && point != nullptr) *point = {1, 2}; + return cursor_result; + } + bool SetForegroundWindowHandle(HWND) override { return foreground_result; } + bool TrackMenu(HMENU, int, int, HWND) override { return track_result; } + + DWORD LastError() const override { return ERROR_GEN_FAILURE; } +}; + +const HWND kWindow = reinterpret_cast(static_cast(0x200)); + +TEST(TrayCallbackTest, DecodesVersionFourEventAndIconIdentifier) { + const LPARAM value = MAKELPARAM(WM_RBUTTONUP, 1); + EXPECT_EQ(TrayCallbackEvent(value), WM_RBUTTONUP); + EXPECT_EQ(TrayCallbackIconId(value), 1u); +} + +TEST(TrayIconControllerTest, CreatesTrayAndNegotiatesCurrentVersion) { + FakeTrayApi api; + TrayIconController controller(api); + const auto result = controller.SetIcon(kWindow, L"busymax.ico"); + EXPECT_TRUE(result.succeeded); + EXPECT_TRUE(controller.available()); + EXPECT_EQ(api.notify_calls, (std::vector{NIM_ADD, NIM_SETVERSION})); + EXPECT_TRUE(api.destroyed.empty()); +} + +TEST(TrayIconControllerTest, RejectsMissingOrCorruptIcon) { + FakeTrayApi api; + api.loaded_icon = nullptr; + TrayIconController controller(api); + const auto result = controller.SetIcon(kWindow, L"missing.ico"); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-icon-load-failed"); + EXPECT_FALSE(controller.available()); + EXPECT_TRUE(api.destroyed.empty()); +} + +TEST(TrayIconControllerTest, AddFailureDoesNotClaimAvailability) { + FakeTrayApi api; + api.add_result = false; + TrayIconController controller(api); + const auto result = controller.SetIcon(kWindow, L"busymax.ico"); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-add-failed"); + EXPECT_FALSE(controller.available()); + EXPECT_EQ(api.destroyed, (std::vector{api.loaded_icon})); +} + +TEST(TrayIconControllerTest, ReplacesIconOnlyAfterSuccessfulModify) { + FakeTrayApi api; + TrayIconController controller(api); + const HICON first = api.loaded_icon; + ASSERT_TRUE(controller.SetIcon(kWindow, L"first.ico").succeeded); + const HICON second = + reinterpret_cast(static_cast(0x101)); + api.loaded_icon = second; + ASSERT_TRUE(controller.SetIcon(kWindow, L"second.ico").succeeded); + EXPECT_EQ(api.destroyed, (std::vector{first})); + + const HICON rejected = + reinterpret_cast(static_cast(0x102)); + api.loaded_icon = rejected; + api.modify_result = false; + EXPECT_FALSE(controller.SetIcon(kWindow, L"rejected.ico").succeeded); + EXPECT_TRUE(controller.available()); + EXPECT_EQ(api.destroyed, (std::vector{first, rejected})); +} + +TEST(TrayIconControllerTest, PreservesTooltipAcrossIconReplacement) { + FakeTrayApi api; + TrayIconController controller(api); + ASSERT_TRUE(controller.SetIcon(kWindow, L"first.ico").succeeded); + ASSERT_TRUE(controller.SetTooltip(L"BusyMax offline").succeeded); + api.loaded_icon = + reinterpret_cast(static_cast(0x101)); + ASSERT_TRUE(controller.SetIcon(kWindow, L"second.ico").succeeded); + EXPECT_EQ(controller.tooltip(), L"BusyMax offline"); + EXPECT_EQ(api.modified_tooltip, L"BusyMax offline"); +} + +TEST(TrayIconControllerTest, ExplorerRecoveryReportsFailureAndSuccess) { + FakeTrayApi api; + TrayIconController controller(api); + ASSERT_TRUE(controller.SetIcon(kWindow, L"busymax.ico").succeeded); + api.add_result = false; + EXPECT_FALSE(controller.Recover(kWindow).succeeded); + EXPECT_FALSE(controller.available()); + api.add_result = true; + EXPECT_TRUE(controller.Recover(kWindow).succeeded); + EXPECT_TRUE(controller.available()); +} + +TEST(TrayIconControllerTest, DestroyAndRecreateReleasesEachIconOnce) { + FakeTrayApi api; + TrayIconController controller(api); + const HICON first = api.loaded_icon; + ASSERT_TRUE(controller.SetIcon(kWindow, L"first.ico").succeeded); + ASSERT_TRUE(controller.Destroy().succeeded); + EXPECT_EQ(api.destroyed, (std::vector{first})); + api.loaded_icon = + reinterpret_cast(static_cast(0x101)); + ASSERT_TRUE(controller.SetIcon(kWindow, L"second.ico").succeeded); + EXPECT_TRUE(controller.available()); +} + +TEST(TrayIconControllerTest, GetBoundsFailureIsPropagated) { + FakeTrayApi api; + TrayIconController controller(api); + ASSERT_TRUE(controller.SetIcon(kWindow, L"busymax.ico").succeeded); + api.rect_result = E_FAIL; + RECT rect{}; + const auto result = controller.GetBounds(&rect); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-bounds-query-failed"); +} + +TEST(TrayMenuControllerTest, MenuCreationFailureIsPropagated) { + FakeTrayApi api; + api.created_menu = nullptr; + TrayMenuController menu(api); + const auto result = menu.SetItems({}); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-menu-create-failed"); + EXPECT_FALSE(menu.available()); +} + +TEST(TrayMenuControllerTest, MenuInsertionFailureDestroysReplacementOnce) { + FakeTrayApi api; + api.append_result = false; + TrayMenuController menu(api); + TrayMenuItem item; + item.id = 1; + item.label = L"Open BusyMax"; + const auto result = menu.SetItems({item}); + EXPECT_FALSE(result.succeeded); + EXPECT_EQ(result.error_code, "tray-menu-insert-failed"); + EXPECT_EQ(api.destroyed_menus, 1); +} + +} // namespace +} // namespace tray_manager diff --git a/third_party/tray_manager/windows/tray_manager_plugin.cpp b/third_party/tray_manager/windows/tray_manager_plugin.cpp index 59e98d69..d69b0925 100644 --- a/third_party/tray_manager/windows/tray_manager_plugin.cpp +++ b/third_party/tray_manager/windows/tray_manager_plugin.cpp @@ -1,21 +1,19 @@ #include "include/tray_manager/tray_manager_plugin.h" -// This must be included before many other Windows headers. -#include -#include +#include "tray_native_api.h" -#include -#include +#include #include #include #include -#include #include #include #include -#include +#include +#include +#include #define WM_MYMESSAGE (WM_USER + 1) @@ -23,393 +21,380 @@ namespace { const flutter::EncodableValue* ValueOrNull(const flutter::EncodableMap& map, const char* key) { - auto it = map.find(flutter::EncodableValue(key)); - if (it == map.end()) { - return nullptr; - } - return &(it->second); + const auto found = map.find(flutter::EncodableValue(key)); + return found == map.end() ? nullptr : &found->second; } -std::unique_ptr< - flutter::MethodChannel, - std::default_delete>> - channel = nullptr; + +std::unique_ptr> channel; +std::unique_ptr> health_channel; class TrayManagerPlugin : public flutter::Plugin { public: static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); - - TrayManagerPlugin(flutter::PluginRegistrarWindows* registrar); - - virtual ~TrayManagerPlugin(); + explicit TrayManagerPlugin(flutter::PluginRegistrarWindows* registrar); + ~TrayManagerPlugin() override; private: - std::wstring_convert> g_converter; - - flutter::PluginRegistrarWindows* registrar; - NOTIFYICONDATA nid; - NOTIFYICONIDENTIFIER niif; - // do create pop-up menu only once. - HMENU hMenu = CreatePopupMenu(); - bool tray_icon_setted = false; - UINT windows_taskbar_created_message_id = 0; - - // The ID of the WindowProc delegate registration. - int window_proc_id = -1; - - void TrayManagerPlugin::_CreateMenu(HMENU menu, flutter::EncodableMap args); - void TrayManagerPlugin::_ApplyIcon(); - - // Called for top-level WindowProc delegation. - std::optional TrayManagerPlugin::HandleWindowProc(HWND hwnd, - UINT message, - WPARAM wparam, - LPARAM lparam); - HWND TrayManagerPlugin::GetMainWindow(); - void TrayManagerPlugin::Destroy( + flutter::PluginRegistrarWindows* registrar_; + tray_manager::WindowsTrayNativeApi native_api_; + tray_manager::TrayIconController tray_{native_api_}; + tray_manager::TrayMenuController menu_{native_api_}; + std::wstring_convert> converter_; + UINT taskbar_created_message_ = 0; + int window_proc_id_ = -1; + + HWND GetMainWindow() const; + void ReportAvailability(const tray_manager::TrayOperationResult& operation); + bool BuildMenu(const flutter::EncodableMap& args, + std::vector* menu, + std::string* error); + std::optional HandleWindowProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam); + void HandleMethodCall( const flutter::MethodCall& method_call, std::unique_ptr> result); - void TrayManagerPlugin::SetIcon( - const flutter::MethodCall& method_call, + void Destroy( std::unique_ptr> result); - void TrayManagerPlugin::SetToolTip( + void SetIcon( const flutter::MethodCall& method_call, std::unique_ptr> result); - void TrayManagerPlugin::SetContextMenu( + void SetToolTip( const flutter::MethodCall& method_call, std::unique_ptr> result); - void TrayManagerPlugin::PopUpContextMenu( + void SetContextMenu( const flutter::MethodCall& method_call, std::unique_ptr> result); - void TrayManagerPlugin::GetBounds( + void PopUpContextMenu( const flutter::MethodCall& method_call, std::unique_ptr> result); - // Called when a method is called on this plugin's channel from Dart. - void HandleMethodCall( + void GetBounds( const flutter::MethodCall& method_call, std::unique_ptr> result); }; -static bool plugin_already_registered = false; +bool plugin_already_registered = false; -// static void TrayManagerPlugin::RegisterWithRegistrar( flutter::PluginRegistrarWindows* registrar) { if (plugin_already_registered) { - // Skip registration in subwindow return; } - plugin_already_registered = true; - channel = std::make_unique>( registrar->messenger(), "tray_manager", &flutter::StandardMethodCodec::GetInstance()); - + health_channel = + std::make_unique>( + registrar->messenger(), "busymax/windows/tray_health", + &flutter::StandardMethodCodec::GetInstance()); auto plugin = std::make_unique(registrar); - channel->SetMethodCallHandler( [plugin_pointer = plugin.get()](const auto& call, auto result) { plugin_pointer->HandleMethodCall(call, std::move(result)); }); - registrar->AddPlugin(std::move(plugin)); } -TrayManagerPlugin::TrayManagerPlugin(flutter::PluginRegistrarWindows* registrar) - : registrar(registrar) { - window_proc_id = registrar->RegisterTopLevelWindowProcDelegate( - [this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { - return HandleWindowProc(hwnd, message, wparam, lparam); +TrayManagerPlugin::TrayManagerPlugin( + flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar) { + window_proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( + [this](HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + return HandleWindowProc(window, message, wparam, lparam); }); - windows_taskbar_created_message_id = RegisterWindowMessage(L"TaskbarCreated"); + taskbar_created_message_ = RegisterWindowMessageW(L"TaskbarCreated"); } TrayManagerPlugin::~TrayManagerPlugin() { - registrar->UnregisterTopLevelWindowProcDelegate(window_proc_id); + tray_.Destroy(); + if (window_proc_id_ >= 0) { + registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_); + } } -void TrayManagerPlugin::_CreateMenu(HMENU menu, flutter::EncodableMap args) { - flutter::EncodableList items = std::get( - args.at(flutter::EncodableValue("items"))); +HWND TrayManagerPlugin::GetMainWindow() const { + return ::GetAncestor(registrar_->GetView()->GetNativeWindow(), GA_ROOT); +} - int count = GetMenuItemCount(menu); - for (int i = 0; i < count; i++) { - // always remove at 0 because they shift every time - RemoveMenu(menu, 0, MF_BYPOSITION); +void TrayManagerPlugin::ReportAvailability( + const tray_manager::TrayOperationResult& operation) { + if (!health_channel) { + return; } + flutter::EncodableMap event; + event[flutter::EncodableValue("available")] = + flutter::EncodableValue(tray_.available()); + if (!operation.succeeded) { + event[flutter::EncodableValue("errorCode")] = + flutter::EncodableValue(operation.error_code); + } + health_channel->InvokeMethod( + "onTrayAvailabilityChanged", + std::make_unique(event)); +} - for (flutter::EncodableValue item_value : items) { - flutter::EncodableMap item_map = - std::get(item_value); - int id = std::get(item_map.at(flutter::EncodableValue("id"))); - std::string type = - std::get(item_map.at(flutter::EncodableValue("type"))); - std::string label = - std::get(item_map.at(flutter::EncodableValue("label"))); - auto* checked = std::get_if(ValueOrNull(item_map, "checked")); - bool disabled = - std::get(item_map.at(flutter::EncodableValue("disabled"))); - - UINT_PTR item_id = id; - UINT uFlags = MF_STRING; - - if (disabled) { - uFlags |= MF_GRAYED; +bool TrayManagerPlugin::BuildMenu(const flutter::EncodableMap& args, + std::vector* menu, + std::string* error) { + const auto items_it = args.find(flutter::EncodableValue("items")); + if (menu == nullptr || items_it == args.end() || + !std::holds_alternative(items_it->second)) { + *error = "tray-menu-invalid"; + return false; + } + const auto& items = std::get(items_it->second); + for (const flutter::EncodableValue& item_value : items) { + if (!std::holds_alternative(item_value)) { + *error = "tray-menu-item-invalid"; + return false; } - - if (type.compare("separator") == 0) { - AppendMenuW(menu, MF_SEPARATOR, item_id, NULL); - } else { - if (type.compare("checkbox") == 0) { + const auto& item = std::get(item_value); + try { + const int id = + std::get(item.at(flutter::EncodableValue("id"))); + const std::string type = + std::get(item.at(flutter::EncodableValue("type"))); + const std::string label = + std::get(item.at(flutter::EncodableValue("label"))); + const bool disabled = + std::get(item.at(flutter::EncodableValue("disabled"))); + tray_manager::TrayMenuItem parsed; + parsed.id = id; + parsed.label = converter_.from_bytes(label); + parsed.disabled = disabled; + if (type == "separator") { + parsed.type = tray_manager::TrayMenuItemType::kSeparator; + } else if (type == "checkbox") { + const bool* checked = std::get_if(ValueOrNull(item, "checked")); if (checked == nullptr) { - // skip - } else { - uFlags |= (*checked == true ? MF_CHECKED : MF_UNCHECKED); + *error = "tray-menu-checkbox-invalid"; + return false; + } + parsed.type = tray_manager::TrayMenuItemType::kCheckbox; + parsed.checked = *checked; + } else if (type == "submenu") { + parsed.type = tray_manager::TrayMenuItemType::kSubmenu; + const auto submenu_it = item.find(flutter::EncodableValue("submenu")); + if (submenu_it == item.end() || + !std::holds_alternative(submenu_it->second) || + !BuildMenu(std::get(submenu_it->second), + &parsed.children, error)) { + return false; } - } else if (type.compare("submenu") == 0) { - uFlags |= MF_POPUP; - HMENU sub_menu = ::CreatePopupMenu(); - _CreateMenu(sub_menu, std::get(item_map.at( - flutter::EncodableValue("submenu")))); - item_id = reinterpret_cast(sub_menu); + } else if (type != "normal") { + *error = "tray-menu-type-invalid"; + return false; } - AppendMenuW(menu, uFlags, item_id, g_converter.from_bytes(label).c_str()); + menu->push_back(std::move(parsed)); + } catch (const std::exception&) { + *error = "tray-menu-value-invalid"; + return false; } } + return true; } -std::optional TrayManagerPlugin::HandleWindowProc(HWND hWnd, +std::optional TrayManagerPlugin::HandleWindowProc(HWND window, UINT message, - WPARAM wParam, - LPARAM lParam) { - std::optional result; + WPARAM wparam, + LPARAM lparam) { if (message == WM_DESTROY) { - if (tray_icon_setted) { - Shell_NotifyIcon(NIM_DELETE, &nid); - DestroyIcon(nid.hIcon); - } + const auto operation = tray_.Destroy(); + ReportAvailability(operation); } else if (message == WM_COMMAND) { - flutter::EncodableMap eventData = flutter::EncodableMap(); - eventData[flutter::EncodableValue("id")] = - flutter::EncodableValue((int)wParam); - + flutter::EncodableMap event; + event[flutter::EncodableValue("id")] = + flutter::EncodableValue(static_cast(LOWORD(wparam))); channel->InvokeMethod("onTrayMenuItemClick", - std::make_unique(eventData)); + std::make_unique(event)); } else if (message == WM_MYMESSAGE) { - switch (lParam) { - case WM_LBUTTONUP: - channel->InvokeMethod("onTrayIconMouseDown", - std::make_unique()); - break; - case WM_RBUTTONUP: - channel->InvokeMethod("onTrayIconRightMouseDown", - std::make_unique()); - break; - default: - return DefWindowProc(hWnd, message, wParam, lParam); - }; - } else if (message == windows_taskbar_created_message_id) { - if (windows_taskbar_created_message_id != 0 && tray_icon_setted) { - // restore the icon with the existing resource. - tray_icon_setted = false; - _ApplyIcon(); + if (tray_manager::TrayCallbackIconId(lparam) != 1) { + return std::nullopt; } - } else if (message == WM_POWERBROADCAST) { - // Handle power management events (sleep/wake) - switch (wParam) { - case PBT_APMRESUMEAUTOMATIC: - case PBT_APMRESUMESUSPEND: - // System is resuming from sleep/hibernation - if (tray_icon_setted) { - // Restore the tray icon after system wakes up - tray_icon_setted = false; - _ApplyIcon(); - } - break; - default: - break; + const UINT event = tray_manager::TrayCallbackEvent(lparam); + if (event == WM_LBUTTONUP || event == NIN_SELECT || + event == NIN_KEYSELECT) { + channel->InvokeMethod("onTrayIconMouseDown", + std::make_unique()); + } else if (event == WM_RBUTTONUP || event == WM_CONTEXTMENU) { + channel->InvokeMethod("onTrayIconRightMouseDown", + std::make_unique()); } + } else if (message == taskbar_created_message_ && + taskbar_created_message_ != 0) { + const auto operation = tray_.Recover(GetMainWindow()); + ReportAvailability(operation); + } else if (message == WM_POWERBROADCAST && + (wparam == PBT_APMRESUMEAUTOMATIC || + wparam == PBT_APMRESUMESUSPEND) && + tray_.available()) { + const auto operation = tray_.Recover(GetMainWindow()); + ReportAvailability(operation); } - return result; -} - -HWND TrayManagerPlugin::GetMainWindow() { - return ::GetAncestor(registrar->GetView()->GetNativeWindow(), GA_ROOT); + return std::nullopt; } void TrayManagerPlugin::Destroy( - const flutter::MethodCall& method_call, std::unique_ptr> result) { - Shell_NotifyIcon(NIM_DELETE, &nid); - DestroyIcon(nid.hIcon); - tray_icon_setted = false; - + const auto operation = tray_.Destroy(); + ReportAvailability(operation); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } result->Success(flutter::EncodableValue(true)); } void TrayManagerPlugin::SetIcon( const flutter::MethodCall& method_call, std::unique_ptr> result) { - const flutter::EncodableMap& args = - std::get(*method_call.arguments()); - - std::string iconPath = - std::get(args.at(flutter::EncodableValue("iconPath"))); - - std::wstring_convert> converter; - - if (nid.hIcon != nullptr) { - DestroyIcon(nid.hIcon); - } - - nid.hIcon = static_cast( - LoadImage(nullptr, (LPCWSTR)(converter.from_bytes(iconPath).c_str()), - IMAGE_ICON, GetSystemMetrics(SM_CXSMICON), - GetSystemMetrics(SM_CYSMICON), LR_LOADFROMFILE)); - - _ApplyIcon(); - - result->Success(flutter::EncodableValue(true)); -} - -void TrayManagerPlugin::_ApplyIcon() { - if (tray_icon_setted) { - Shell_NotifyIcon(NIM_MODIFY, &nid); - } else { - HICON hIconBackup = nid.hIcon; - WCHAR szTipBackup[128]; - StringCchCopy(szTipBackup, _countof(szTipBackup), nid.szTip); - - ZeroMemory(&nid, sizeof(NOTIFYICONDATA)); - nid.cbSize = sizeof(NOTIFYICONDATA); - nid.hWnd = GetMainWindow(); - nid.uID = 1; - nid.hIcon = hIconBackup; - StringCchCopy(nid.szTip, _countof(nid.szTip), szTipBackup); - nid.uCallbackMessage = WM_MYMESSAGE; - nid.uFlags = NIF_MESSAGE | NIF_ICON; - if (nid.szTip[0] != '\0') { - nid.uFlags |= NIF_TIP; + try { + const auto& args = + std::get(*method_call.arguments()); + const std::string path = + std::get(args.at(flutter::EncodableValue("iconPath"))); + const auto operation = tray_.SetIcon(GetMainWindow(), + converter_.from_bytes(path)); + ReportAvailability(operation); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; } - Shell_NotifyIcon(NIM_ADD, &nid); + result->Success(flutter::EncodableValue(true)); + } catch (const std::exception&) { + result->Error("tray-icon-arguments-invalid", + "The BusyMax tray icon path was invalid."); } - - niif.cbSize = sizeof(NOTIFYICONIDENTIFIER); - niif.hWnd = nid.hWnd; - niif.uID = nid.uID; - niif.guidItem = GUID_NULL; - - tray_icon_setted = true; } void TrayManagerPlugin::SetToolTip( const flutter::MethodCall& method_call, std::unique_ptr> result) { - const flutter::EncodableMap& args = - std::get(*method_call.arguments()); - - std::string toolTip = - std::get(args.at(flutter::EncodableValue("toolTip"))); - - std::wstring_convert> converter; - nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; - StringCchCopy(nid.szTip, _countof(nid.szTip), - converter.from_bytes(toolTip).c_str()); - Shell_NotifyIcon(NIM_MODIFY, &nid); - - result->Success(flutter::EncodableValue(true)); + try { + const auto& args = + std::get(*method_call.arguments()); + const std::string tooltip = + std::get(args.at(flutter::EncodableValue("toolTip"))); + const auto operation = tray_.SetTooltip(converter_.from_bytes(tooltip)); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + result->Success(flutter::EncodableValue(true)); + } catch (const std::exception&) { + result->Error("tray-tooltip-invalid", + "The BusyMax tray tooltip was invalid."); + } } void TrayManagerPlugin::SetContextMenu( const flutter::MethodCall& method_call, std::unique_ptr> result) { - const flutter::EncodableMap& args = - std::get(*method_call.arguments()); - - _CreateMenu(hMenu, std::get( - args.at(flutter::EncodableValue("menu")))); - + std::vector parsed; + std::string error; + try { + const auto& args = + std::get(*method_call.arguments()); + const auto& menu_args = std::get( + args.at(flutter::EncodableValue("menu"))); + if (!BuildMenu(menu_args, &parsed, &error)) { + result->Error(error, "Windows could not build the BusyMax tray menu."); + return; + } + } catch (const std::exception&) { + result->Error("tray-menu-arguments-invalid", + "The BusyMax tray menu was invalid."); + return; + } + const auto operation = menu_.SetItems(parsed); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } result->Success(flutter::EncodableValue(true)); } void TrayManagerPlugin::PopUpContextMenu( const flutter::MethodCall& method_call, std::unique_ptr> result) { - const flutter::EncodableMap& args = - std::get(*method_call.arguments()); - - bool bringAppToFront = - std::get(args.at(flutter::EncodableValue("bringAppToFront"))); - - HWND hWnd = GetMainWindow(); - - double x, y; - - // RECT rect; - // Shell_NotifyIconGetRect(&niif, &rect); - - // x = rect.left + ((rect.right - rect.left) / 2); - // y = rect.top + ((rect.bottom - rect.top) / 2); - - POINT cursorPos; - GetCursorPos(&cursorPos); - x = cursorPos.x; - y = cursorPos.y; - - if (bringAppToFront) { - SetForegroundWindow(hWnd); + if (!tray_.available() || !menu_.available()) { + result->Error("tray-menu-unavailable", + "The BusyMax tray menu is not available."); + return; + } + bool bring_to_front = false; + try { + const auto& args = + std::get(*method_call.arguments()); + bring_to_front = std::get( + args.at(flutter::EncodableValue("bringAppToFront"))); + } catch (const std::exception&) { + result->Error("tray-menu-arguments-invalid", + "The BusyMax tray menu request was invalid."); + return; + } + const auto operation = menu_.Show(GetMainWindow(), bring_to_front); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; } - TrackPopupMenu(hMenu, TPM_BOTTOMALIGN | TPM_LEFTALIGN, static_cast(x), - static_cast(y), 0, hWnd, NULL); result->Success(flutter::EncodableValue(true)); } void TrayManagerPlugin::GetBounds( const flutter::MethodCall& method_call, std::unique_ptr> result) { - const flutter::EncodableMap& args = - std::get(*method_call.arguments()); - - if (!tray_icon_setted) { - result->Success(); + double scale = 0; + try { + const auto& args = + std::get(*method_call.arguments()); + scale = std::get( + args.at(flutter::EncodableValue("devicePixelRatio"))); + } catch (const std::exception&) { + result->Error("tray-bounds-arguments-invalid", + "The BusyMax tray scale value was invalid."); return; } - - double devicePixelRatio = - std::get(args.at(flutter::EncodableValue("devicePixelRatio"))); - - RECT rect; - Shell_NotifyIconGetRect(&niif, &rect); - flutter::EncodableMap resultMap = flutter::EncodableMap(); - - double x = rect.left / devicePixelRatio * 1.0f; - double y = rect.top / devicePixelRatio * 1.0f; - double width = (rect.right - rect.left) / devicePixelRatio * 1.0f; - double height = (rect.bottom - rect.top) / devicePixelRatio * 1.0f; - - resultMap[flutter::EncodableValue("x")] = flutter::EncodableValue(x); - resultMap[flutter::EncodableValue("y")] = flutter::EncodableValue(y); - resultMap[flutter::EncodableValue("width")] = flutter::EncodableValue(width); - resultMap[flutter::EncodableValue("height")] = - flutter::EncodableValue(height); - - result->Success(flutter::EncodableValue(resultMap)); + if (scale <= 0) { + result->Error("tray-bounds-scale-invalid", + "The BusyMax tray scale value must be positive."); + return; + } + RECT rect{}; + const auto operation = tray_.GetBounds(&rect); + if (!operation.succeeded) { + result->Error(operation.error_code, operation.error_message); + return; + } + flutter::EncodableMap bounds; + bounds[flutter::EncodableValue("x")] = + flutter::EncodableValue(rect.left / scale); + bounds[flutter::EncodableValue("y")] = + flutter::EncodableValue(rect.top / scale); + bounds[flutter::EncodableValue("width")] = + flutter::EncodableValue((rect.right - rect.left) / scale); + bounds[flutter::EncodableValue("height")] = + flutter::EncodableValue((rect.bottom - rect.top) / scale); + result->Success(flutter::EncodableValue(bounds)); } void TrayManagerPlugin::HandleMethodCall( const flutter::MethodCall& method_call, std::unique_ptr> result) { - if (method_call.method_name().compare("destroy") == 0) { - Destroy(method_call, std::move(result)); - } else if (method_call.method_name().compare("setIcon") == 0) { + const std::string& name = method_call.method_name(); + if (name == "destroy") { + Destroy(std::move(result)); + } else if (name == "setIcon") { SetIcon(method_call, std::move(result)); - } else if (method_call.method_name().compare("setToolTip") == 0) { + } else if (name == "setToolTip") { SetToolTip(method_call, std::move(result)); - } else if (method_call.method_name().compare("setContextMenu") == 0) { + } else if (name == "setContextMenu") { SetContextMenu(method_call, std::move(result)); - } else if (method_call.method_name().compare("popUpContextMenu") == 0) { + } else if (name == "popUpContextMenu") { PopUpContextMenu(method_call, std::move(result)); - } else if (method_call.method_name().compare("getBounds") == 0) { + } else if (name == "getBounds") { GetBounds(method_call, std::move(result)); } else { result->NotImplemented(); diff --git a/third_party/tray_manager/windows/tray_native_api.cpp b/third_party/tray_manager/windows/tray_native_api.cpp new file mode 100644 index 00000000..6270686e --- /dev/null +++ b/third_party/tray_manager/windows/tray_native_api.cpp @@ -0,0 +1,416 @@ +#include "tray_native_api.h" + +#include + +#include + +namespace tray_manager { + +UINT TrayCallbackEvent(LPARAM value) { + return LOWORD(static_cast(value)); +} + +UINT TrayCallbackIconId(LPARAM value) { + return HIWORD(static_cast(value)); +} + +namespace { + +TrayOperationResult Success() { + TrayOperationResult result{}; + result.succeeded = true; + return result; +} + +} // namespace + +HICON WindowsTrayNativeApi::LoadIconFile(const wchar_t* path, + int width, + int height) { + return static_cast(::LoadImageW(nullptr, path, IMAGE_ICON, width, + height, LR_LOADFROMFILE)); +} + +bool WindowsTrayNativeApi::DestroyIconHandle(HICON icon) { + return ::DestroyIcon(icon) != FALSE; +} + +bool WindowsTrayNativeApi::NotifyIcon(DWORD message, + NOTIFYICONDATAW* data) { + return ::Shell_NotifyIconW(message, data) != FALSE; +} + +HRESULT WindowsTrayNativeApi::NotifyIconGetRect( + const NOTIFYICONIDENTIFIER* identifier, + RECT* rect) { + return ::Shell_NotifyIconGetRect(identifier, rect); +} + +HMENU WindowsTrayNativeApi::CreatePopupMenuHandle() { + return ::CreatePopupMenu(); +} + +bool WindowsTrayNativeApi::AppendMenuItem(HMENU menu, + UINT flags, + UINT_PTR id, + const wchar_t* label) { + return ::AppendMenuW(menu, flags, id, label) != FALSE; +} + +bool WindowsTrayNativeApi::DestroyMenuHandle(HMENU menu) { + return ::DestroyMenu(menu) != FALSE; +} + +bool WindowsTrayNativeApi::GetCursorPosition(POINT* point) { + return ::GetCursorPos(point) != FALSE; +} + +bool WindowsTrayNativeApi::SetForegroundWindowHandle(HWND window) { + return ::SetForegroundWindow(window) != FALSE; +} + +bool WindowsTrayNativeApi::TrackMenu(HMENU menu, + int x, + int y, + HWND window) { + ::SetLastError(ERROR_SUCCESS); + const BOOL result = ::TrackPopupMenu(menu, TPM_BOTTOMALIGN | TPM_LEFTALIGN, + x, y, 0, window, nullptr); + // TrackPopupMenu also returns zero when the user dismisses the menu. That is + // a successful interaction unless Windows set a concrete error. + return result != FALSE || ::GetLastError() == ERROR_SUCCESS; +} + +DWORD WindowsTrayNativeApi::LastError() const { + return ::GetLastError(); +} + +TrayMenuController::~TrayMenuController() { + if (menu_ != nullptr) { + api_.DestroyMenuHandle(menu_); + menu_ = nullptr; + } +} + +bool TrayMenuController::Build(HMENU menu, + const std::vector& items, + std::string* error) { + for (const TrayMenuItem& item : items) { + UINT flags = item.type == TrayMenuItemType::kSeparator ? MF_SEPARATOR + : MF_STRING; + if (item.disabled) flags |= MF_GRAYED; + if (item.type == TrayMenuItemType::kCheckbox) { + flags |= item.checked ? MF_CHECKED : MF_UNCHECKED; + } + UINT_PTR item_id = static_cast(item.id); + HMENU unowned_submenu = nullptr; + if (item.type == TrayMenuItemType::kSubmenu) { + unowned_submenu = api_.CreatePopupMenuHandle(); + if (unowned_submenu == nullptr) { + *error = "tray-submenu-create-failed"; + return false; + } + if (!Build(unowned_submenu, item.children, error)) { + if (!api_.DestroyMenuHandle(unowned_submenu)) { + *error = "tray-submenu-destroy-failed"; + } + return false; + } + flags |= MF_POPUP; + item_id = reinterpret_cast(unowned_submenu); + } + if (!api_.AppendMenuItem(menu, flags, item_id, item.label.c_str())) { + if (unowned_submenu != nullptr) { + if (!api_.DestroyMenuHandle(unowned_submenu)) { + *error = "tray-submenu-destroy-failed"; + return false; + } + } + *error = "tray-menu-insert-failed"; + return false; + } + // After AppendMenu succeeds Windows transfers submenu ownership to root. + unowned_submenu = nullptr; + } + return true; +} + +TrayOperationResult TrayMenuController::SetItems( + const std::vector& items) { + HMENU replacement = api_.CreatePopupMenuHandle(); + if (replacement == nullptr) { + return {false, "tray-menu-create-failed", + "Windows could not create the BusyMax tray menu."}; + } + std::string error; + if (!Build(replacement, items, &error)) { + if (!api_.DestroyMenuHandle(replacement)) { + error = "tray-menu-destroy-failed"; + } + return {false, error, "Windows could not build the BusyMax tray menu."}; + } + HMENU previous = menu_; + menu_ = replacement; + if (previous != nullptr && !api_.DestroyMenuHandle(previous)) { + return {false, "tray-menu-destroy-failed", + "Windows could not release the previous BusyMax tray menu."}; + } + return Success(); +} + +TrayOperationResult TrayMenuController::Show(HWND window, + bool bring_to_front) { + if (menu_ == nullptr || window == nullptr) { + return {false, "tray-menu-unavailable", + "The BusyMax tray menu is unavailable."}; + } + POINT cursor{}; + if (!api_.GetCursorPosition(&cursor)) { + return {false, "tray-cursor-query-failed", + "Windows could not locate the pointer for the tray menu."}; + } + if (bring_to_front && !api_.SetForegroundWindowHandle(window)) { + return {false, "tray-window-foreground-failed", + "Windows could not foreground BusyMax for its tray menu."}; + } + if (!api_.TrackMenu(menu_, cursor.x, cursor.y, window)) { + return {false, "tray-menu-display-failed", + "Windows could not display the BusyMax tray menu."}; + } + return Success(); +} + +TrayIconController::TrayIconController(TrayNativeApi& api) : api_(api) { + notification_data_.cbSize = sizeof(notification_data_); + identifier_.cbSize = sizeof(identifier_); +} + +TrayIconController::~TrayIconController() { + if (available_) { + api_.NotifyIcon(NIM_DELETE, ¬ification_data_); + available_ = false; + } + ReleaseIcon(); +} + +TrayOperationResult TrayIconController::Failure(const char* code, + const char* message) const { + TrayOperationResult result{}; + result.error_code = code; + result.error_message = message; + return result; +} + +bool TrayIconController::FillNotificationData(HWND window, HICON icon) { + notification_data_ = {}; + notification_data_.cbSize = sizeof(notification_data_); + notification_data_.hWnd = window; + notification_data_.uID = 1; + notification_data_.uCallbackMessage = WM_USER + 1; + notification_data_.uFlags = NIF_MESSAGE | NIF_ICON; + notification_data_.hIcon = icon; + if (!tooltip_.empty()) { + notification_data_.uFlags |= NIF_TIP | NIF_SHOWTIP; + if (FAILED(StringCchCopyW(notification_data_.szTip, + _countof(notification_data_.szTip), + tooltip_.c_str()))) { + notification_data_.szTip[0] = L'\0'; + return false; + } + } + return true; +} + +void TrayIconController::FillIdentifier() { + identifier_ = {}; + identifier_.cbSize = sizeof(identifier_); + identifier_.hWnd = notification_data_.hWnd; + identifier_.uID = notification_data_.uID; + identifier_.guidItem = GUID_NULL; +} + +bool TrayIconController::ReleaseIcon() { + if (icon_ == nullptr) { + return true; + } + const bool destroyed = api_.DestroyIconHandle(icon_); + icon_ = nullptr; + notification_data_.hIcon = nullptr; + return destroyed; +} + +TrayOperationResult TrayIconController::Install(HWND window, + HICON icon, + bool own_new_icon) { + if (!FillNotificationData(window, icon)) { + if (own_new_icon && icon != nullptr && !api_.DestroyIconHandle(icon)) { + return Failure("tray-icon-destroy-failed", + "Windows could not release the invalid tray icon."); + } + return Failure("tray-tooltip-copy-failed", + "Windows could not prepare the BusyMax tray tooltip."); + } + if (!api_.NotifyIcon(NIM_ADD, ¬ification_data_)) { + available_ = false; + if (own_new_icon && icon != nullptr) { + if (!api_.DestroyIconHandle(icon)) { + return Failure("tray-add-rollback-failed", + "Windows could not add or release the tray icon."); + } + } + notification_data_.hIcon = icon_; + return Failure("tray-add-failed", + "Windows could not add the BusyMax notification icon."); + } + + notification_data_.uVersion = NOTIFYICON_VERSION_4; + if (!api_.NotifyIcon(NIM_SETVERSION, ¬ification_data_)) { + const bool deleted = api_.NotifyIcon(NIM_DELETE, ¬ification_data_); + available_ = false; + if (own_new_icon && icon != nullptr) { + if (!api_.DestroyIconHandle(icon)) { + return Failure("tray-version-icon-rollback-failed", + "Windows could not release the rejected tray icon."); + } + } + notification_data_.hIcon = icon_; + return Failure(deleted ? "tray-version-failed" + : "tray-version-rollback-failed", + "Windows rejected the notification icon protocol version."); + } + + HICON previous = icon_; + icon_ = icon; + available_ = true; + FillIdentifier(); + if (own_new_icon && previous != nullptr && previous != icon_ && + !api_.DestroyIconHandle(previous)) { + return Failure("tray-previous-icon-destroy-failed", + "Windows could not release the previous tray icon."); + } + return Success(); +} + +TrayOperationResult TrayIconController::SetIcon(HWND window, + const std::wstring& path) { + if (window == nullptr || path.empty()) { + return Failure("tray-icon-arguments-invalid", + "The tray icon path or window was invalid."); + } + HICON replacement = api_.LoadIconFile( + path.c_str(), GetSystemMetrics(SM_CXSMICON), + GetSystemMetrics(SM_CYSMICON)); + if (replacement == nullptr) { + return Failure("tray-icon-load-failed", + "Windows could not load the BusyMax tray icon."); + } + + if (!available_) { + return Install(window, replacement, true); + } + + NOTIFYICONDATAW modified = notification_data_; + modified.uFlags = NIF_MESSAGE | NIF_ICON; + modified.hIcon = replacement; + if (!tooltip_.empty()) { + modified.uFlags |= NIF_TIP | NIF_SHOWTIP; + } + if (!api_.NotifyIcon(NIM_MODIFY, &modified)) { + if (!api_.DestroyIconHandle(replacement)) { + return Failure("tray-modify-rollback-failed", + "Windows could not replace or release the tray icon."); + } + return Failure("tray-modify-failed", + "Windows could not replace the BusyMax tray icon."); + } + + HICON previous = icon_; + icon_ = replacement; + notification_data_ = modified; + if (previous != nullptr && previous != replacement) { + if (!api_.DestroyIconHandle(previous)) { + return Failure("tray-previous-icon-destroy-failed", + "Windows could not release the previous tray icon."); + } + } + return Success(); +} + +TrayOperationResult TrayIconController::SetTooltip( + const std::wstring& tooltip) { + if (tooltip.size() >= _countof(notification_data_.szTip)) { + return Failure("tray-tooltip-too-long", + "The BusyMax tray tooltip exceeded the Windows limit."); + } + if (!available_) { + tooltip_ = tooltip; + return Success(); + } + + NOTIFYICONDATAW modified = notification_data_; + modified.uFlags = NIF_MESSAGE | NIF_ICON; + modified.szTip[0] = L'\0'; + if (!tooltip.empty()) { + modified.uFlags |= NIF_TIP | NIF_SHOWTIP; + const HRESULT copied = StringCchCopyW( + modified.szTip, _countof(modified.szTip), tooltip.c_str()); + if (FAILED(copied)) { + return Failure("tray-tooltip-copy-failed", + "Windows could not prepare the BusyMax tray tooltip."); + } + } + if (!api_.NotifyIcon(NIM_MODIFY, &modified)) { + return Failure("tray-tooltip-modify-failed", + "Windows could not update the BusyMax tray tooltip."); + } + tooltip_ = tooltip; + notification_data_ = modified; + return Success(); +} + +TrayOperationResult TrayIconController::Recover(HWND window) { + if (icon_ == nullptr || window == nullptr) { + available_ = false; + return Failure("tray-recovery-unavailable", + "No valid tray icon was available for recovery."); + } + available_ = false; + return Install(window, icon_, false); +} + +TrayOperationResult TrayIconController::Destroy() { + bool deleted = true; + if (available_) { + deleted = api_.NotifyIcon(NIM_DELETE, ¬ification_data_); + } + available_ = false; + const bool icon_destroyed = ReleaseIcon(); + notification_data_ = {}; + notification_data_.cbSize = sizeof(notification_data_); + identifier_ = {}; + identifier_.cbSize = sizeof(identifier_); + if (!deleted) { + return Failure("tray-delete-failed", + "Windows could not remove the BusyMax tray icon."); + } + if (!icon_destroyed) { + return Failure("tray-icon-destroy-failed", + "Windows could not release the BusyMax tray icon."); + } + return Success(); +} + +TrayOperationResult TrayIconController::GetBounds(RECT* rect) { + if (!available_ || rect == nullptr) { + return Failure("tray-bounds-unavailable", + "The BusyMax tray icon is not available."); + } + *rect = {}; + if (FAILED(api_.NotifyIconGetRect(&identifier_, rect))) { + return Failure("tray-bounds-query-failed", + "Windows could not locate the BusyMax tray icon."); + } + return Success(); +} + +} // namespace tray_manager diff --git a/third_party/tray_manager/windows/tray_native_api.h b/third_party/tray_manager/windows/tray_native_api.h new file mode 100644 index 00000000..d6069447 --- /dev/null +++ b/third_party/tray_manager/windows/tray_native_api.h @@ -0,0 +1,124 @@ +#ifndef TRAY_MANAGER_TRAY_NATIVE_API_H_ +#define TRAY_MANAGER_TRAY_NATIVE_API_H_ + +#include + +#include + +#include +#include + +namespace tray_manager { + +UINT TrayCallbackEvent(LPARAM value); +UINT TrayCallbackIconId(LPARAM value); + +struct TrayOperationResult { + bool succeeded = false; + std::string error_code; + std::string error_message; +}; + +class TrayNativeApi { + public: + virtual ~TrayNativeApi() = default; + + virtual HICON LoadIconFile(const wchar_t* path, int width, int height) = 0; + virtual bool DestroyIconHandle(HICON icon) = 0; + virtual bool NotifyIcon(DWORD message, NOTIFYICONDATAW* data) = 0; + virtual HRESULT NotifyIconGetRect(const NOTIFYICONIDENTIFIER* identifier, + RECT* rect) = 0; + virtual HMENU CreatePopupMenuHandle() = 0; + virtual bool AppendMenuItem(HMENU menu, + UINT flags, + UINT_PTR id, + const wchar_t* label) = 0; + virtual bool DestroyMenuHandle(HMENU menu) = 0; + virtual bool GetCursorPosition(POINT* point) = 0; + virtual bool SetForegroundWindowHandle(HWND window) = 0; + virtual bool TrackMenu(HMENU menu, int x, int y, HWND window) = 0; + virtual DWORD LastError() const = 0; +}; + +class WindowsTrayNativeApi final : public TrayNativeApi { + public: + HICON LoadIconFile(const wchar_t* path, int width, int height) override; + bool DestroyIconHandle(HICON icon) override; + bool NotifyIcon(DWORD message, NOTIFYICONDATAW* data) override; + HRESULT NotifyIconGetRect(const NOTIFYICONIDENTIFIER* identifier, + RECT* rect) override; + HMENU CreatePopupMenuHandle() override; + bool AppendMenuItem(HMENU menu, + UINT flags, + UINT_PTR id, + const wchar_t* label) override; + bool DestroyMenuHandle(HMENU menu) override; + bool GetCursorPosition(POINT* point) override; + bool SetForegroundWindowHandle(HWND window) override; + bool TrackMenu(HMENU menu, int x, int y, HWND window) override; + DWORD LastError() const override; +}; + +enum class TrayMenuItemType { kNormal, kSeparator, kCheckbox, kSubmenu }; + +struct TrayMenuItem { + int id = 0; + TrayMenuItemType type = TrayMenuItemType::kNormal; + std::wstring label; + bool disabled = false; + bool checked = false; + std::vector children; +}; + +class TrayMenuController { + public: + explicit TrayMenuController(TrayNativeApi& api) : api_(api) {} + ~TrayMenuController(); + + TrayOperationResult SetItems(const std::vector& items); + TrayOperationResult Show(HWND window, bool bring_to_front); + bool available() const { return menu_ != nullptr; } + + private: + bool Build(HMENU menu, const std::vector& items, + std::string* error); + + TrayNativeApi& api_; + HMENU menu_ = nullptr; +}; + +class TrayIconController { + public: + explicit TrayIconController(TrayNativeApi& api); + ~TrayIconController(); + + TrayIconController(const TrayIconController&) = delete; + TrayIconController& operator=(const TrayIconController&) = delete; + + bool available() const { return available_; } + const std::wstring& tooltip() const { return tooltip_; } + + TrayOperationResult SetIcon(HWND window, const std::wstring& path); + TrayOperationResult SetTooltip(const std::wstring& tooltip); + TrayOperationResult Recover(HWND window); + TrayOperationResult Destroy(); + TrayOperationResult GetBounds(RECT* rect); + + private: + TrayOperationResult Install(HWND window, HICON icon, bool own_new_icon); + TrayOperationResult Failure(const char* code, const char* message) const; + bool FillNotificationData(HWND window, HICON icon); + void FillIdentifier(); + bool ReleaseIcon(); + + TrayNativeApi& api_; + NOTIFYICONDATAW notification_data_{}; + NOTIFYICONIDENTIFIER identifier_{}; + HICON icon_ = nullptr; + std::wstring tooltip_; + bool available_ = false; +}; + +} // namespace tray_manager + +#endif // TRAY_MANAGER_TRAY_NATIVE_API_H_ diff --git a/tools/windows/build_release.ps1 b/tools/windows/build_release.ps1 index 2e50410f..8710e1ba 100644 --- a/tools/windows/build_release.ps1 +++ b/tools/windows/build_release.ps1 @@ -8,6 +8,23 @@ param( $identity = & "$PSScriptRoot/validate_release_config.ps1" ` -ConfigPath $ConfigPath -Ci:$Ci $config = Get-BusyMaxStoreConfig -Path $ConfigPath +$pesterModule = Get-Module -ListAvailable Pester | + Sort-Object Version -Descending | Select-Object -First 1 +if ($null -eq $pesterModule -or $pesterModule.Version -lt [version]'5.0.0') { + throw 'Pester 5 is required to run the Windows packaging contract tests.' +} +Import-Module Pester -MinimumVersion 5.0.0 +New-Item -ItemType Directory -Force -Path 'build\windows\test-results' | Out-Null +$pester = Invoke-Pester -Path 'tools\windows\tests' -PassThru -Output Detailed +[pscustomobject]@{ + Result = [string]$pester.Result + PassedCount = $pester.PassedCount + FailedCount = $pester.FailedCount + SkippedCount = $pester.SkippedCount +} | ConvertTo-Json | Set-Content ` + -LiteralPath 'build\windows\test-results\pester-summary.json' ` + -Encoding utf8NoBOM +if ($pester.FailedCount -ne 0) { throw 'Windows packaging contract tests failed.' } & flutter pub get if ($LASTEXITCODE -ne 0) { throw 'Dependency resolution failed.' } @@ -21,7 +38,6 @@ if ($LASTEXITCODE -ne 0) { throw 'Generated files are not committed.' } if ($LASTEXITCODE -ne 0) { throw 'Formatting check failed.' } & flutter analyze if ($LASTEXITCODE -ne 0) { throw 'Flutter analysis failed.' } -New-Item -ItemType Directory -Force -Path 'build\windows\test-results' | Out-Null & flutter test --machine | Tee-Object ` -FilePath 'build\windows\test-results\flutter-tests.jsonl' if ($LASTEXITCODE -ne 0) { throw 'Flutter tests failed.' } @@ -45,6 +61,8 @@ if (-not [string]::IsNullOrWhiteSpace($config.googleOAuthClientSecret)) { } & flutter build windows --release -t lib/main_windows.dart @defines if ($LASTEXITCODE -ne 0) { throw 'Flutter Windows release build failed.' } +& "$PSScriptRoot/run_native_tests.ps1" +if ($LASTEXITCODE -ne 0) { throw 'Windows native tests failed.' } $metadata = & "$PSScriptRoot/package_store.ps1" ` -ConfigPath $ConfigPath -Ci:$Ci diff --git a/tools/windows/common.ps1 b/tools/windows/common.ps1 index 91aeeea4..94704651 100644 --- a/tools/windows/common.ps1 +++ b/tools/windows/common.ps1 @@ -47,43 +47,105 @@ function Assert-BusyMaxHttpsUrl { } } +function Assert-BusyMaxCertificatePublisher { + param( + [Parameter(Mandatory)][string]$CertificateSubject, + [Parameter(Mandatory)][string]$ManifestPublisher + ) + if ($CertificateSubject -cne $ManifestPublisher) { + throw "Certificate subject '$CertificateSubject' does not exactly match AppxManifest Publisher '$ManifestPublisher' in LocalTestSigning mode." + } +} + function Assert-BusyMaxStoreConfig { - param([Parameter(Mandatory)]$Config, [switch]$Ci) + param( + [Parameter(Mandatory)]$Config, + [Parameter(Mandatory)] + [ValidateSet('ProductionStore', 'CiNonProduction', 'LocalTestSigning')] + [string]$Mode + ) foreach ($field in @( 'identityName', 'publisher', 'publisherDisplayName', 'msixVersion', 'privacyPolicyUrl', 'supportUrl', 'googleOAuthClientId', 'microsoftOAuthClientId', 'microsoftOAuthAuthorityTenant', 'homepageUrl')) { if (Test-BusyMaxPlaceholder -Value ([string]$Config.$field)) { - throw "Store configuration field '$field' is missing or still a placeholder." + throw "Store configuration field '$field' is missing or still a placeholder in $Mode mode." } } - if ($Ci) { - if ($Config.production -ne $false) { - throw 'A CI package must use an explicitly non-production identity.' + switch ($Mode) { + 'ProductionStore' { + if ($Config.production -ne $true) { + throw "Field 'production' must be true in ProductionStore mode." + } + if ([string]$Config.identityName -ceq 'BusyStack.BusyMax.CI' -or + [string]$Config.publisher -ceq 'CN=BusyMax CI Package') { + throw "Field 'identityName' and 'publisher' must not use the committed CI identity in ProductionStore mode." + } + } + 'LocalTestSigning' { + if ($Config.production -ne $true) { + throw "Field 'production' must be true in LocalTestSigning mode because the test certificate must match the owner's rendered Store identity." + } + if ([string]$Config.identityName -ceq 'BusyStack.BusyMax.CI' -or + [string]$Config.publisher -ceq 'CN=BusyMax CI Package') { + throw "Field 'identityName' and 'publisher' must use the owner's Store identity, not the committed CI identity, in LocalTestSigning mode." + } } - } elseif ($Config.production -ne $true) { - throw 'A production Store build requires production=true.' + 'CiNonProduction' { + if ($Config.production -ne $false) { + throw "Field 'production' must be false in CiNonProduction mode. Production configurations cannot be used by CI." + } + $expected = [ordered]@{ + identityName = 'BusyStack.BusyMax.CI' + publisher = 'CN=BusyMax CI Package' + publisherDisplayName = 'BusyMax CI' + msixVersion = '1.0.0.0' + privacyPolicyUrl = 'https://busystack.org/privacy' + supportUrl = 'https://busystack.org/support' + homepageUrl = 'https://busystack.org' + googleOAuthClientId = 'busymax-ci-google-client-id' + microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' + microsoftOAuthAuthorityTenant = 'common' + } + foreach ($entry in $expected.GetEnumerator()) { + $property = $Config.PSObject.Properties[$entry.Key] + if ($null -eq $property -or + [string]$property.Value -cne [string]$entry.Value) { + throw "Field '$($entry.Key)' must equal the committed nonproduction test value in CiNonProduction mode." + } + } + } + } + if ($Config.fakeData -eq $true) { + throw "Field 'fakeData' must be false in $Mode mode." } - if ($Config.fakeData -eq $true) { throw 'Fake-data mode is prohibited for Store packages.' } if ($Config.developmentBackend -eq $true) { - throw 'A development backend is prohibited for Store packages.' + throw "Field 'developmentBackend' must be false in $Mode mode." } if ($Config.identityName -notmatch '^[A-Za-z0-9.-]{3,50}$') { - throw 'identityName must be a valid Partner Center package identity name.' + throw "Field 'identityName' must be a valid Partner Center package identity name in $Mode mode." } - if ($Config.publisher -notmatch '^CN=') { - throw 'publisher must be the exact Partner Center Publisher CN.' + if ($Config.publisher -notmatch '^CN=.+') { + throw "Field 'publisher' must be the exact Partner Center Publisher CN in $Mode mode." } if (-not [string]::IsNullOrWhiteSpace($Config.googleOAuthClientSecret) -and (Test-BusyMaxPlaceholder -Value ([string]$Config.googleOAuthClientSecret))) { - throw 'googleOAuthClientSecret is still a placeholder.' + throw "Field 'googleOAuthClientSecret' is still a placeholder in $Mode mode." + } + try { + Assert-BusyMaxMsixVersion -Version ([string]$Config.msixVersion) ` + -PreviousVersion ([string]$Config.previousMsixVersion) + } catch { + throw "Field 'msixVersion' is invalid in $Mode mode: $($_.Exception.Message)" + } + foreach ($urlField in @('privacyPolicyUrl', 'supportUrl', 'homepageUrl')) { + try { + Assert-BusyMaxHttpsUrl -Name $urlField -Value $Config.$urlField + } catch { + throw "Field '$urlField' is invalid in $Mode mode: $($_.Exception.Message)" + } } - Assert-BusyMaxMsixVersion -Version ([string]$Config.msixVersion) ` - -PreviousVersion ([string]$Config.previousMsixVersion) - Assert-BusyMaxHttpsUrl -Name 'privacyPolicyUrl' -Value $Config.privacyPolicyUrl - Assert-BusyMaxHttpsUrl -Name 'supportUrl' -Value $Config.supportUrl - Assert-BusyMaxHttpsUrl -Name 'homepageUrl' -Value $Config.homepageUrl } function Get-BusyMaxPackageFamilyName { @@ -126,7 +188,7 @@ namespace BusyMax { return [BusyMax.PackageIdentity]::FamilyName($Name, $Publisher) } -function Get-BusyMaxResourceXml { +function Get-BusyMaxResourceLanguages { $locales = Get-ChildItem -LiteralPath 'lib/l10n' -Filter 'app_*.arb' | ForEach-Object { $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json @@ -148,7 +210,13 @@ function Get-BusyMaxResourceXml { } } | Sort-Object -Unique if (-not $locales) { throw 'No BusyMax ARB locales were found.' } - return ($locales | ForEach-Object { " " }) -join "`r`n" + return @($locales) +} + +function Get-BusyMaxResourceXml { + return (Get-BusyMaxResourceLanguages | ForEach-Object { + " " + }) -join "`r`n" } function Copy-BusyMaxVCRuntime { diff --git a/tools/windows/create_test_certificate.ps1 b/tools/windows/create_test_certificate.ps1 index 4dbbb334..2af34fee 100644 --- a/tools/windows/create_test_certificate.ps1 +++ b/tools/windows/create_test_certificate.ps1 @@ -5,7 +5,7 @@ param( . "$PSScriptRoot/common.ps1" $config = Get-BusyMaxStoreConfig -Path $ConfigPath -Assert-BusyMaxStoreConfig -Config $config -Ci +Assert-BusyMaxStoreConfig -Config $config -Mode LocalTestSigning New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null $certificate = New-SelfSignedCertificate ` -Type Custom ` @@ -14,6 +14,14 @@ $certificate = New-SelfSignedCertificate ` -FriendlyName 'BusyMax local MSIX test certificate' ` -CertStoreLocation 'Cert:\CurrentUser\My' ` -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') +try { + Assert-BusyMaxCertificatePublisher ` + -CertificateSubject ([string]$certificate.Subject) ` + -ManifestPublisher ([string]$config.publisher) +} catch { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force + throw +} $password = Read-Host 'Choose a local PFX password' -AsSecureString $pfx = Join-Path $OutputDirectory 'busymax-test-only.pfx' $cer = Join-Path $OutputDirectory 'busymax-test-only.cer' diff --git a/tools/windows/install_test_package.ps1 b/tools/windows/install_test_package.ps1 index 0e883d08..d94472f0 100644 --- a/tools/windows/install_test_package.ps1 +++ b/tools/windows/install_test_package.ps1 @@ -7,17 +7,45 @@ param( . "$PSScriptRoot/common.ps1" $prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" $config = Get-BusyMaxStoreConfig -Path $ConfigPath -Assert-BusyMaxStoreConfig -Config $config -Ci +Assert-BusyMaxStoreConfig -Config $config -Mode LocalTestSigning $password = Read-Host 'Local PFX password' -AsSecureString $certificate = Import-PfxCertificate -FilePath $PfxPath ` -CertStoreLocation 'Cert:\CurrentUser\My' -Password $password +$certificateSubject = [string]$certificate.Subject +Assert-BusyMaxCertificatePublisher -CertificateSubject $certificateSubject ` + -ManifestPublisher ([string]$config.publisher) +# The exact package manifest is checked below after a clean unpack. +$inspection = Join-Path ([IO.Path]::GetTempPath()) ` + "busymax-install-inspection-$([guid]::NewGuid().ToString('N'))" +& (Join-Path $prerequisites.WindowsSdkBin 'makeappx.exe') unpack /p $PackagePath /d $inspection /o +if ($LASTEXITCODE -ne 0) { + if (Test-Path -LiteralPath $inspection) { + Remove-Item -LiteralPath $inspection -Recurse -Force + } + throw 'Could not unpack the MSIX before local test signing.' +} +try { + [xml]$manifest = Get-Content ` + -LiteralPath (Join-Path $inspection 'AppxManifest.xml') -Raw +} finally { + Remove-Item -LiteralPath $inspection -Recurse -Force +} +$manifestPublisher = [string]$manifest.Package.Identity.Publisher +Assert-BusyMaxCertificatePublisher -CertificateSubject $certificateSubject ` + -ManifestPublisher $manifestPublisher +if ([string]$manifest.Package.Identity.Name -cne [string]$config.identityName) { + throw "Packed manifest identity '$($manifest.Package.Identity.Name)' does not match configuration field 'identityName' in LocalTestSigning mode." +} +if ([string]$manifest.Package.Identity.Version -cne [string]$config.msixVersion) { + throw "Packed manifest version '$($manifest.Package.Identity.Version)' does not match configuration field 'msixVersion' in LocalTestSigning mode." +} $testPackage = Join-Path (Split-Path -Parent $PackagePath) 'BusyMax-test-signed.msix' Copy-Item -LiteralPath $PackagePath -Destination $testPackage -Force $signTool = Join-Path $prerequisites.WindowsSdkBin 'signtool.exe' & $signTool sign /fd SHA256 /sha1 $certificate.Thumbprint /s My $testPackage if ($LASTEXITCODE -ne 0) { throw 'Local test signing failed.' } $trustedCertificate = Import-Certificate ` - -FilePath ($PfxPath -replace '\.pfx$', '.cer') ` + -FilePath ([IO.Path]::ChangeExtension($PfxPath, '.cer')) ` -CertStoreLocation 'Cert:\CurrentUser\TrustedPeople' Add-AppxPackage -Path $testPackage Write-Host 'Installed local test package. It is not a Store artifact.' diff --git a/tools/windows/package_store.ps1 b/tools/windows/package_store.ps1 index 77f148d1..97645c2c 100644 --- a/tools/windows/package_store.ps1 +++ b/tools/windows/package_store.ps1 @@ -8,7 +8,8 @@ param( . "$PSScriptRoot/common.ps1" $prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" $config = Get-BusyMaxStoreConfig -Path $ConfigPath -Assert-BusyMaxStoreConfig -Config $config -Ci:$Ci +$mode = if ($Ci) { 'CiNonProduction' } else { 'ProductionStore' } +Assert-BusyMaxStoreConfig -Config $config -Mode $mode if (-not (Test-Path -LiteralPath "$ReleasePath/busymax.exe")) { throw "Windows release output was not found: $ReleasePath" } @@ -25,9 +26,13 @@ if (-not $resolvedOutput.StartsWith( New-Item -ItemType Directory -Force -Path $resolvedOutput | Out-Null $ownerMarker = Join-Path $resolvedOutput '.busymax-store-output' $staging = Join-Path $OutputDirectory 'staging' -if ((Test-Path -LiteralPath $staging) -and - -not (Test-Path -LiteralPath $ownerMarker -PathType Leaf)) { - throw "Refusing to replace unowned staging directory: $staging" +$unpacked = Join-Path $OutputDirectory 'unpacked-final-msix' +$package = Join-Path $OutputDirectory "BusyMax-$($config.msixVersion)-x64.msix" +foreach ($managedPath in @($staging, $unpacked, $package)) { + if ((Test-Path -LiteralPath $managedPath) -and + -not (Test-Path -LiteralPath $ownerMarker -PathType Leaf)) { + throw "Refusing to replace unowned Windows package output: $managedPath" + } } if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force @@ -36,8 +41,22 @@ Set-Content -LiteralPath $ownerMarker -Value 'busymax-windows-store-output-v1' ` -Encoding ascii New-Item -ItemType Directory -Force -Path $staging | Out-Null Copy-Item -LiteralPath "$ReleasePath/busymax.exe" -Destination $staging -Get-ChildItem -LiteralPath $ReleasePath -File | - Where-Object { $_.Extension -in @('.dll', '.dat') } | +$runtimeFiles = @( + 'flutter_windows.dll', 'sqlite3.dll', + 'connectivity_plus_plugin.dll', 'file_selector_windows_plugin.dll', + 'flutter_local_notifications_windows.dll', + 'flutter_secure_storage_windows_plugin.dll', + 'flutter_timezone_plugin.dll', 'system_theme_plugin.dll', + 'tray_manager_plugin.dll', 'url_launcher_windows_plugin.dll' +) +foreach ($runtimeFile in $runtimeFiles) { + $source = Join-Path $ReleasePath $runtimeFile + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Required Windows runtime file was not built: $runtimeFile" + } + Copy-Item -LiteralPath $source -Destination $staging +} +Get-ChildItem -LiteralPath $ReleasePath -File -Filter '*.dat' | Copy-Item -Destination $staging Copy-BusyMaxVCRuntime -VisualStudioPath $prerequisites.VisualStudio ` -Destination $staging @@ -48,12 +67,39 @@ Copy-Item -Path 'windows\runner\resources\msix\*' -Destination $assets & "$PSScriptRoot/render_manifest.ps1" -ConfigPath $ConfigPath ` -OutputPath (Join-Path $staging 'AppxManifest.xml') ` -MaximumTestedVersion $prerequisites.WindowsSdk -Ci:$Ci -& "$PSScriptRoot/validate_package_contents.ps1" -StagingPath $staging -$package = Join-Path $OutputDirectory "BusyMax-$($config.msixVersion)-x64.msix" +$stagingInventoryPath = Join-Path $OutputDirectory 'staging-inventory.json' +$stagingInventory = @(& "$PSScriptRoot/validate_package_contents.ps1" ` + -PackageRoot $staging -InventoryOutputPath $stagingInventoryPath) if (Test-Path -LiteralPath $package) { Remove-Item -LiteralPath $package -Force } $makeAppx = Join-Path $prerequisites.WindowsSdkBin 'makeappx.exe' & $makeAppx pack /d $staging /p $package /o if ($LASTEXITCODE -ne 0) { throw 'MakeAppx failed.' } +if (Test-Path -LiteralPath $unpacked) { + Remove-Item -LiteralPath $unpacked -Recurse -Force +} +& $makeAppx unpack /p $package /d $unpacked /o +if ($LASTEXITCODE -ne 0) { throw 'MakeAppx could not unpack the final MSIX.' } +$finalInventoryPath = Join-Path $OutputDirectory 'final-msix-inventory.json' +$finalInventory = @(& "$PSScriptRoot/validate_package_contents.ps1" ` + -PackageRoot $unpacked -InventoryOutputPath $finalInventoryPath) +$packageMetadataFiles = @('AppxBlockMap.xml', '[Content_Types].xml') +$expectedInventory = @($stagingInventory | Where-Object { + $_ -is [pscustomobject] +}) +$actualInventory = @($finalInventory | Where-Object { + $_ -is [pscustomobject] -and $_.Path -notin $packageMetadataFiles +}) +$expectedLines = @($expectedInventory | ForEach-Object { + "$($_.Path)|$($_.Length)|$($_.SHA256)" +}) +$actualLines = @($actualInventory | ForEach-Object { + "$($_.Path)|$($_.Length)|$($_.SHA256)" +}) +$difference = Compare-Object -ReferenceObject $expectedLines ` + -DifferenceObject $actualLines +if ($difference) { + throw "The exact packed MSIX inventory differs from staging: $($difference.InputObject -join ', ')" +} $hash = Get-FileHash -LiteralPath $package -Algorithm SHA256 [pscustomobject]@{ Package = (Resolve-Path $package).Path @@ -62,4 +108,5 @@ $hash = Get-FileHash -LiteralPath $package -Algorithm SHA256 MinimumOS = '10.0.26100.0' MaximumTestedOS = $prerequisites.WindowsSdk SHA256 = $hash.Hash + Inventory = (Resolve-Path $finalInventoryPath).Path } diff --git a/tools/windows/render_manifest.ps1 b/tools/windows/render_manifest.ps1 index 77d0187f..970a34b8 100644 --- a/tools/windows/render_manifest.ps1 +++ b/tools/windows/render_manifest.ps1 @@ -7,7 +7,8 @@ param( . "$PSScriptRoot/common.ps1" $config = Get-BusyMaxStoreConfig -Path $ConfigPath -Assert-BusyMaxStoreConfig -Config $config -Ci:$Ci +$mode = if ($Ci) { 'CiNonProduction' } else { 'ProductionStore' } +Assert-BusyMaxStoreConfig -Config $config -Mode $mode if ([version]$MaximumTestedVersion -lt [version]'10.0.26100.0') { throw 'MaximumTestedVersion must be the Windows 11 24H2 SDK or newer.' } diff --git a/tools/windows/run_native_tests.ps1 b/tools/windows/run_native_tests.ps1 new file mode 100644 index 00000000..aa1a6df1 --- /dev/null +++ b/tools/windows/run_native_tests.ps1 @@ -0,0 +1,24 @@ +param( + [string]$BuildDirectory = 'build\windows\x64', + [string]$Configuration = 'Release' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $IsWindows) { throw 'BusyMax native tests require Windows.' } +if (-not (Test-Path -LiteralPath (Join-Path $BuildDirectory 'CMakeCache.txt'))) { + throw "The configured Windows CMake build was not found: $BuildDirectory" +} +& cmake -S windows -B $BuildDirectory ` + -Dinclude_flutter_timezone_tests=ON ` + -Dinclude_tray_manager_tests=ON +if ($LASTEXITCODE -ne 0) { throw 'BusyMax native test configuration failed.' } +& cmake --build $BuildDirectory --config $Configuration ` + --target busymax_runner_native_test flutter_timezone_test tray_manager_test +if ($LASTEXITCODE -ne 0) { throw 'BusyMax native test compilation failed.' } +$resultDirectory = 'build\windows\test-results' +New-Item -ItemType Directory -Force -Path $resultDirectory | Out-Null +& ctest --test-dir $BuildDirectory -C $Configuration --output-on-failure ` + --output-junit (Join-Path $resultDirectory 'native-tests.xml') +if ($LASTEXITCODE -ne 0) { throw 'BusyMax native tests failed.' } diff --git a/tools/windows/run_wack.ps1 b/tools/windows/run_wack.ps1 index d2a95c34..16ab3891 100644 --- a/tools/windows/run_wack.ps1 +++ b/tools/windows/run_wack.ps1 @@ -1,8 +1,19 @@ param( [Parameter(Mandatory)][string]$PackagePath, - [string]$ReportPath = 'build\windows\store\wack-report.xml' + [string]$ReportPath = 'build\windows\store\wack-report.xml', + [string]$WarningDispositionPath = '', + [string]$SummaryPath = 'build\windows\store\wack-summary.json' ) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot/wack_report.ps1" + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "WACK package does not exist: $PackagePath" +} +$packageHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + & "$PSScriptRoot/check_prerequisites.ps1" -RequireWack | Out-Host $appCert = Join-Path ${env:ProgramFiles(x86)} ` 'Windows Kits\10\App Certification Kit\appcert.exe' @@ -11,6 +22,44 @@ New-Item -ItemType Directory -Force -Path $directory | Out-Null & $appCert reset if ($LASTEXITCODE -ne 0) { throw 'Windows App Certification Kit reset failed.' } & $appCert test -appxpackagepath $PackagePath -reportoutputpath $ReportPath -if ($LASTEXITCODE -ne 0) { throw 'Windows App Certification Kit failed.' } -if (-not (Test-Path -LiteralPath $ReportPath)) { throw 'WACK did not emit a report.' } -Write-Host "WACK report: $ReportPath" +$appCertExitCode = $LASTEXITCODE +$analysis = Read-BusyMaxWackReport -ReportPath $ReportPath +$warnings = @($analysis.Warnings) +if ($warnings.Count -gt 0) { + Write-Host 'WACK warnings:' + $warnings | ForEach-Object { Write-Host " - $_" } +} +Assert-BusyMaxWackWarningDispositions -Warnings $warnings ` + -WarningDispositionPath $WarningDispositionPath + +$summary = [pscustomobject]@{ + Package = [IO.Path]::GetFullPath($PackagePath) + PackageSHA256 = $packageHash + Report = [IO.Path]::GetFullPath($ReportPath) + OverallResult = $analysis.OverallResult + AppCertExitCode = $appCertExitCode + ResultCount = $analysis.ResultCount + FailureCount = $analysis.FailureNames.Count + Warnings = $warnings + WarningDispositionPath = if ($warnings.Count -gt 0) { + [IO.Path]::GetFullPath($WarningDispositionPath) + } else { $null } +} +$summaryDirectory = Split-Path -Parent $SummaryPath +if (-not [string]::IsNullOrWhiteSpace($summaryDirectory)) { + New-Item -ItemType Directory -Force -Path $summaryDirectory | Out-Null +} +$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $SummaryPath ` + -Encoding utf8NoBOM + +if ($analysis.FailureNames.Count -gt 0) { + throw "WACK reported failed tests: $($analysis.FailureNames -join ', ')" +} +if ($analysis.OverallResult -notin @('PASS', 'PASSED', 'PASS WITH WARNINGS', 'PASSED WITH WARNINGS')) { + throw "WACK overall result is not passing: $($analysis.OverallResult)" +} +if ($appCertExitCode -ne 0) { + throw "WACK process exited with code $appCertExitCode despite producing a report." +} +Write-Host "WACK report passed: $ReportPath" +$summary diff --git a/tools/windows/tests/store_config.Tests.ps1 b/tools/windows/tests/store_config.Tests.ps1 new file mode 100644 index 00000000..7231a437 --- /dev/null +++ b/tools/windows/tests/store_config.Tests.ps1 @@ -0,0 +1,113 @@ +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path (Split-Path -Parent $here) 'common.ps1') + +function New-ValidConfig { + param([bool]$Production = $true) + return [pscustomobject]@{ + production = $Production + identityName = 'BusyStack.BusyMax.Owner' + publisher = 'CN=BusyMax Owner' + publisherDisplayName = 'BusyMax Owner' + msixVersion = '1.2.3.0' + previousMsixVersion = '1.2.2.0' + privacyPolicyUrl = 'https://busystack.org/privacy' + supportUrl = 'https://busystack.org/support' + homepageUrl = 'https://busystack.org' + googleOAuthClientId = 'google-owner-client-id' + googleOAuthClientSecret = '' + microsoftOAuthClientId = 'microsoft-owner-client-id' + microsoftOAuthAuthorityTenant = 'common' + fakeData = $false + developmentBackend = $false + } +} + +function New-CiConfig { + $config = New-ValidConfig -Production $false + $config.identityName = 'BusyStack.BusyMax.CI' + $config.publisher = 'CN=BusyMax CI Package' + $config.publisherDisplayName = 'BusyMax CI' + $config.msixVersion = '1.0.0.0' + $config.previousMsixVersion = '' + $config.googleOAuthClientId = 'busymax-ci-google-client-id' + $config.microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' + return $config +} + +Describe 'BusyMax Store configuration validation modes' { + It 'accepts a production Store configuration only in ProductionStore mode' { + $config = New-ValidConfig + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Not -Throw + { Assert-BusyMaxStoreConfig -Config $config -Mode CiNonProduction } | + Should -Throw + } + + It 'accepts the exact committed CI identity only in CiNonProduction mode' { + $config = New-CiConfig + { Assert-BusyMaxStoreConfig -Config $config -Mode CiNonProduction } | + Should -Not -Throw + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Throw + { Assert-BusyMaxStoreConfig -Config $config -Mode LocalTestSigning } | + Should -Throw + $config.identityName = 'BusyStack.BusyMax.NotTheCommittedCiIdentity' + { Assert-BusyMaxStoreConfig -Config $config -Mode CiNonProduction } | + Should -Throw + } + + It 'accepts owner production identity in LocalTestSigning mode' { + { Assert-BusyMaxStoreConfig -Config (New-ValidConfig) ` + -Mode LocalTestSigning } | Should -Not -Throw + } + + It 'rejects placeholders in every mode' { + $production = New-ValidConfig + $production.identityName = 'REPLACE_WITH_IDENTITY' + { Assert-BusyMaxStoreConfig -Config $production -Mode ProductionStore } | + Should -Throw + $local = New-ValidConfig + $local.identityName = 'REPLACE_WITH_IDENTITY' + { Assert-BusyMaxStoreConfig -Config $local -Mode LocalTestSigning } | + Should -Throw + $ci = New-CiConfig + $ci.supportUrl = 'https://example.com/support' + { Assert-BusyMaxStoreConfig -Config $ci -Mode CiNonProduction } | + Should -Throw + } + + It 'rejects malformed and prohibited MSIX versions' { + foreach ($invalid in @('1.2.3', '0.2.3.0', '1.2.3.4')) { + $config = New-ValidConfig + $config.msixVersion = $invalid + $config.previousMsixVersion = '' + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Throw -ExpectedMessage "*msixVersion*ProductionStore*" + } + } + + It 'rejects non-HTTPS production links' { + $config = New-ValidConfig + $config.supportUrl = 'http://busystack.org/support' + { Assert-BusyMaxStoreConfig -Config $config -Mode ProductionStore } | + Should -Throw -ExpectedMessage "*supportUrl*ProductionStore*" + } + + It 'rejects fake data and development backends' { + $fake = New-ValidConfig + $fake.fakeData = $true + { Assert-BusyMaxStoreConfig -Config $fake -Mode ProductionStore } | + Should -Throw + $development = New-ValidConfig + $development.developmentBackend = $true + { Assert-BusyMaxStoreConfig -Config $development -Mode ProductionStore } | + Should -Throw + } + + It 'rejects a mismatched certificate publisher' { + { Assert-BusyMaxCertificatePublisher -CertificateSubject 'CN=Other' ` + -ManifestPublisher 'CN=BusyMax Owner' } | Should -Throw + { Assert-BusyMaxCertificatePublisher -CertificateSubject 'CN=BusyMax Owner' ` + -ManifestPublisher 'CN=BusyMax Owner' } | Should -Not -Throw + } +} diff --git a/tools/windows/tests/wack_report.Tests.ps1 b/tools/windows/tests/wack_report.Tests.ps1 new file mode 100644 index 00000000..6aeba567 --- /dev/null +++ b/tools/windows/tests/wack_report.Tests.ps1 @@ -0,0 +1,61 @@ +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path (Split-Path -Parent $here) 'wack_report.ps1') + +Describe 'BusyMax WACK report parsing' { + BeforeEach { + $report = Join-Path $TestDrive 'report.xml' + $dispositions = Join-Path $TestDrive 'dispositions.json' + } + + It 'accepts result elements and attribute-based test results' { + @' + + PASS + + +'@ | Set-Content -LiteralPath $report -Encoding utf8NoBOM + $parsed = Read-BusyMaxWackReport -ReportPath $report + $parsed.OverallResult | Should -Be 'PASS' + $parsed.ResultCount | Should -Be 2 + @($parsed.FailureNames).Count | Should -Be 0 + } + + It 'rejects every failed test even when the report overall value passes' { + @' + + + +'@ | Set-Content -LiteralPath $report -Encoding utf8NoBOM + $parsed = Read-BusyMaxWackReport -ReportPath $report + $parsed.FailureNames | Should -Contain 'Launch' + } + + It 'requires a non-placeholder disposition for every warning' { + @' + + WARNING + +'@ | Set-Content -LiteralPath $report -Encoding utf8NoBOM + $parsed = Read-BusyMaxWackReport -ReportPath $report + { Assert-BusyMaxWackWarningDispositions -Warnings $parsed.Warnings } | + Should -Throw + '{"Optional API":"TODO"}' | Set-Content -LiteralPath $dispositions ` + -Encoding utf8NoBOM + { Assert-BusyMaxWackWarningDispositions -Warnings $parsed.Warnings ` + -WarningDispositionPath $dispositions } | Should -Throw + '{"Optional API":"Reviewed; the optional API is not called by BusyMax."}' | + Set-Content -LiteralPath $dispositions -Encoding utf8NoBOM + { Assert-BusyMaxWackWarningDispositions -Warnings $parsed.Warnings ` + -WarningDispositionPath $dispositions } | Should -Not -Throw + } + + It 'rejects missing, empty, malformed, and result-free reports' { + { Read-BusyMaxWackReport -ReportPath $report } | Should -Throw + '' | Set-Content -LiteralPath $report -NoNewline + { Read-BusyMaxWackReport -ReportPath $report } | Should -Throw + '' | Set-Content -LiteralPath $report + { Read-BusyMaxWackReport -ReportPath $report } | Should -Throw + } +} diff --git a/tools/windows/validate_manifest.ps1 b/tools/windows/validate_manifest.ps1 index 3c7aae82..3ed88530 100644 --- a/tools/windows/validate_manifest.ps1 +++ b/tools/windows/validate_manifest.ps1 @@ -1,9 +1,12 @@ param([Parameter(Mandatory)][string]$ManifestPath) +. "$PSScriptRoot/common.ps1" + if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { throw "Manifest not found: $ManifestPath" } -[xml]$manifest = Get-Content -LiteralPath $ManifestPath -Raw +$manifestText = Get-Content -LiteralPath $ManifestPath -Raw +[xml]$manifest = $manifestText $manager = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) $manager.AddNamespace('f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') $manager.AddNamespace('uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10') @@ -20,10 +23,45 @@ if ($null -eq $identity -or [string]::IsNullOrWhiteSpace($identity.Name) -or throw 'MSIX identity values are incomplete.' } if ($identity.ProcessorArchitecture -ne 'x64') { throw 'MSIX architecture must be x64.' } +if ([string]$identity.Publisher -notmatch '^CN=.+') { + throw 'MSIX Publisher must be the exact Partner Center Publisher CN.' +} +if ([string]$identity.Version -notmatch '^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$') { + throw 'MSIX version must have four numeric components, start at 1 or newer, and end in 0.' +} +$versionParts = @($Matches[1], $Matches[2], $Matches[3], $Matches[4]) | + ForEach-Object { [uint64]$_ } +if ($versionParts[0] -lt 1 -or $versionParts[3] -ne 0 -or + @($versionParts | Where-Object { $_ -gt 65535 }).Count -gt 0) { + throw 'MSIX version must have components from 0 through 65535, start at 1 or newer, and end in 0.' +} $target = $manifest.SelectSingleNode('/f:Package/f:Dependencies/f:TargetDeviceFamily', $manager) if ($target.Name -ne 'Windows.Desktop' -or $target.MinVersion -ne '10.0.26100.0') { throw 'MSIX must target Windows.Desktop 10.0.26100.0 or newer.' } +$resources = @($manifest.SelectNodes('/f:Package/f:Resources/f:Resource', $manager)) +if ($resources.Count -eq 0) { throw 'MSIX resource language declarations are empty.' } +$resourceLanguages = @($resources | ForEach-Object { [string]$_.Language }) +if (($resourceLanguages | Sort-Object -Unique).Count -ne $resourceLanguages.Count) { + throw 'MSIX resource language declarations contain duplicates.' +} +foreach ($language in $resourceLanguages) { + if ($language -notmatch '^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-[A-Za-z]{2}|-[0-9]{3})?$') { + throw "MSIX resource language is not a valid BCP-47 tag: $language" + } + try { + [Globalization.CultureInfo]::GetCultureInfo($language) | Out-Null + } catch { + throw "MSIX resource language is not recognized by Windows: $language" + } +} +$expectedResourceLanguages = @(Get-BusyMaxResourceLanguages) +$resourceDifference = Compare-Object ` + -ReferenceObject $expectedResourceLanguages ` + -DifferenceObject @($resourceLanguages | Sort-Object) +if ($resourceDifference) { + throw "MSIX resource languages differ from the supported ARB locales: $($resourceDifference.InputObject -join ', ')" +} if ([version]$target.MaxVersionTested -lt [version]$target.MinVersion) { throw 'MaxVersionTested must be at least the minimum Windows version.' } @@ -93,5 +131,7 @@ if (@($capabilities | Where-Object { $_ -notin @('internetClient', 'runFullTrust foreach ($required in @('internetClient', 'runFullTrust')) { if ($required -notin $capabilities) { throw "Missing capability: $required" } } -if ($manifest.InnerText -match '@@[A-Z_]+@@') { throw 'Manifest placeholders remain.' } +if ($manifestText -match '@@[A-Z_]+@@|(?i)REPLACE_WITH|example\.com|placeholder|\bTODO\b|your[-_]') { + throw 'Manifest placeholders remain.' +} Write-Host "Validated manifest: $ManifestPath" diff --git a/tools/windows/validate_package_contents.ps1 b/tools/windows/validate_package_contents.ps1 index 3f511605..a233caea 100644 --- a/tools/windows/validate_package_contents.ps1 +++ b/tools/windows/validate_package_contents.ps1 @@ -1,13 +1,23 @@ -param([Parameter(Mandatory)][string]$StagingPath) +param( + [Parameter(Mandatory)][Alias('StagingPath')][string]$PackageRoot, + [string]$InventoryOutputPath = '' +) -if (-not (Test-Path -LiteralPath "$StagingPath/busymax.exe")) { +if (-not (Test-Path -LiteralPath "$PackageRoot/busymax.exe")) { throw 'Staging directory does not contain busymax.exe.' } foreach ($required in @( - 'flutter_windows.dll', 'sqlite3.dll', 'msvcp140.dll', 'vcruntime140.dll', - 'vcruntime140_1.dll', 'data/flutter_assets/AssetManifest.bin', + 'flutter_windows.dll', 'sqlite3.dll', + 'connectivity_plus_plugin.dll', 'file_selector_windows_plugin.dll', + 'flutter_local_notifications_windows.dll', + 'flutter_secure_storage_windows_plugin.dll', + 'flutter_timezone_plugin.dll', 'system_theme_plugin.dll', + 'tray_manager_plugin.dll', 'url_launcher_windows_plugin.dll', + 'msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll', + 'data/app.so', 'data/icudtl.dat', + 'data/flutter_assets/AssetManifest.bin', 'AppxManifest.xml')) { - if (-not (Test-Path -LiteralPath (Join-Path $StagingPath $required))) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $required))) { throw "Required package file is missing: $required" } } @@ -16,26 +26,89 @@ foreach ($asset in @( 'FileAssociation')) { foreach ($scale in @(100, 125, 150, 200, 400)) { $candidate = "Assets/$asset.scale-$scale.png" - if (-not (Test-Path -LiteralPath (Join-Path $StagingPath $candidate))) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $candidate))) { throw "Required package visual asset is missing: $candidate" } } } -$prohibited = Get-ChildItem -LiteralPath $StagingPath -Recurse -File | +$prohibited = Get-ChildItem -LiteralPath $PackageRoot -Recurse -File | Where-Object { - $relative = [IO.Path]::GetRelativePath($StagingPath, $_.FullName).Replace('\', '/') + $relative = [IO.Path]::GetRelativePath($PackageRoot, $_.FullName).Replace('\', '/') $_.Extension -in @( '.pfx', '.cer', '.log', '.db', '.sqlite', '.dart', '.cc', '.h', '.pdb', - '.so', '.snap', '.AppImage' + '.cpp', '.c', '.hpp', '.cmake', '.ps1', '.jsonl', '.yaml', '.yml', + '.md', '.p12', '.pem', '.key', '.crt', '.p7x', '.snap', '.AppImage' ) -or - $_.Name -match '(?i)(^|[._-])(test|fake)([._-]|$)|flutter_logo' -or + ($_.Extension -eq '.so' -and $relative -cne 'data/app.so') -or + $_.Name -match '(?i)(^|[._-])(test|fake|credential|secret|token|development)([._-]|$)|flutter_logo' -or $relative -match '(?i)(^|/)(linux|snap|snapcraft)(/|$)' } if ($prohibited) { throw "Prohibited package content: $($prohibited.FullName -join ', ')" } -$executables = @(Get-ChildItem -LiteralPath $StagingPath -Recurse -File -Filter '*.exe') +$executables = @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File -Filter '*.exe') if ($executables.Count -ne 1 -or $executables[0].Name -cne 'busymax.exe') { throw "The package must contain only busymax.exe: $($executables.FullName -join ', ')" } -Write-Host "Validated package contents: $StagingPath" +$allowedDlls = @( + 'flutter_windows.dll', 'sqlite3.dll', + 'connectivity_plus_plugin.dll', 'file_selector_windows_plugin.dll', + 'flutter_local_notifications_windows.dll', + 'flutter_secure_storage_windows_plugin.dll', + 'flutter_timezone_plugin.dll', 'system_theme_plugin.dll', + 'tray_manager_plugin.dll', 'url_launcher_windows_plugin.dll', + 'concrt140.dll', 'msvcp140.dll', 'msvcp140_1.dll', 'msvcp140_2.dll', + 'msvcp140_atomic_wait.dll', 'msvcp140_codecvt_ids.dll', 'vccorlib140.dll', + 'vcruntime140.dll', 'vcruntime140_1.dll', 'vcruntime140_threads.dll' +) +$unexpectedDlls = @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File -Filter '*.dll' | + Where-Object { + $relative = [IO.Path]::GetRelativePath($PackageRoot, $_.FullName).Replace('\', '/') + $_.Name -notin $allowedDlls -or $relative -cne $_.Name + }) +if ($unexpectedDlls) { + throw "Unexpected package DLLs: $($unexpectedDlls.FullName -join ', ')" +} +function Assert-BusyMaxX64Pe { + param([Parameter(Mandatory)][string]$Path) + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 64 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + throw "Packaged native binary is not a Windows PE file: $Path" + } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length -or + $bytes[$peOffset] -ne 0x50 -or $bytes[$peOffset + 1] -ne 0x45 -or + $bytes[$peOffset + 2] -ne 0 -or $bytes[$peOffset + 3] -ne 0 -or + [BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x8664) { + throw "Packaged native binary is not x64 PE: $Path" + } +} +foreach ($nativeBinary in @( + Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.exe' + Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.dll' + Get-Item -LiteralPath (Join-Path $PackageRoot 'data/app.so') + )) { + Assert-BusyMaxX64Pe -Path $nativeBinary.FullName +} +& "$PSScriptRoot/validate_manifest.ps1" ` + -ManifestPath (Join-Path $PackageRoot 'AppxManifest.xml') + +$inventory = @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File | + Sort-Object FullName | + ForEach-Object { + [pscustomobject]@{ + Path = [IO.Path]::GetRelativePath($PackageRoot, $_.FullName).Replace('\', '/') + Length = $_.Length + SHA256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + }) +if (-not [string]::IsNullOrWhiteSpace($InventoryOutputPath)) { + $inventoryDirectory = Split-Path -Parent $InventoryOutputPath + if (-not [string]::IsNullOrWhiteSpace($inventoryDirectory)) { + New-Item -ItemType Directory -Force -Path $inventoryDirectory | Out-Null + } + $inventory | ConvertTo-Json -Depth 3 | Set-Content ` + -LiteralPath $InventoryOutputPath -Encoding utf8NoBOM +} +Write-Host "Validated package contents: $PackageRoot" +$inventory diff --git a/tools/windows/validate_release_config.ps1 b/tools/windows/validate_release_config.ps1 index 52207efb..002c2091 100644 --- a/tools/windows/validate_release_config.ps1 +++ b/tools/windows/validate_release_config.ps1 @@ -2,7 +2,8 @@ param([Parameter(Mandatory)][string]$ConfigPath, [switch]$Ci) . "$PSScriptRoot/common.ps1" $config = Get-BusyMaxStoreConfig -Path $ConfigPath -Assert-BusyMaxStoreConfig -Config $config -Ci:$Ci +$mode = if ($Ci) { 'CiNonProduction' } else { 'ProductionStore' } +Assert-BusyMaxStoreConfig -Config $config -Mode $mode $familyName = Get-BusyMaxPackageFamilyName ` -Name $config.identityName -Publisher $config.publisher [pscustomobject]@{ diff --git a/tools/windows/wack_report.ps1 b/tools/windows/wack_report.ps1 new file mode 100644 index 00000000..0d660024 --- /dev/null +++ b/tools/windows/wack_report.ps1 @@ -0,0 +1,131 @@ +Set-StrictMode -Version Latest + +function Get-BusyMaxWackAttribute { + param( + [Parameter(Mandatory)][System.Xml.XmlNode]$Node, + [Parameter(Mandatory)][string[]]$Names + ) + foreach ($name in $Names) { + $attribute = $Node.Attributes[$name] + if ($null -ne $attribute -and + -not [string]::IsNullOrWhiteSpace([string]$attribute.Value)) { + return ([string]$attribute.Value).Trim() + } + } + return '' +} + +function Get-BusyMaxWackNodeName { + param( + [Parameter(Mandatory)][System.Xml.XmlNode]$Node, + [Parameter(Mandatory)][string]$Fallback + ) + $named = $Node.SelectSingleNode('ancestor-or-self::*[@NAME or @Name or @TITLE or @Title][1]') + if ($null -eq $named) { return $Fallback } + $name = Get-BusyMaxWackAttribute -Node $named ` + -Names @('NAME', 'Name', 'TITLE', 'Title') + if ([string]::IsNullOrWhiteSpace($name)) { return $Fallback } + return $name +} + +function Read-BusyMaxWackReport { + param([Parameter(Mandatory)][string]$ReportPath) + if (-not (Test-Path -LiteralPath $ReportPath -PathType Leaf)) { + throw 'WACK did not emit an XML report.' + } + if ((Get-Item -LiteralPath $ReportPath).Length -eq 0) { + throw 'WACK emitted an empty XML report.' + } + try { + [xml]$report = Get-Content -LiteralPath $ReportPath -Raw + } catch { + throw "WACK emitted malformed XML: $($_.Exception.Message)" + } + $root = $report.DocumentElement + if ($null -eq $root -or $root.LocalName -cne 'REPORT') { + throw 'WACK XML does not contain the expected REPORT root.' + } + $overall = Get-BusyMaxWackAttribute -Node $root ` + -Names @('OVERALL_RESULT', 'OverallResult', 'RESULT', 'Result') + $overall = $overall.ToUpperInvariant() + if ([string]::IsNullOrWhiteSpace($overall)) { + throw 'WACK XML does not contain OVERALL_RESULT.' + } + + $recognizedValues = @('PASS', 'PASSED', 'FAIL', 'FAILED', 'ERROR', 'WARNING', 'WARN') + $records = @() + foreach ($node in @($report.SelectNodes('//*'))) { + if ($node -eq $root) { continue } + $value = '' + if ($node.LocalName -ceq 'RESULT') { + $value = ([string]$node.InnerText).Trim() + } + if ([string]::IsNullOrWhiteSpace($value)) { + $value = Get-BusyMaxWackAttribute -Node $node ` + -Names @('RESULT', 'Result', 'STATUS', 'Status', 'OUTCOME', 'Outcome', 'OVERALL_RESULT', 'OverallResult') + } + $value = $value.ToUpperInvariant() + if ($value -in $recognizedValues) { + $records += [pscustomobject]@{ Node = $node; Value = $value } + } + } + if ($records.Count -eq 0) { + throw 'WACK XML contains no recognized per-test results.' + } + + $failureNames = @($records | Where-Object { + $_.Value -in @('FAIL', 'FAILED', 'ERROR') + } | ForEach-Object { + Get-BusyMaxWackNodeName -Node $_.Node -Fallback $_.Value + } | Sort-Object -Unique) + + $warningNames = @($records | Where-Object { + $_.Value -in @('WARNING', 'WARN') + } | ForEach-Object { + Get-BusyMaxWackNodeName -Node $_.Node -Fallback 'WACK warning' + }) + foreach ($node in @($report.SelectNodes('//*'))) { + $severity = Get-BusyMaxWackAttribute -Node $node ` + -Names @('TYPE', 'Type', 'SEVERITY', 'Severity', 'MESSAGE_TYPE', 'MessageType') + if ($node.LocalName.ToUpperInvariant() -in @('WARNING', 'WARN') -or + $severity.ToUpperInvariant() -in @('WARNING', 'WARN')) { + $warningNames += Get-BusyMaxWackNodeName -Node $node ` + -Fallback 'WACK warning' + } + } + $warningNames = @($warningNames | Sort-Object -Unique) + + return [pscustomobject]@{ + OverallResult = $overall + ResultCount = $records.Count + FailureNames = $failureNames + Warnings = $warningNames + } +} + +function Assert-BusyMaxWackWarningDispositions { + param( + [Parameter(Mandatory)][string[]]$Warnings, + [string]$WarningDispositionPath = '' + ) + if ($Warnings.Count -gt 0) { + if ([string]::IsNullOrWhiteSpace($WarningDispositionPath) -or + -not (Test-Path -LiteralPath $WarningDispositionPath -PathType Leaf)) { + throw 'WACK reported warnings. Supply -WarningDispositionPath with a JSON object containing a written disposition for every warning name.' + } + try { + $dispositions = Get-Content -LiteralPath $WarningDispositionPath -Raw | + ConvertFrom-Json + } catch { + throw "WACK warning dispositions are malformed JSON: $($_.Exception.Message)" + } + foreach ($warningName in $Warnings) { + $property = $dispositions.PSObject.Properties[$warningName] + if ($null -eq $property -or + [string]::IsNullOrWhiteSpace([string]$property.Value) -or + [string]$property.Value -match '(?i)(todo|placeholder|replace_with)') { + throw "WACK warning '$warningName' has no explicit written disposition." + } + } + } +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index e91a7f52..a305a965 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,6 +1,7 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.14) project(busymax LANGUAGES CXX) +enable_testing() if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8 OR CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64") message(FATAL_ERROR "BusyMax for Windows supports x64 only.") diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index a3ac398b..419e1354 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -39,5 +39,17 @@ target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_link_libraries(${BINARY_NAME} PRIVATE "advapi32.lib" "windowsapp.lib") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +# Built and executed explicitly by tools/windows/run_native_tests.ps1. Keeping +# it out of the default target prevents test code from entering the package. +add_executable(busymax_runner_native_test EXCLUDE_FROM_ALL + "tests/single_instance_test.cpp" + "single_instance.cpp" +) +apply_standard_settings(busymax_runner_native_test) +target_compile_definitions(busymax_runner_native_test PRIVATE "NOMINMAX") +target_link_libraries(busymax_runner_native_test PRIVATE + "advapi32.lib" "user32.lib") +add_test(NAME busymax_runner_native_test COMMAND busymax_runner_native_test) + # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 547620a3..7f83e4c7 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -17,6 +17,12 @@ constexpr UINT kUiTaskMessage = WM_APP + 43; constexpr LONG kMinimumWidth = 900; constexpr LONG kMinimumHeight = 600; +bool HasPackageIdentity() { + UINT32 length = 0; + const LONG result = GetCurrentPackageFullName(&length, nullptr); + return result == ERROR_INSUFFICIENT_BUFFER && length > 0; +} + void PostUiTask(HWND window, std::function task) { auto* owned = new std::function(std::move(task)); if (!PostMessage(window, kUiTaskMessage, 0, @@ -83,6 +89,20 @@ bool FlutterWindow::OnCreate() { static_cast(IsWindowVisible(GetHandle())))); return; } + if (method == "hasPackageIdentity") { + result->Success(flutter::EncodableValue(HasPackageIdentity())); + return; + } + if (method == "reportNotificationActivationFailure") { + MessageBoxW( + GetHandle(), + L"BusyMax could not deliver the selected notification action " + L"to the running application. No calendar or task data was " + L"changed by this process.", + L"BusyMax notification error", MB_OK | MB_ICONERROR); + result->Success(); + return; + } if (method == "setHideOnClose") { const auto* enabled = std::get_if(call.arguments()); diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index b812efc0..168b00bb 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -3,6 +3,7 @@ #include #include +#include #include "flutter_window.h" #include "single_instance.h" @@ -25,10 +26,26 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, const std::string initial_activation = BuildBusyMaxActivation(command_line_arguments); BusyMaxSingleInstance single_instance; + if (single_instance.state() == + BusyMaxInstanceState::kFatalInitializationFailure) { + MessageBoxW(nullptr, + L"BusyMax could not initialize its per-user single-instance " + L"guard. No application data was opened.", + L"BusyMax startup error", MB_OK | MB_ICONERROR); + return EXIT_FAILURE; + } const bool notification_forwarder = - !single_instance.IsPrimary() && notification_activation_server; - if (!single_instance.IsPrimary() && !notification_forwarder) { - single_instance.ForwardActivation(initial_activation); + single_instance.state() == BusyMaxInstanceState::kSecondary && + notification_activation_server; + if (single_instance.state() == BusyMaxInstanceState::kSecondary && + !notification_forwarder) { + if (!single_instance.ForwardActivation(initial_activation)) { + MessageBoxW(nullptr, + L"BusyMax could not contact the running instance. No " + L"application data was opened by this process.", + L"BusyMax activation error", MB_OK | MB_ICONERROR); + return EXIT_FAILURE; + } return EXIT_SUCCESS; } if (notification_forwarder) { @@ -42,6 +59,11 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, // APIs such as StartupTask. const HRESULT com_result = ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(com_result)) { + MessageBoxW(nullptr, L"BusyMax could not initialize Windows COM services.", + L"BusyMax startup error", MB_OK | MB_ICONERROR); + return EXIT_FAILURE; + } flutter::DartProject project(L"data"); @@ -55,29 +77,47 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, [&single_instance](const std::string& activation) { return single_instance.ForwardActivation(activation); }); + const DWORD main_thread_id = GetCurrentThreadId(); + // Create the main-thread message queue before the listener can report a + // fatal post-start failure from its worker thread. + MSG pending_message{}; + PeekMessageW(&pending_message, nullptr, WM_USER, WM_USER, PM_NOREMOVE); if (single_instance.IsPrimary()) { - single_instance.Start( - [&window](std::string activation) { + if (!single_instance.Start([&window](std::string activation) { window.QueueActivation(std::move(activation)); - }); + }, [main_thread_id] { + PostThreadMessageW(main_thread_id, WM_QUIT, EXIT_FAILURE, 0); + })) { + MessageBoxW(nullptr, + L"BusyMax could not start its secured activation channel. " + L"No application data was opened.", + L"BusyMax startup error", MB_OK | MB_ICONERROR); + ::CoUninitialize(); + return EXIT_FAILURE; + } } Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 800); if (!window.Create(L"BusyMax", origin, size)) { single_instance.Stop(); + ::CoUninitialize(); return EXIT_FAILURE; } window.SetQuitOnClose(true); - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { + ::MSG msg{}; + BOOL message_result = 0; + while ((message_result = ::GetMessage(&msg, nullptr, 0, 0)) > 0) { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } + const int process_result = message_result == -1 || msg.wParam != 0 + ? EXIT_FAILURE + : EXIT_SUCCESS; single_instance.Stop(); if (SUCCEEDED(com_result)) { ::CoUninitialize(); } - return EXIT_SUCCESS; + return process_result; } diff --git a/windows/runner/single_instance.cpp b/windows/runner/single_instance.cpp index 70810a2c..303739dc 100644 --- a/windows/runner/single_instance.cpp +++ b/windows/runner/single_instance.cpp @@ -3,10 +3,13 @@ #include #include +#include #include #include #include #include +#include +#include namespace { @@ -76,12 +79,33 @@ bool WriteExact(HANDLE pipe, const void* source, DWORD bytes) { return true; } +bool ReadAcknowledgmentWithTimeout(HANDLE pipe, uint8_t* acknowledgment) { + if (acknowledgment == nullptr) return false; + const ULONGLONG deadline = + GetTickCount64() + kActivationConnectTimeoutMilliseconds; + while (GetTickCount64() < deadline) { + DWORD available = 0; + if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) { + return false; + } + if (available >= sizeof(*acknowledgment)) { + return ReadExact(pipe, acknowledgment, sizeof(*acknowledgment)); + } + Sleep(10); + } + return false; +} + std::wstring CurrentUserSid() { HANDLE token = nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return L""; DWORD length = 0; - GetTokenInformation(token, TokenUser, nullptr, 0, &length); - std::vector buffer(length); + if (GetTokenInformation(token, TokenUser, nullptr, 0, &length) || + GetLastError() != ERROR_INSUFFICIENT_BUFFER || length == 0) { + CloseHandle(token); + return L""; + } + std::vector buffer(length, 0); if (!GetTokenInformation(token, TokenUser, buffer.data(), length, &length)) { CloseHandle(token); return L""; @@ -95,6 +119,25 @@ std::wstring CurrentUserSid() { return value; } +BusyMaxSingleInstanceNativeHooks DefaultHooks() { + BusyMaxSingleInstanceNativeHooks hooks; + hooks.current_user_sid = CurrentUserSid; + hooks.create_mutex = [](const wchar_t* name) { + return CreateMutexW(nullptr, FALSE, name); + }; + hooks.last_error = [] { return GetLastError(); }; + hooks.create_pipe = [](const wchar_t* name, SECURITY_ATTRIBUTES* attributes) { + return CreateNamedPipeW( + name, PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, sizeof(uint8_t), kMaximumActivationBytes + sizeof(uint32_t), 5000, + attributes); + }; + hooks.close_handle = [](HANDLE handle) { CloseHandle(handle); }; + return hooks; +} + std::string EscapeJson(const std::string& value) { std::string result; result.reserve(value.size() + 8); @@ -133,6 +176,26 @@ bool EndsWithIcs(const std::string& value) { return suffix == ".ics"; } +bool IsValidIcsPath(const std::string& value) { + if (!EndsWithIcs(value) || value.size() > 32767 || value.empty()) { + return false; + } + for (const unsigned char character : value) { + if (character < 0x20 || character == '"' || character == '*' || + character == '<' || character == '>' || character == '|') { + return false; + } + } + const size_t colon = value.find(':'); + if (colon == std::string::npos) return true; + const bool drive_path = + colon == 1 && std::isalpha(static_cast(value[0])); + const bool extended_drive_path = + colon == 5 && value.compare(0, 4, R"(\\?\)") == 0 && + std::isalpha(static_cast(value[4])); + return drive_path || extended_drive_path; +} + bool StartsWithWebcal(const std::string& value) { if (value.size() < 9) return false; std::string scheme = value.substr(0, 9); @@ -143,6 +206,34 @@ bool StartsWithWebcal(const std::string& value) { return scheme == "webcal://"; } +bool IsValidWebcalUri(const std::string& value) { + if (!StartsWithWebcal(value) || value.size() > 8192 || + value.find('#') != std::string::npos || + value.find('\\') != std::string::npos) { + return false; + } + for (size_t index = 0; index < value.size(); ++index) { + const unsigned char character = value[index]; + if (character <= 0x20 || character == 0x7f) return false; + if (character == '%') { + if (index + 2 >= value.size() || + !std::isxdigit(static_cast(value[index + 1])) || + !std::isxdigit(static_cast(value[index + 2]))) { + return false; + } + index += 2; + } + } + constexpr size_t kSchemeLength = 9; + const size_t authority_end = value.find_first_of("/?", kSchemeLength); + const std::string authority = value.substr( + kSchemeLength, authority_end == std::string::npos + ? std::string::npos + : authority_end - kSchemeLength); + return !authority.empty() && authority.find('@') == std::string::npos && + authority.find('\\') == std::string::npos; +} + bool ValidUtf8(const std::string& value) { if (value.empty()) return false; return MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), @@ -273,24 +364,60 @@ bool IsValidNotificationActivation(const std::string& activation) { } // namespace -BusyMaxSingleInstance::BusyMaxSingleInstance() : user_sid_(CurrentUserSid()) { - if (user_sid_.empty()) return; +BusyMaxSingleInstanceNativeHooks DefaultBusyMaxSingleInstanceNativeHooks() { + return DefaultHooks(); +} + +BusyMaxSingleInstance::BusyMaxSingleInstance() + : BusyMaxSingleInstance(DefaultBusyMaxSingleInstanceNativeHooks()) {} + +BusyMaxSingleInstance::BusyMaxSingleInstance( + BusyMaxSingleInstanceNativeHooks hooks) + : hooks_(std::move(hooks)) { + if (!hooks_.current_user_sid || !hooks_.create_mutex || + !hooks_.last_error || !hooks_.create_pipe || !hooks_.close_handle) { + initialization_error_ = "single-instance native hooks are incomplete"; + return; + } + user_sid_ = hooks_.current_user_sid(); + if (user_sid_.empty()) { + initialization_error_ = "current-user SID lookup failed"; + return; + } pipe_name_ = std::wstring(kPipePrefix) + user_sid_; const std::wstring mutex_name = std::wstring(kMutexPrefix) + user_sid_; - mutex_ = CreateMutexW(nullptr, FALSE, mutex_name.c_str()); - primary_ = mutex_ != nullptr && GetLastError() != ERROR_ALREADY_EXISTS; + mutex_ = hooks_.create_mutex(mutex_name.c_str()); + const DWORD mutex_error = hooks_.last_error(); + if (mutex_ == nullptr) { + initialization_error_ = "per-user mutex creation failed"; + return; + } + state_ = mutex_error == ERROR_ALREADY_EXISTS + ? BusyMaxInstanceState::kSecondary + : BusyMaxInstanceState::kPrimary; } BusyMaxSingleInstance::~BusyMaxSingleInstance() { Stop(); - if (mutex_ != nullptr) CloseHandle(mutex_); + if (mutex_ != nullptr) hooks_.close_handle(mutex_); } -bool BusyMaxSingleInstance::IsPrimary() const { return primary_; } +BusyMaxInstanceState BusyMaxSingleInstance::state() const { return state_; } + +bool BusyMaxSingleInstance::IsPrimary() const { + return state_ == BusyMaxInstanceState::kPrimary; +} + +const std::string& BusyMaxSingleInstance::initialization_error() const { + return initialization_error_; +} bool BusyMaxSingleInstance::ForwardActivation( const std::string& activation) const { - if (primary_ || !IsValidBusyMaxActivation(activation)) return false; + if (state_ != BusyMaxInstanceState::kSecondary || + !IsValidBusyMaxActivation(activation)) { + return false; + } HANDLE pipe = ConnectToActivationPipe(pipe_name_); if (pipe == INVALID_HANDLE_VALUE) return false; ULONG server_process_id = 0; @@ -302,16 +429,51 @@ bool BusyMaxSingleInstance::ForwardActivation( WriteExact(pipe, activation.data(), length); uint8_t acknowledgment = 0; const bool acknowledged = - written && ReadExact(pipe, &acknowledgment, sizeof(acknowledgment)); - CloseHandle(pipe); + written && ReadAcknowledgmentWithTimeout(pipe, &acknowledgment); + hooks_.close_handle(pipe); return acknowledged && acknowledgment == 1; } bool BusyMaxSingleInstance::Start( - std::function on_activation) { - if (!primary_ || listener_.joinable()) return false; + std::function on_activation, + std::function on_listener_failure) { + if (state_ != BusyMaxInstanceState::kPrimary || listener_.joinable()) { + return false; + } + stopping_ = false; + { + std::scoped_lock lock(listener_start_mutex_); + listener_start_complete_ = false; + listener_start_succeeded_ = false; + listener_start_error_.clear(); + } on_activation_ = std::move(on_activation); - listener_ = std::thread(&BusyMaxSingleInstance::Listen, this); + on_listener_failure_ = std::move(on_listener_failure); + try { + listener_ = std::thread(&BusyMaxSingleInstance::Listen, this); + } catch (const std::system_error&) { + initialization_error_ = "named-pipe listener thread creation failed"; + state_ = BusyMaxInstanceState::kFatalInitializationFailure; + return false; + } + std::unique_lock lock(listener_start_mutex_); + const bool acknowledged = listener_start_condition_.wait_for( + lock, std::chrono::seconds(5), + [this] { return listener_start_complete_; }); + if (!acknowledged) { + initialization_error_ = "named-pipe listener startup timed out"; + lock.unlock(); + Stop(); + state_ = BusyMaxInstanceState::kFatalInitializationFailure; + return false; + } + if (!listener_start_succeeded_) { + initialization_error_ = listener_start_error_; + lock.unlock(); + if (listener_.joinable()) listener_.join(); + state_ = BusyMaxInstanceState::kFatalInitializationFailure; + return false; + } return true; } @@ -321,27 +483,47 @@ void BusyMaxSingleInstance::Stop() { HANDLE pipe = CreateFileW(pipe_name_.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (pipe != INVALID_HANDLE_VALUE) CloseHandle(pipe); + if (pipe != INVALID_HANDLE_VALUE) hooks_.close_handle(pipe); CancelSynchronousIo(static_cast(listener_.native_handle())); listener_.join(); } +HANDLE BusyMaxSingleInstance::CreateSecuredPipe( + SECURITY_ATTRIBUTES* attributes) const { + return hooks_.create_pipe(pipe_name_.c_str(), attributes); +} + +void BusyMaxSingleInstance::CompleteListenerStartup(bool succeeded, + std::string error) { + { + std::scoped_lock lock(listener_start_mutex_); + if (listener_start_complete_) return; + listener_start_succeeded_ = succeeded; + listener_start_error_ = std::move(error); + listener_start_complete_ = true; + } + listener_start_condition_.notify_one(); +} + void BusyMaxSingleInstance::Listen() { const std::wstring sddl = L"D:P(A;;GA;;;" + user_sid_ + L")"; PSECURITY_DESCRIPTOR descriptor = nullptr; if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( sddl.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) { + CompleteListenerStartup(false, + "named-pipe security descriptor creation failed"); return; } SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor, FALSE}; + HANDLE pipe = CreateSecuredPipe(&attributes); + if (pipe == INVALID_HANDLE_VALUE) { + LocalFree(descriptor); + CompleteListenerStartup(false, "secured named-pipe creation failed"); + return; + } + CompleteListenerStartup(true, ""); while (!stopping_) { - HANDLE pipe = CreateNamedPipeW( - pipe_name_.c_str(), PIPE_ACCESS_DUPLEX, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, - 1, sizeof(uint8_t), kMaximumActivationBytes + sizeof(uint32_t), 5000, - &attributes); - if (pipe == INVALID_HANDLE_VALUE) break; const bool connected = ConnectNamedPipe(pipe, nullptr) || GetLastError() == ERROR_PIPE_CONNECTED; uint8_t acknowledgment = 0; @@ -352,16 +534,31 @@ void BusyMaxSingleInstance::Listen() { std::string message(length, '\0'); if (ReadExact(pipe, message.data(), length) && IsValidBusyMaxActivation(message)) { - on_activation_(std::move(message)); - acknowledgment = 1; + if (on_activation_) { + on_activation_(std::move(message)); + acknowledgment = 1; + } } } WriteExact(pipe, &acknowledgment, sizeof(acknowledgment)); FlushFileBuffers(pipe); } DisconnectNamedPipe(pipe); - CloseHandle(pipe); + hooks_.close_handle(pipe); + pipe = INVALID_HANDLE_VALUE; + if (!stopping_) { + pipe = CreateSecuredPipe(&attributes); + if (pipe == INVALID_HANDLE_VALUE) { + MessageBoxW(nullptr, + L"BusyMax lost its secured activation channel. Close " + L"BusyMax before starting it again.", + L"BusyMax activation error", MB_OK | MB_ICONERROR); + if (on_listener_failure_) on_listener_failure_(); + break; + } + } } + if (pipe != INVALID_HANDLE_VALUE) hooks_.close_handle(pipe); LocalFree(descriptor); } @@ -377,14 +574,14 @@ std::string BuildBusyMaxActivation( } } for (const auto& argument : arguments) { - if (EndsWithIcs(argument) && argument.size() <= 32767) { + if (IsValidIcsPath(argument)) { const auto escaped = EscapeJson(argument); if (!escaped.empty()) { return "{\"version\":1,\"kind\":\"icsFile\",\"value\":\"" + escaped + "\"}"; } } - if (StartsWithWebcal(argument) && argument.size() <= 8192) { + if (IsValidWebcalUri(argument)) { const auto escaped = EscapeJson(argument); if (!escaped.empty()) { return "{\"version\":1,\"kind\":\"webCal\",\"value\":\"" + @@ -408,12 +605,12 @@ bool IsValidBusyMaxActivation(const std::string& activation) { if (DecodeValueActivation( activation, R"({"version":1,"kind":"icsFile","value":")", &value)) { - return value.size() <= 32767 && EndsWithIcs(value); + return IsValidIcsPath(value); } if (DecodeValueActivation( activation, R"({"version":1,"kind":"webCal","value":")", &value)) { - return value.size() <= 8192 && StartsWithWebcal(value); + return IsValidWebcalUri(value); } return IsValidNotificationActivation(activation); } diff --git a/windows/runner/single_instance.h b/windows/runner/single_instance.h index 20aa2922..3aa8a8a9 100644 --- a/windows/runner/single_instance.h +++ b/windows/runner/single_instance.h @@ -4,34 +4,67 @@ #include #include +#include #include +#include #include #include #include +enum class BusyMaxInstanceState { + kPrimary, + kSecondary, + kFatalInitializationFailure, +}; + +struct BusyMaxSingleInstanceNativeHooks { + std::function current_user_sid; + std::function create_mutex; + std::function last_error; + std::function create_pipe; + std::function close_handle; +}; + +BusyMaxSingleInstanceNativeHooks DefaultBusyMaxSingleInstanceNativeHooks(); + class BusyMaxSingleInstance { public: BusyMaxSingleInstance(); + explicit BusyMaxSingleInstance(BusyMaxSingleInstanceNativeHooks hooks); ~BusyMaxSingleInstance(); BusyMaxSingleInstance(const BusyMaxSingleInstance&) = delete; BusyMaxSingleInstance& operator=(const BusyMaxSingleInstance&) = delete; + BusyMaxInstanceState state() const; bool IsPrimary() const; + const std::string& initialization_error() const; bool ForwardActivation(const std::string& activation) const; - bool Start(std::function on_activation); + bool Start(std::function on_activation, + std::function on_listener_failure = {}); void Stop(); private: void Listen(); + HANDLE CreateSecuredPipe(SECURITY_ATTRIBUTES* attributes) const; + void CompleteListenerStartup(bool succeeded, std::string error); HANDLE mutex_ = nullptr; std::wstring pipe_name_; std::wstring user_sid_; - bool primary_ = false; + BusyMaxInstanceState state_ = + BusyMaxInstanceState::kFatalInitializationFailure; + std::string initialization_error_; + BusyMaxSingleInstanceNativeHooks hooks_; std::atomic stopping_ = false; std::function on_activation_; + std::function on_listener_failure_; std::thread listener_; + std::mutex listener_start_mutex_; + std::condition_variable listener_start_condition_; + bool listener_start_complete_ = false; + bool listener_start_succeeded_ = false; + std::string listener_start_error_; }; std::string BuildBusyMaxActivation( diff --git a/windows/runner/tests/single_instance_test.cpp b/windows/runner/tests/single_instance_test.cpp new file mode 100644 index 00000000..772dde70 --- /dev/null +++ b/windows/runner/tests/single_instance_test.cpp @@ -0,0 +1,181 @@ +#include "../single_instance.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures = 0; + +void Check(bool condition, const char* description) { + if (condition) return; + ++failures; + std::cerr << "FAILED: " << description << std::endl; +} + +void TestActivationValidation() { + Check(BuildBusyMaxActivation({"----AppNotificationActivationServer"}) == + R"({"version":1,"kind":"startMinimized"})", + "notification forwarder starts hidden without opening application data"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "normal activation"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"icsFile","value":"C:\\Temp\\a.ics"})"), + "ICS activation"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"icsFile","value":"file:///C:/Temp/a.ics"})"), + "file URI is not accepted as an ICS path"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"webCal","value":"webcal://example.test/a"})"), + "webcal activation"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"webCal","value":"webcal://user@example.test/a"})"), + "webcal user information rejected"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"webCal","value":"webcal://example.test/%zz"})"), + "malformed webcal percent encoding rejected"); + Check(BuildBusyMaxActivation({"webcal://example.test/feed.ics"}) == + R"({"version":1,"kind":"webCal","value":"webcal://example.test/feed.ics"})", + "webcal URI ending in ICS is not misclassified as a file path"); + Check(!IsValidBusyMaxActivation(std::string("\xC3\x28", 2)), + "malformed UTF-8 rejected"); + Check(!IsValidBusyMaxActivation("not-json"), "malformed JSON rejected"); + Check(!IsValidBusyMaxActivation(std::string(16 * 1024 + 1, 'x')), + "oversized activation rejected"); + Check(IsValidBusyMaxActivation( + R"({"version":1,"kind":"notification","action":"snooze","payload":{"notificationScheduleId":"row-1","itemId":"item-1"}})"), + "notification action allowlist accepted"); + Check(!IsValidBusyMaxActivation( + R"({"version":1,"kind":"notification","action":"run-command","payload":{"notificationScheduleId":"row-1"}})"), + "unknown notification action rejected"); +} + +void TestFatalInitializationStates() { + auto sid_failure = DefaultBusyMaxSingleInstanceNativeHooks(); + sid_failure.current_user_sid = [] { return std::wstring(); }; + BusyMaxSingleInstance no_sid(std::move(sid_failure)); + Check(no_sid.state() == + BusyMaxInstanceState::kFatalInitializationFailure, + "SID failure is fatal"); + + auto mutex_failure = DefaultBusyMaxSingleInstanceNativeHooks(); + mutex_failure.create_mutex = [](const wchar_t*) -> HANDLE { return nullptr; }; + mutex_failure.last_error = [] { return ERROR_ACCESS_DENIED; }; + BusyMaxSingleInstance no_mutex(std::move(mutex_failure)); + Check(no_mutex.state() == + BusyMaxInstanceState::kFatalInitializationFailure, + "mutex failure is fatal"); +} + +void TestListenerStartupAcknowledgmentFailure() { + auto hooks = DefaultBusyMaxSingleInstanceNativeHooks(); + hooks.create_pipe = [](const wchar_t*, SECURITY_ATTRIBUTES*) { + SetLastError(ERROR_ACCESS_DENIED); + return INVALID_HANDLE_VALUE; + }; + BusyMaxSingleInstance instance(std::move(hooks)); + if (instance.state() != BusyMaxInstanceState::kPrimary) { + // A concurrently running BusyMax owns the real per-user mutex; this case is + // covered by the installed-package integration harness instead. + return; + } + Check(!instance.Start([](std::string) {}), + "listener startup failure is acknowledged"); + Check(instance.state() == + BusyMaxInstanceState::kFatalInitializationFailure, + "listener startup failure becomes fatal"); +} + +void TestSimultaneousStartAndPipeAcknowledgment() { + BusyMaxSingleInstance primary; + if (primary.state() != BusyMaxInstanceState::kPrimary) return; + std::atomic activations = 0; + Check(primary.Start([&activations](std::string activation) { + if (IsValidBusyMaxActivation(activation)) ++activations; + }), + "primary listener starts before application data"); + + BusyMaxSingleInstance secondary; + Check(secondary.state() == BusyMaxInstanceState::kSecondary, + "simultaneous process is secondary"); + Check(secondary.ForwardActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "secondary receives pipe acknowledgment"); + const ULONGLONG deadline = GetTickCount64() + 1000; + while (activations.load() == 0 && GetTickCount64() < deadline) Sleep(5); + Check(activations.load() == 1, "primary receives one activation"); + + primary.Stop(); + Check(!secondary.ForwardActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "terminating primary does not permit a second database writer"); +} + +void TestForwardingTimeoutIsBounded() { + const std::wstring sid = + L"BusyMaxNativeTimeoutTest-" + std::to_wstring(GetCurrentProcessId()); + const std::wstring pipe_name = L"\\\\.\\pipe\\BusyMax-Activation-" + sid; + HANDLE server = CreateNamedPipeW( + pipe_name.c_str(), PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, 1, 16 * 1024 + sizeof(uint32_t), 5000, nullptr); + Check(server != INVALID_HANDLE_VALUE, "timeout test creates stale pipe"); + if (server == INVALID_HANDLE_VALUE) return; + + std::thread hung_server([server] { + if (ConnectNamedPipe(server, nullptr) || + GetLastError() == ERROR_PIPE_CONNECTED) { + uint32_t length = 0; + DWORD read = 0; + ReadFile(server, &length, sizeof(length), &read, nullptr); + std::vector body(length); + if (length > 0 && length <= 16 * 1024) { + ReadFile(server, body.data(), length, &read, nullptr); + } + // Simulate a stale or terminating primary that accepted the activation + // but never acknowledged it. + Sleep(5500); + } + DisconnectNamedPipe(server); + CloseHandle(server); + }); + + auto hooks = DefaultBusyMaxSingleInstanceNativeHooks(); + hooks.current_user_sid = [sid] { return sid; }; + hooks.create_mutex = [](const wchar_t*) { + return CreateEventW(nullptr, TRUE, FALSE, nullptr); + }; + hooks.last_error = [] { return ERROR_ALREADY_EXISTS; }; + BusyMaxSingleInstance secondary(std::move(hooks)); + const ULONGLONG started = GetTickCount64(); + Check(!secondary.ForwardActivation( + R"({"version":1,"kind":"normalLaunch"})"), + "forwarding without acknowledgment fails"); + const ULONGLONG elapsed = GetTickCount64() - started; + Check(elapsed >= 4000 && elapsed < 8000, + "forwarding acknowledgment timeout is bounded"); + hung_server.join(); +} + +} // namespace + +int main() { + TestActivationValidation(); + TestFatalInitializationStates(); + TestListenerStartupAcknowledgmentFailure(); + TestSimultaneousStartAndPipeAcknowledgment(); + TestForwardingTimeoutIsBounded(); + if (failures == 0) { + std::cout << "BusyMax single-instance native tests passed." << std::endl; + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} From 6134b5c70de625efd6dd3783dad5cc4386455638 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 17:10:24 -0700 Subject: [PATCH 25/69] Fix Windows toast manifest and package assets --- .github/workflows/flutter-windows.yml | 6 +- docs/windows_development.md | 8 +- docs/windows_packaging.md | 17 +++- .../platform/windows/windows_assets_test.dart | 17 ++++ .../windows_packaging_contract_test.dart | 38 ++++++++ tool/generate_windows_assets.dart | 4 + tools/windows/AppxManifest.xml.template | 11 +-- tools/windows/package_store.ps1 | 26 +++++- .../windows/tests/manifest_contract.Tests.ps1 | 86 ++++++++++++++++++ tools/windows/validate_manifest.ps1 | 36 +++++++- tools/windows/validate_package_contents.ps1 | 78 ++++++++++++++-- .../resources/msix/FileAssociationLogo.png | Bin 0 -> 2294 bytes .../resources/msix/Square150x150Logo.png | Bin 0 -> 15349 bytes .../runner/resources/msix/Square44x44Logo.png | Bin 0 -> 2294 bytes windows/runner/resources/msix/StoreLogo.png | Bin 0 -> 2746 bytes 15 files changed, 299 insertions(+), 28 deletions(-) create mode 100644 tools/windows/tests/manifest_contract.Tests.ps1 create mode 100644 windows/runner/resources/msix/FileAssociationLogo.png create mode 100644 windows/runner/resources/msix/Square150x150Logo.png create mode 100644 windows/runner/resources/msix/Square44x44Logo.png create mode 100644 windows/runner/resources/msix/StoreLogo.png diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 86c51ee7..804f11da 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -1,10 +1,13 @@ name: Flutter Windows on: + workflow_dispatch: pull_request: branches: [main] push: - branches: [main] + branches: + - main + - 'Release/**' jobs: build: @@ -69,5 +72,6 @@ jobs: build/windows/store/*.msix build/windows/store/staging/AppxManifest.xml build/windows/store/*inventory.json + build/windows/store/*metadata.json build/windows/test-results/** if-no-files-found: error diff --git a/docs/windows_development.md b/docs/windows_development.md index af76b4bf..e763fd37 100644 --- a/docs/windows_development.md +++ b/docs/windows_development.md @@ -63,6 +63,10 @@ flutter test dart run tool/check_platform_boundaries.dart ``` +The Windows workflow can be started manually with `workflow_dispatch` and runs +automatically for pushes to `main` and `Release/**`. This permits validation of +the exact release-branch commit without first merging it into `main`. + The boundary checker fails if Windows/common code reaches Yaru, Ubuntu localizations, DBus, freedesktop notifications, XDG tray, or GTK services, or if business code reaches Fluent UI. @@ -91,10 +95,10 @@ On 2026-08-31, the post-port working tree was validated on Linux with Flutter | --- | --- | | `flutter gen-l10n` | Passed; every supported catalog generated. | | `dart run build_runner build --delete-conflicting-outputs` | Passed; generated Drift content remained consistent and the schema version remained 13. The pinned build runner reported that the legacy delete-conflicting option is ignored. | -| `dart format --output=none --set-exit-if-changed .` | Passed; 458 files checked, zero changes required. | +| `dart format --output=none --set-exit-if-changed .` | Passed; 459 files checked, zero changes required. | | `flutter analyze` | Passed; no issues found. | | `dart run tool/check_platform_boundaries.dart` | Passed. | -| `flutter test --reporter compact` | Passed; 1,566 tests passed, 10 skipped, zero failed. | +| `flutter test --reporter compact` | Passed; 1,568 tests passed, 10 skipped, zero failed. | | `flutter build linux --release -t lib/main_linux.dart` | Passed; produced `build/linux/x64/release/bundle/busymax`. | These results establish Linux and platform-neutral source health only. They do diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md index 4aafb6a6..e7411f34 100644 --- a/docs/windows_packaging.md +++ b/docs/windows_packaging.md @@ -73,6 +73,11 @@ The manifest is rendered reproducibly from catalogs, and BusyMax visual assets. It contains no location, microphone, webcam, contacts, broad-filesystem, or private-network capability. +Toast activation uses the base `desktop:Extension` and +`desktop:ToastNotificationActivation` schema. Its CLSID is validated against +both the COM class and the notification backend constant. The obsolete +`desktop4` namespace is neither declared nor accepted for this extension. + `msix` remains pinned as the repository's Store packaging tool dependency, but its generated configuration does not express BusyMax's complete combination of StartupTask parameters, full-trust toast COM activation, and resource-derived @@ -90,6 +95,14 @@ Windows assets with: dart run tool/generate_windows_assets.dart ``` +The Store package deliberately stages concrete, unqualified PNG files for all +four manifest logo paths. Scale-qualified source variants remain committed for +review and possible future PRI-based packaging, but this packaging path does +not place them in the MSIX. `validate_package_contents.ps1` rejects a staged +`.scale-*` or `.targetsize-*` asset and confirms every logo reference resolves +to a real file after the exact MSIX is unpacked. Consequently this path does +not require or pretend to contain `resources.pri`. + The startup extension passes `--start-minimized` through its schema-valid `uap10:Parameters` attribute. The nested `desktop:StartupTask` remains disabled by default; that element does not define an arguments attribute. @@ -133,7 +146,9 @@ packaging script unpacks that exact MSIX into a new clean directory, reruns the manifest/content checks, writes a per-file SHA-256 inventory, and compares the unpacked inventory with the deterministic staging inventory (excluding only the package metadata files produced by `MakeAppx`). Both inventories are CI -artifacts. +artifacts. `MakeAppx pack /v` is also the mandatory Windows SDK schema and +package validation gate; a successful custom XML check alone cannot produce an +artifact. After installing a release-equivalent locally signed package, run: diff --git a/test/platform/windows/windows_assets_test.dart b/test/platform/windows/windows_assets_test.dart index 4b1da3b2..dbad57dd 100644 --- a/test/platform/windows/windows_assets_test.dart +++ b/test/platform/windows/windows_assets_test.dart @@ -49,4 +49,21 @@ void main() { } }, ); + + test('MSIX manifest assets also have real unqualified files', () { + const logicalSizes = { + 'StoreLogo.png': 50, + 'Square44x44Logo.png': 44, + 'Square150x150Logo.png': 150, + 'FileAssociationLogo.png': 44, + }; + for (final MapEntry(key: name, value: size) in logicalSizes.entries) { + final path = 'windows/runner/resources/msix/$name'; + final asset = image.decodePng(File(path).readAsBytesSync()); + expect(asset, isNotNull, reason: path); + expect(asset!.width, size, reason: path); + expect(asset.height, size, reason: path); + expect(asset.getPixel(0, 0).a, 0, reason: '$path needs safe padding'); + } + }); } diff --git a/test/platform/windows/windows_packaging_contract_test.dart b/test/platform/windows/windows_packaging_contract_test.dart index ced3e12c..1fb569fb 100644 --- a/test/platform/windows/windows_packaging_contract_test.dart +++ b/test/platform/windows/windows_packaging_contract_test.dart @@ -64,11 +64,49 @@ void main() { final toastClsid = elements( 'ToastNotificationActivation', ).single.getAttribute('ToastActivatorCLSID'); + final toast = elements('ToastNotificationActivation').single; + expect( + toast.name.namespaceUri, + 'http://schemas.microsoft.com/appx/manifest/desktop/windows10', + ); + expect( + toast.parentElement!.name.namespaceUri, + 'http://schemas.microsoft.com/appx/manifest/desktop/windows10', + ); + final ignorableNamespaces = manifest.rootElement + .getAttribute('IgnorableNamespaces')! + .split(' '); + expect(ignorableNamespaces, contains('desktop')); + expect(ignorableNamespaces, isNot(contains('desktop4'))); + expect(manifestSource, isNot(contains('xmlns:desktop4='))); final comClsid = elements('Class').single.getAttribute('Id'); expect(toastClsid, '{$busyMaxToastActivatorClsid}'); expect(comClsid, toastClsid); }); + test('every manifest logo has a matching unqualified source asset', () { + final references = { + elements('Logo').first.innerText, + elements('Logo').last.innerText, + elements('VisualElements').single.getAttribute('Square44x44Logo')!, + elements('VisualElements').single.getAttribute('Square150x150Logo')!, + }; + expect(references, { + r'Assets\StoreLogo.png', + r'Assets\FileAssociationLogo.png', + r'Assets\Square44x44Logo.png', + r'Assets\Square150x150Logo.png', + }); + for (final reference in references) { + final name = reference.split(r'\').last; + expect( + File('windows/runner/resources/msix/$name').existsSync(), + isTrue, + reason: reference, + ); + } + }); + test('Store manifest capability set remains minimal', () { final capabilities = elements('Capabilities').single.childElements .map((element) => element.getAttribute('Name')) diff --git a/tool/generate_windows_assets.dart b/tool/generate_windows_assets.dart index b642660e..f633ac77 100644 --- a/tool/generate_windows_assets.dart +++ b/tool/generate_windows_assets.dart @@ -34,6 +34,7 @@ void main() { 200: 88, 400: 176, }); + _writePng(packageAssets, packageSource, 'Square44x44Logo.png', 44); _writeScaleSet(packageAssets, packageSource, 'Square150x150Logo', const { 100: 150, 125: 188, @@ -41,6 +42,7 @@ void main() { 200: 300, 400: 600, }); + _writePng(packageAssets, packageSource, 'Square150x150Logo.png', 150); _writeScaleSet(packageAssets, packageSource, 'StoreLogo', const { 100: 50, 125: 63, @@ -48,7 +50,9 @@ void main() { 200: 100, 400: 200, }); + _writePng(packageAssets, packageSource, 'StoreLogo.png', 50); _writePng(packageAssets, packageSource, 'FileAssociation.png', 44); + _writePng(packageAssets, packageSource, 'FileAssociationLogo.png', 44); _writeScaleSet(packageAssets, packageSource, 'FileAssociation', const { 100: 44, 125: 55, diff --git a/tools/windows/AppxManifest.xml.template b/tools/windows/AppxManifest.xml.template index 3c7032a4..7e03407c 100644 --- a/tools/windows/AppxManifest.xml.template +++ b/tools/windows/AppxManifest.xml.template @@ -4,10 +4,9 @@ xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" - xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" - IgnorableNamespaces="uap uap10 desktop desktop4 com rescap"> + IgnorableNamespaces="uap uap10 desktop com rescap"> iCalendar file - Assets\FileAssociation.png + Assets\FileAssociationLogo.png .ics @@ -71,10 +70,10 @@ Enabled="false" DisplayName="BusyMax" /> - - + - + ', + '') ` + .Replace('(\s*)', + '$1') + $rendered | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + { & (Join-Path $tools 'validate_manifest.ps1') ` + -ManifestPath $manifestPath } | + Should -Throw -ExpectedMessage '*desktop namespace, not desktop4*' + } + + It 'rejects a COM class that differs from the toast activator CLSID' { + $rendered = (New-BusyMaxRenderedTestManifest).Replace( + '', + '') + $rendered | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM + { & (Join-Path $tools 'validate_manifest.ps1') ` + -ManifestPath $manifestPath } | + Should -Throw -ExpectedMessage '*Toast COM server*' + } + + It 'references only concrete unqualified package logo paths' { + [xml]$manifest = New-BusyMaxRenderedTestManifest + $namespaces = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable) + $namespaces.AddNamespace( + 'f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') + $namespaces.AddNamespace( + 'uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10') + $visualElements = $manifest.SelectSingleNode( + '//uap:VisualElements', $namespaces) + $references = @( + [string]$manifest.SelectSingleNode( + '/f:Package/f:Properties/f:Logo', $namespaces).InnerText + [string]$visualElements.Square44x44Logo + [string]$visualElements.Square150x150Logo + [string]$manifest.SelectSingleNode( + '//uap:FileTypeAssociation/uap:Logo', $namespaces).InnerText + ) + $references | Should -HaveCount 4 + foreach ($reference in $references) { + $reference | Should -Not -Match '\.(scale|targetsize)-' + } + } +} diff --git a/tools/windows/validate_manifest.ps1 b/tools/windows/validate_manifest.ps1 index 3ed88530..aa5fef92 100644 --- a/tools/windows/validate_manifest.ps1 +++ b/tools/windows/validate_manifest.ps1 @@ -74,18 +74,42 @@ if ($application.Id -cne 'BusyMax' -or $application.Executable -cne 'busymax.exe } $visualElements = $manifest.SelectSingleNode( '/f:Package/f:Applications/f:Application/uap:VisualElements', $manager) -if ($null -eq $visualElements -or +$propertiesLogo = $manifest.SelectSingleNode( + '/f:Package/f:Properties/f:Logo', $manager) +if ($null -eq $propertiesLogo -or + $propertiesLogo.InnerText -cne 'Assets\StoreLogo.png' -or + $null -eq $visualElements -or $visualElements.Square44x44Logo -cne 'Assets\Square44x44Logo.png' -or $visualElements.Square150x150Logo -cne 'Assets\Square150x150Logo.png') { throw 'BusyMax package visual elements are incomplete.' } $fileType = $manifest.SelectSingleNode('//uap:FileType[text()=".ics"]', $manager) +$fileAssociationLogo = $manifest.SelectSingleNode( + '//uap:FileTypeAssociation/uap:Logo', $manager) +if ($null -eq $fileAssociationLogo -or + $fileAssociationLogo.InnerText -cne 'Assets\FileAssociationLogo.png') { + throw 'BusyMax file-association logo is incomplete.' +} $protocol = $manifest.SelectSingleNode('//uap:Protocol[@Name="webcal"]', $manager) $startup = $manifest.SelectSingleNode('//desktop:StartupTask[@TaskId="BusyMaxStartupTask"]', $manager) -$toast = $manifest.SelectSingleNode('//desktop4:ToastNotificationActivation', $manager) +$invalidDesktop4Toast = $manifest.SelectSingleNode( + '//desktop4:Extension[@Category="windows.toastNotificationActivation"]', + $manager) +if ($null -ne $invalidDesktop4Toast) { + throw 'Toast activation must use the base desktop namespace, not desktop4.' +} +$toastExtension = $manifest.SelectSingleNode( + '//desktop:Extension[@Category="windows.toastNotificationActivation"]', + $manager) +$toast = $null +if ($null -ne $toastExtension) { + $toast = $toastExtension.SelectSingleNode( + 'desktop:ToastNotificationActivation', $manager) +} $toastServer = $manifest.SelectSingleNode('//com:ExeServer/com:Class', $manager) if ($null -eq $fileType -or $null -eq $protocol -or $null -eq $startup -or - $null -eq $toast -or $null -eq $toastServer) { + $null -eq $toastExtension -or $null -eq $toast -or + $null -eq $toastServer) { throw 'Manifest activation declarations are incomplete.' } $extensions = @($manifest.SelectNodes( @@ -119,7 +143,11 @@ if ($startup.Enabled -ne 'false' -or $startupArguments -ne '--start-minimized') if ($toast.ToastActivatorCLSID -ne '{7B854A6D-8B2A-45A5-B998-1F51EC5A81D7}') { throw 'Toast activator CLSID does not match the committed notification backend.' } -if ($toastServer.Id -ne $toast.ToastActivatorCLSID -or +if ($toastExtension.NamespaceURI -cne 'http://schemas.microsoft.com/appx/manifest/desktop/windows10' -or + $toast.NamespaceURI -cne 'http://schemas.microsoft.com/appx/manifest/desktop/windows10') { + throw 'Toast activation must use desktop:Extension and desktop:ToastNotificationActivation.' +} +if ($toastServer.Id -cne $toast.ToastActivatorCLSID -or $toastServer.ParentNode.Arguments -ne '----AppNotificationActivationServer') { throw 'Toast COM server does not match the activation declaration.' } diff --git a/tools/windows/validate_package_contents.ps1 b/tools/windows/validate_package_contents.ps1 index a233caea..827278be 100644 --- a/tools/windows/validate_package_contents.ps1 +++ b/tools/windows/validate_package_contents.ps1 @@ -21,16 +21,35 @@ foreach ($required in @( throw "Required package file is missing: $required" } } -foreach ($asset in @( - 'StoreLogo', 'Square44x44Logo', 'Square150x150Logo', - 'FileAssociation')) { - foreach ($scale in @(100, 125, 150, 200, 400)) { - $candidate = "Assets/$asset.scale-$scale.png" - if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $candidate))) { - throw "Required package visual asset is missing: $candidate" - } +$allowedAssets = @( + 'Assets/StoreLogo.png', + 'Assets/Square44x44Logo.png', + 'Assets/Square150x150Logo.png', + 'Assets/FileAssociationLogo.png') +foreach ($asset in $allowedAssets) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $asset))) { + throw "Required unqualified package visual asset is missing: $asset" } } +$unexpectedAssets = @(Get-ChildItem ` + -LiteralPath (Join-Path $PackageRoot 'Assets') -Recurse -File | + Where-Object { + $relative = [IO.Path]::GetRelativePath( + $PackageRoot, $_.FullName).Replace('\', '/') + $relative -notin $allowedAssets + }) +if ($unexpectedAssets.Count -gt 0) { + throw "Unexpected or unreferenced package visual asset: $($unexpectedAssets.FullName -join ', ')" +} +$qualifiedVisualAssets = @(Get-ChildItem ` + -LiteralPath (Join-Path $PackageRoot 'Assets') -File | + Where-Object { $_.Name -match '(?i)\.(scale|targetsize)-' }) +if ($qualifiedVisualAssets.Count -gt 0) { + throw 'Qualified visual assets require a generated resources.pri and must not be staged by the unqualified-asset packaging path.' +} +if (Test-Path -LiteralPath (Join-Path $PackageRoot 'resources.pri')) { + throw 'The unqualified-asset packaging path must not contain a stale resources.pri.' +} $prohibited = Get-ChildItem -LiteralPath $PackageRoot -Recurse -File | Where-Object { $relative = [IO.Path]::GetRelativePath($PackageRoot, $_.FullName).Replace('\', '/') @@ -90,8 +109,47 @@ foreach ($nativeBinary in @( )) { Assert-BusyMaxX64Pe -Path $nativeBinary.FullName } -& "$PSScriptRoot/validate_manifest.ps1" ` - -ManifestPath (Join-Path $PackageRoot 'AppxManifest.xml') +$manifestPath = Join-Path $PackageRoot 'AppxManifest.xml' +& "$PSScriptRoot/validate_manifest.ps1" -ManifestPath $manifestPath +[xml]$packageManifest = Get-Content -LiteralPath $manifestPath -Raw +$manifestNamespaces = [System.Xml.XmlNamespaceManager]::new( + $packageManifest.NameTable) +$manifestNamespaces.AddNamespace( + 'f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') +$manifestNamespaces.AddNamespace( + 'uap', 'http://schemas.microsoft.com/appx/manifest/uap/windows10') +$propertiesLogo = $packageManifest.SelectSingleNode( + '/f:Package/f:Properties/f:Logo', $manifestNamespaces) +$visualElements = $packageManifest.SelectSingleNode( + '//uap:VisualElements', $manifestNamespaces) +$fileAssociationLogo = $packageManifest.SelectSingleNode( + '//uap:FileTypeAssociation/uap:Logo', $manifestNamespaces) +if ($null -eq $propertiesLogo -or $null -eq $visualElements -or + $null -eq $fileAssociationLogo) { + throw 'The exact package manifest is missing required logo declarations.' +} +$logoReferences = @( + [string]$propertiesLogo.InnerText + [string]$visualElements.Square44x44Logo + [string]$visualElements.Square150x150Logo + [string]$fileAssociationLogo.InnerText +) | Sort-Object -Unique +if ($logoReferences.Count -ne 4) { + throw 'The manifest must contain exactly four distinct BusyMax logo paths.' +} +foreach ($reference in $logoReferences) { + if ([string]::IsNullOrWhiteSpace($reference) -or + [IO.Path]::IsPathRooted($reference) -or + $reference -match '(^|[\\/])\.\.([\\/]|$)') { + throw "Manifest logo path is unsafe: $reference" + } + $resolvedReference = $reference.Replace( + '\', [IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -LiteralPath ` + (Join-Path $PackageRoot $resolvedReference) -PathType Leaf)) { + throw "Manifest logo does not resolve inside the exact package: $reference" + } +} $inventory = @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File | Sort-Object FullName | diff --git a/windows/runner/resources/msix/FileAssociationLogo.png b/windows/runner/resources/msix/FileAssociationLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..d0c2947aa4ac78ae4cd3bcfee5dcdbb19598a325 GIT binary patch literal 2294 zcmV$=NQL9Q?rAlbJh=?|XR%LD4Y-#`{G#f>sgcj0; z&`^jWB_#&6sUnw1X%(oDAfeEdghYlq{DatRk8!abyw1G+Ywvw`#?O!v4^XL6m0`-IQve-&83asb-g*rAiJTenRHkiC}R`ZZLP zm+|U(bK-xmH31j^RwSv{B*N;9If>w%1`u2=xPFZkH5Ml+D?l|h^xQdPSeJB9m_+n= zh9~~L4(Dp1uLfB1d^_&h2G{bCejl)=EsYO%B(VS4;r$C>jBQ9{vVH!?SZSxk+Wd&( z|25!T`L)T$+S;4UXTFmI)d~1TV_IHxGQcg1GPreN1{+6LLN@_MK0H_;9$;ncBWJs_ z@R4-z!8QdxXvOtHK>@^PC8^uYfdZ&$X@>8HUs+vMDg7(1^9-=OEnLLbv8C`}OK2As z=9ueiZ0f1mV$TvbaR&=t2(U3T9%N0Fj$ z&3LG>6PXUehUvBf56AqX?HP-@QRgfx0^ofbv_1$>tvc537HcE&AUX)YJ<|dvt}?=t zV^c8sz-G%g9SAk4QrOgkC5=8-G?n6+QEl)v3of(aQwaW5<5|0fj-Nd0)lI&f+Lr?s z-CulLlDZ7_}95i12V~ zul(F+j~CGmQ+z5nT^L%M{s{_6}F~lf*TCqDBl`dM5xWOn4*8>!f|g;vAXFD4mcAW zd{YNDMRS!ih6k!D@taf4*nBn(*8x^ds=UMsxpR*~KrawS3tnKuWfop&yakLinQ7lD z-!2#8swxPZi1z@5r|pZQPrrXisW)ISYTO-dC8jJ z%`AENYq|mGIxu_OXy`?RJq@9sqc*Ag=17X;}mYW$KKdOT5h#)6cz#ZjaFA5qutJE1z=H+kb!evUJnFnu08`IJxtkFBZ?Q<~pG10N28y zafaD50J8|#bC4vs9(CHEE0BVHimN@qTix_;jr>W4Uvlg}R z@wF3rPn%i-LTSeP)z;M_ZX7!b(6EV8*80bTPmGa`$#oyGm=2LH89&H_S^>$DOXwh6z_HyKkP&0EKX28)@orJ2 z`rU$3LcTos7Z^Ak7RVV&cp2jR2@Q9)VPs;YR94F=ZS*%L7rQn?m)Cu9d-lWfTha!A z<&kW%lc&z1_4sw*MgkTic#LrO?RL74dT{VinOTsB-^T|{8aP~ti2#o?_#E&VaQ!=F za5REG7gFt9V`>r0$OhGbUQu$&*&tL{ynpu%AFg&?I5!iKM)EhrIrL(Ts93n6qjkr! zL8rtF+&aZa(B=t9FbV3h9DJx;=8uGKSeX31WEgp>5pzmA6j~wlY6!dcH4m<)A>cfm zk1JrNJSasW>S*1us^FB+pjEA=pS|(}?T2@T6Ek=`oZRK1>N<<#qn#e&ceG;KwA28F zd)?5hqOtbvPow>81$+q#xKe-|EDPsjz_Et=etMzfPg~W-r=XMwL`tlpC%?POeX8^f zcaHgtGpPa#t>Gb1Q3kXh7=_)3JlwOKfomEvUg+5#$41>Y9gbEZ)eCsI=-eE_W&+kF zi}oEXoeEGFfJ~u#iD2MviTrW4F^So%xW2@z_t`*ShsUtIfA|%_);u^Ia5%x{THc@H z=n6#+OgppF_|At{lIHAnp;aqbexn%1RH{tTx)VUN%H15OdnK_(kzHG$hMI)jkLldoceEQfzFyYeU>GGV zkSYhEVw_2vgvv*nl=oL8LtUtQZ{!pJeE_q>oDo&M%RR5ST+@*nEVA8;Y&*%F+dg6< z2P@t=#yRRU4goG=P+9W)-BYQ8~sO5n^TsLYxrAf z?1oZb#ngfG&+U2dpKBjepth=(9VtB(@|>+uV2VJqi0WPl!kqPIrcMJ`Ol^^c=PiKQ z7d4fCrB5=i=3yX3RPuAaR^`s91yQ5|-j%9)t)CA^Q)KA>IEH|SfIsE+FMW!~VJ&%n QfB*mh07*qoM6N<$f)CJ9(*OVf literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square150x150Logo.png b/windows/runner/resources/msix/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a153498a176ac651fcfde6330929d909d86a51d0 GIT binary patch literal 15349 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 literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square44x44Logo.png b/windows/runner/resources/msix/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d0c2947aa4ac78ae4cd3bcfee5dcdbb19598a325 GIT binary patch literal 2294 zcmV$=NQL9Q?rAlbJh=?|XR%LD4Y-#`{G#f>sgcj0; z&`^jWB_#&6sUnw1X%(oDAfeEdghYlq{DatRk8!abyw1G+Ywvw`#?O!v4^XL6m0`-IQve-&83asb-g*rAiJTenRHkiC}R`ZZLP zm+|U(bK-xmH31j^RwSv{B*N;9If>w%1`u2=xPFZkH5Ml+D?l|h^xQdPSeJB9m_+n= zh9~~L4(Dp1uLfB1d^_&h2G{bCejl)=EsYO%B(VS4;r$C>jBQ9{vVH!?SZSxk+Wd&( z|25!T`L)T$+S;4UXTFmI)d~1TV_IHxGQcg1GPreN1{+6LLN@_MK0H_;9$;ncBWJs_ z@R4-z!8QdxXvOtHK>@^PC8^uYfdZ&$X@>8HUs+vMDg7(1^9-=OEnLLbv8C`}OK2As z=9ueiZ0f1mV$TvbaR&=t2(U3T9%N0Fj$ z&3LG>6PXUehUvBf56AqX?HP-@QRgfx0^ofbv_1$>tvc537HcE&AUX)YJ<|dvt}?=t zV^c8sz-G%g9SAk4QrOgkC5=8-G?n6+QEl)v3of(aQwaW5<5|0fj-Nd0)lI&f+Lr?s z-CulLlDZ7_}95i12V~ zul(F+j~CGmQ+z5nT^L%M{s{_6}F~lf*TCqDBl`dM5xWOn4*8>!f|g;vAXFD4mcAW zd{YNDMRS!ih6k!D@taf4*nBn(*8x^ds=UMsxpR*~KrawS3tnKuWfop&yakLinQ7lD z-!2#8swxPZi1z@5r|pZQPrrXisW)ISYTO-dC8jJ z%`AENYq|mGIxu_OXy`?RJq@9sqc*Ag=17X;}mYW$KKdOT5h#)6cz#ZjaFA5qutJE1z=H+kb!evUJnFnu08`IJxtkFBZ?Q<~pG10N28y zafaD50J8|#bC4vs9(CHEE0BVHimN@qTix_;jr>W4Uvlg}R z@wF3rPn%i-LTSeP)z;M_ZX7!b(6EV8*80bTPmGa`$#oyGm=2LH89&H_S^>$DOXwh6z_HyKkP&0EKX28)@orJ2 z`rU$3LcTos7Z^Ak7RVV&cp2jR2@Q9)VPs;YR94F=ZS*%L7rQn?m)Cu9d-lWfTha!A z<&kW%lc&z1_4sw*MgkTic#LrO?RL74dT{VinOTsB-^T|{8aP~ti2#o?_#E&VaQ!=F za5REG7gFt9V`>r0$OhGbUQu$&*&tL{ynpu%AFg&?I5!iKM)EhrIrL(Ts93n6qjkr! zL8rtF+&aZa(B=t9FbV3h9DJx;=8uGKSeX31WEgp>5pzmA6j~wlY6!dcH4m<)A>cfm zk1JrNJSasW>S*1us^FB+pjEA=pS|(}?T2@T6Ek=`oZRK1>N<<#qn#e&ceG;KwA28F zd)?5hqOtbvPow>81$+q#xKe-|EDPsjz_Et=etMzfPg~W-r=XMwL`tlpC%?POeX8^f zcaHgtGpPa#t>Gb1Q3kXh7=_)3JlwOKfomEvUg+5#$41>Y9gbEZ)eCsI=-eE_W&+kF zi}oEXoeEGFfJ~u#iD2MviTrW4F^So%xW2@z_t`*ShsUtIfA|%_);u^Ia5%x{THc@H z=n6#+OgppF_|At{lIHAnp;aqbexn%1RH{tTx)VUN%H15OdnK_(kzHG$hMI)jkLldoceEQfzFyYeU>GGV zkSYhEVw_2vgvv*nl=oL8LtUtQZ{!pJeE_q>oDo&M%RR5ST+@*nEVA8;Y&*%F+dg6< z2P@t=#yRRU4goG=P+9W)-BYQ8~sO5n^TsLYxrAf z?1oZb#ngfG&+U2dpKBjepth=(9VtB(@|>+uV2VJqi0WPl!kqPIrcMJ`Ol^^c=PiKQ z7d4fCrB5=i=3yX3RPuAaR^`s91yQ5|-j%9)t)CA^Q)KA>IEH|SfIsE+FMW!~VJ&%n QfB*mh07*qoM6N<$f)CJ9(*OVf literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/StoreLogo.png b/windows/runner/resources/msix/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..60dc64d013af55c2925380050ffcbda41b563e91 GIT binary patch literal 2746 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 AVE_OC literal 0 HcmV?d00001 From c255d9f83c2fc798e4ca36faaa1a161732c97b81 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 17:19:56 -0700 Subject: [PATCH 26/69] Fix Windows packaging PowerShell tests --- tools/windows/render_manifest.ps1 | 19 +++--- .../windows/tests/manifest_contract.Tests.ps1 | 42 +++++++------ tools/windows/tests/store_config.Tests.ps1 | 63 ++++++++++--------- tools/windows/tests/wack_report.Tests.ps1 | 10 +-- 4 files changed, 74 insertions(+), 60 deletions(-) diff --git a/tools/windows/render_manifest.ps1 b/tools/windows/render_manifest.ps1 index 970a34b8..18e39258 100644 --- a/tools/windows/render_manifest.ps1 +++ b/tools/windows/render_manifest.ps1 @@ -13,13 +13,18 @@ if ([version]$MaximumTestedVersion -lt [version]'10.0.26100.0') { throw 'MaximumTestedVersion must be the Windows 11 24H2 SDK or newer.' } $template = Get-Content -LiteralPath "$PSScriptRoot/AppxManifest.xml.template" -Raw -$rendered = $template ` - .Replace('@@IDENTITY_NAME@@', [Security.SecurityElement]::Escape($config.identityName)) ` - .Replace('@@PUBLISHER@@', [Security.SecurityElement]::Escape($config.publisher)) ` - .Replace('@@PUBLISHER_DISPLAY_NAME@@', [Security.SecurityElement]::Escape($config.publisherDisplayName)) ` - .Replace('@@PACKAGE_VERSION@@', $config.msixVersion) ` - .Replace('@@MAX_TESTED_VERSION@@', $MaximumTestedVersion) ` - .Replace('@@RESOURCE_LANGUAGES@@', (Get-BusyMaxResourceXml)) +$rendered = $template.Replace( + '@@IDENTITY_NAME@@', [Security.SecurityElement]::Escape($config.identityName)) +$rendered = $rendered.Replace( + '@@PUBLISHER@@', [Security.SecurityElement]::Escape($config.publisher)) +$rendered = $rendered.Replace( + '@@PUBLISHER_DISPLAY_NAME@@', + [Security.SecurityElement]::Escape($config.publisherDisplayName)) +$rendered = $rendered.Replace('@@PACKAGE_VERSION@@', $config.msixVersion) +$rendered = $rendered.Replace( + '@@MAX_TESTED_VERSION@@', $MaximumTestedVersion) +$rendered = $rendered.Replace( + '@@RESOURCE_LANGUAGES@@', (Get-BusyMaxResourceXml)) $directory = Split-Path -Parent $OutputPath New-Item -ItemType Directory -Force -Path $directory | Out-Null [IO.File]::WriteAllText($OutputPath, $rendered, [Text.UTF8Encoding]::new($false)) diff --git a/tools/windows/tests/manifest_contract.Tests.ps1 b/tools/windows/tests/manifest_contract.Tests.ps1 index 326922e3..7daf71e5 100644 --- a/tools/windows/tests/manifest_contract.Tests.ps1 +++ b/tools/windows/tests/manifest_contract.Tests.ps1 @@ -1,16 +1,22 @@ -$tools = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -. (Join-Path $tools 'common.ps1') +BeforeAll { + $script:tools = Split-Path -Parent $PSScriptRoot + . (Join-Path $script:tools 'common.ps1') -function New-BusyMaxRenderedTestManifest { - $template = Get-Content ` - -LiteralPath (Join-Path $tools 'AppxManifest.xml.template') -Raw - return $template ` - .Replace('@@IDENTITY_NAME@@', 'BusyStack.BusyMax.CI') ` - .Replace('@@PUBLISHER@@', 'CN=BusyMax CI Package') ` - .Replace('@@PUBLISHER_DISPLAY_NAME@@', 'BusyMax CI') ` - .Replace('@@PACKAGE_VERSION@@', '1.0.0.0') ` - .Replace('@@MAX_TESTED_VERSION@@', '10.0.26100.0') ` - .Replace('@@RESOURCE_LANGUAGES@@', (Get-BusyMaxResourceXml)) + function New-BusyMaxRenderedTestManifest { + $rendered = Get-Content ` + -LiteralPath (Join-Path $script:tools 'AppxManifest.xml.template') -Raw + $rendered = $rendered.Replace( + '@@IDENTITY_NAME@@', 'BusyStack.BusyMax.CI') + $rendered = $rendered.Replace( + '@@PUBLISHER@@', 'CN=BusyMax CI Package') + $rendered = $rendered.Replace( + '@@PUBLISHER_DISPLAY_NAME@@', 'BusyMax CI') + $rendered = $rendered.Replace('@@PACKAGE_VERSION@@', '1.0.0.0') + $rendered = $rendered.Replace( + '@@MAX_TESTED_VERSION@@', '10.0.26100.0') + return $rendered.Replace( + '@@RESOURCE_LANGUAGES@@', (Get-BusyMaxResourceXml)) + } } Describe 'BusyMax rendered manifest contract' { @@ -35,12 +41,12 @@ Describe 'BusyMax rendered manifest contract' { $rendered = $rendered.Replace( 'IgnorableNamespaces="uap uap10 desktop com rescap"', 'IgnorableNamespaces="uap uap10 desktop desktop4 com rescap"') - $rendered = $rendered ` - .Replace( - '', - '') ` - .Replace('', + '') + $rendered = $rendered.Replace( + '(\s*)', diff --git a/tools/windows/tests/store_config.Tests.ps1 b/tools/windows/tests/store_config.Tests.ps1 index 7231a437..84773214 100644 --- a/tools/windows/tests/store_config.Tests.ps1 +++ b/tools/windows/tests/store_config.Tests.ps1 @@ -1,37 +1,38 @@ -$here = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path (Split-Path -Parent $here) 'common.ps1') +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'common.ps1') -function New-ValidConfig { - param([bool]$Production = $true) - return [pscustomobject]@{ - production = $Production - identityName = 'BusyStack.BusyMax.Owner' - publisher = 'CN=BusyMax Owner' - publisherDisplayName = 'BusyMax Owner' - msixVersion = '1.2.3.0' - previousMsixVersion = '1.2.2.0' - privacyPolicyUrl = 'https://busystack.org/privacy' - supportUrl = 'https://busystack.org/support' - homepageUrl = 'https://busystack.org' - googleOAuthClientId = 'google-owner-client-id' - googleOAuthClientSecret = '' - microsoftOAuthClientId = 'microsoft-owner-client-id' - microsoftOAuthAuthorityTenant = 'common' - fakeData = $false - developmentBackend = $false + function New-ValidConfig { + param([bool]$Production = $true) + return [pscustomobject]@{ + production = $Production + identityName = 'BusyStack.BusyMax.Owner' + publisher = 'CN=BusyMax Owner' + publisherDisplayName = 'BusyMax Owner' + msixVersion = '1.2.3.0' + previousMsixVersion = '1.2.2.0' + privacyPolicyUrl = 'https://busystack.org/privacy' + supportUrl = 'https://busystack.org/support' + homepageUrl = 'https://busystack.org' + googleOAuthClientId = 'google-owner-client-id' + googleOAuthClientSecret = '' + microsoftOAuthClientId = 'microsoft-owner-client-id' + microsoftOAuthAuthorityTenant = 'common' + fakeData = $false + developmentBackend = $false + } } -} -function New-CiConfig { - $config = New-ValidConfig -Production $false - $config.identityName = 'BusyStack.BusyMax.CI' - $config.publisher = 'CN=BusyMax CI Package' - $config.publisherDisplayName = 'BusyMax CI' - $config.msixVersion = '1.0.0.0' - $config.previousMsixVersion = '' - $config.googleOAuthClientId = 'busymax-ci-google-client-id' - $config.microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' - return $config + function New-CiConfig { + $config = New-ValidConfig -Production $false + $config.identityName = 'BusyStack.BusyMax.CI' + $config.publisher = 'CN=BusyMax CI Package' + $config.publisherDisplayName = 'BusyMax CI' + $config.msixVersion = '1.0.0.0' + $config.previousMsixVersion = '' + $config.googleOAuthClientId = 'busymax-ci-google-client-id' + $config.microsoftOAuthClientId = 'busymax-ci-microsoft-client-id' + return $config + } } Describe 'BusyMax Store configuration validation modes' { diff --git a/tools/windows/tests/wack_report.Tests.ps1 b/tools/windows/tests/wack_report.Tests.ps1 index 6aeba567..42f7c7ef 100644 --- a/tools/windows/tests/wack_report.Tests.ps1 +++ b/tools/windows/tests/wack_report.Tests.ps1 @@ -1,10 +1,12 @@ -$here = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path (Split-Path -Parent $here) 'wack_report.ps1') +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'wack_report.ps1') +} Describe 'BusyMax WACK report parsing' { BeforeEach { - $report = Join-Path $TestDrive 'report.xml' - $dispositions = Join-Path $TestDrive 'dispositions.json' + $caseId = [guid]::NewGuid().ToString('N') + $report = Join-Path $TestDrive "$caseId-report.xml" + $dispositions = Join-Path $TestDrive "$caseId-dispositions.json" } It 'accepts result elements and attribute-based test results' { From 07275c8f0ec1b60ed73ce0cde930a2d09d5b3c63 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 17:24:24 -0700 Subject: [PATCH 27/69] Expose Windows CI preflight gates --- .github/workflows/flutter-windows.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 804f11da..65486899 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -57,6 +57,26 @@ jobs: } | ConvertTo-Json | Set-Content ` -LiteralPath build/windows/store-ci.json -Encoding utf8NoBOM + - name: Verify Windows build prerequisites + shell: pwsh + run: ./tools/windows/check_prerequisites.ps1 | Format-List + + - name: Validate non-production CI identity + shell: pwsh + run: >- + ./tools/windows/validate_release_config.ps1 + -ConfigPath build/windows/store-ci.json + -Ci | Format-List + + - name: Run pinned Pester contract tests + shell: pwsh + run: | + $result = Invoke-Pester ` + -Path tools/windows/tests -PassThru -Output Detailed + if ($result.FailedCount -ne 0) { + throw 'Windows packaging contract tests failed.' + } + - name: Validate, compile, and package Windows x64 shell: pwsh run: >- From 38dac6d05f8d9350a5fdf6ccb974466fa62bfcd1 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 17:29:57 -0700 Subject: [PATCH 28/69] Separate Windows build and validation hosts --- docs/windows_development.md | 9 +++++++-- tools/windows/check_prerequisites.ps1 | 11 ++++++++--- tools/windows/install_test_package.ps1 | 2 +- tools/windows/run_wack.ps1 | 3 ++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/windows_development.md b/docs/windows_development.md index e763fd37..92a8db37 100644 --- a/docs/windows_development.md +++ b/docs/windows_development.md @@ -6,7 +6,10 @@ Flutter and Dart must come from the repository's pinned Flutter 3.44.4 SDK. ## Prerequisites -- 64-bit Windows 11 24H2 or newer. +- A 64-bit Windows build host for compilation and unsigned packaging. The + GitHub workflow uses `windows-latest`. +- 64-bit Windows 11 24H2 or newer for installing, exercising, signing, and + WACK-validating the release-equivalent package. - Flutter 3.44.4 with Windows desktop enabled. - Visual Studio 2022 with **Desktop development with C++**, including the x64 MSVC toolchain and CMake tools. @@ -15,7 +18,9 @@ Flutter and Dart must come from the repository's pinned Flutter 3.44.4 SDK. - Developer Mode for unpackaged development, or a local test certificate for installed MSIX testing. -Check the toolchain from a PowerShell prompt at the repository root: +Check the build toolchain from a PowerShell prompt at the repository root. +This requires SDK 10.0.26100.0 or newer and never lowers the MSIX manifest's +Windows 11 24H2 minimum: ```powershell flutter config --enable-windows-desktop diff --git a/tools/windows/check_prerequisites.ps1 b/tools/windows/check_prerequisites.ps1 index d9634bb3..c0273139 100644 --- a/tools/windows/check_prerequisites.ps1 +++ b/tools/windows/check_prerequisites.ps1 @@ -1,12 +1,15 @@ -param([switch]$RequireWack) +param( + [switch]$RequireWindows11, + [switch]$RequireWack +) . "$PSScriptRoot/common.ps1" if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitOperatingSystem) { throw 'BusyMax Windows builds require 64-bit Windows 11.' } -if ([Environment]::OSVersion.Version.Build -lt 26100) { - throw 'BusyMax Windows builds require Windows 11 24H2/build 26100 or newer.' +if ($RequireWindows11 -and [Environment]::OSVersion.Version.Build -lt 26100) { + throw 'Installed-package and WACK validation require Windows 11 24H2/build 26100 or newer.' } $flutterVersion = (& flutter --version --machine | ConvertFrom-Json).frameworkVersion if ($flutterVersion -ne '3.44.4') { @@ -45,4 +48,6 @@ if ($RequireWack) { VisualStudio = $visualStudio WindowsSdk = $sdk.Name WindowsSdkBin = Join-Path $sdk.FullName 'x64' + HostBuild = [Environment]::OSVersion.Version.Build + Windows11ValidationHost = [Environment]::OSVersion.Version.Build -ge 26100 } diff --git a/tools/windows/install_test_package.ps1 b/tools/windows/install_test_package.ps1 index d94472f0..6e159efe 100644 --- a/tools/windows/install_test_package.ps1 +++ b/tools/windows/install_test_package.ps1 @@ -5,7 +5,7 @@ param( ) . "$PSScriptRoot/common.ps1" -$prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" +$prerequisites = & "$PSScriptRoot/check_prerequisites.ps1" -RequireWindows11 $config = Get-BusyMaxStoreConfig -Path $ConfigPath Assert-BusyMaxStoreConfig -Config $config -Mode LocalTestSigning $password = Read-Host 'Local PFX password' -AsSecureString diff --git a/tools/windows/run_wack.ps1 b/tools/windows/run_wack.ps1 index 16ab3891..dfd1d050 100644 --- a/tools/windows/run_wack.ps1 +++ b/tools/windows/run_wack.ps1 @@ -14,7 +14,8 @@ if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { } $packageHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash -& "$PSScriptRoot/check_prerequisites.ps1" -RequireWack | Out-Host +& "$PSScriptRoot/check_prerequisites.ps1" ` + -RequireWindows11 -RequireWack | Out-Host $appCert = Join-Path ${env:ProgramFiles(x86)} ` 'Windows Kits\10\App Certification Kit\appcert.exe' $directory = Split-Path -Parent $ReportPath From 46c733aa1d161a4cf535f9bee6448e819bc538c5 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 17:34:19 -0700 Subject: [PATCH 29/69] Expose Windows toolchain CI gates --- .github/workflows/flutter-windows.yml | 49 ++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 65486899..c47bf828 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -57,7 +57,54 @@ jobs: } | ConvertTo-Json | Set-Content ` -LiteralPath build/windows/store-ci.json -Encoding utf8NoBOM - - name: Verify Windows build prerequisites + - name: Verify x64 Windows build host + shell: pwsh + run: | + if ($env:OS -ne 'Windows_NT' -or + -not [Environment]::Is64BitOperatingSystem) { + throw 'BusyMax Windows builds require 64-bit Windows.' + } + + - name: Verify pinned Flutter version + shell: pwsh + run: | + $version = (flutter --version --machine | + ConvertFrom-Json).frameworkVersion + if ($version -ne '3.44.4') { + throw "BusyMax requires Flutter 3.44.4; found $version." + } + + - name: Verify Visual Studio C++ toolchain + shell: pwsh + run: | + $vswhere = Join-Path ${env:ProgramFiles(x86)} ` + 'Microsoft Visual Studio\Installer\vswhere.exe' + $installation = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath + if ([string]::IsNullOrWhiteSpace($installation)) { + throw 'Visual Studio C++ x64 desktop tools were not found.' + } + + - name: Verify Windows SDK packaging tools + shell: pwsh + run: | + $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $sdk = Get-ChildItem -LiteralPath $kitsBin -Directory | + Where-Object { [version]$_.Name -ge [version]'10.0.26100.0' } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 + if ($null -eq $sdk) { + throw 'Windows SDK 10.0.26100.0 or newer is required.' + } + foreach ($tool in @('makeappx.exe', 'signtool.exe')) { + if (-not (Test-Path -LiteralPath ` + (Join-Path $sdk.FullName "x64\$tool"))) { + throw "$tool was not found in Windows SDK $($sdk.Name)." + } + } + + - name: Verify combined Windows build prerequisites shell: pwsh run: ./tools/windows/check_prerequisites.ps1 | Format-List From dce5fa5ffebca419cc42afaad3e6fa5c4b35b9d4 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 17:40:30 -0700 Subject: [PATCH 30/69] Fix Windows SDK discovery --- .github/workflows/flutter-windows.yml | 6 ++-- tools/windows/check_prerequisites.ps1 | 5 +-- tools/windows/common.ps1 | 12 +++++++ .../tests/prerequisite_contract.Tests.ps1 | 33 +++++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 tools/windows/tests/prerequisite_contract.Tests.ps1 diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index c47bf828..62b30da1 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -89,11 +89,9 @@ jobs: - name: Verify Windows SDK packaging tools shell: pwsh run: | + . ./tools/windows/common.ps1 $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' - $sdk = Get-ChildItem -LiteralPath $kitsBin -Directory | - Where-Object { [version]$_.Name -ge [version]'10.0.26100.0' } | - Sort-Object { [version]$_.Name } -Descending | - Select-Object -First 1 + $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin if ($null -eq $sdk) { throw 'Windows SDK 10.0.26100.0 or newer is required.' } diff --git a/tools/windows/check_prerequisites.ps1 b/tools/windows/check_prerequisites.ps1 index c0273139..8ed15d1e 100644 --- a/tools/windows/check_prerequisites.ps1 +++ b/tools/windows/check_prerequisites.ps1 @@ -26,10 +26,7 @@ if ([string]::IsNullOrWhiteSpace($visualStudio)) { throw 'Visual Studio C++ x64 desktop tools were not found.' } $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' -$sdk = Get-ChildItem -LiteralPath $kitsBin -Directory | - Where-Object { [version]$_.Name -ge [version]'10.0.26100.0' } | - Sort-Object { [version]$_.Name } -Descending | - Select-Object -First 1 +$sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin if ($null -eq $sdk) { throw 'Windows SDK 10.0.26100.0 or newer is required.' } foreach ($tool in @('makeappx.exe', 'signtool.exe')) { if (-not (Test-Path -LiteralPath (Join-Path $sdk.FullName "x64\$tool"))) { diff --git a/tools/windows/common.ps1 b/tools/windows/common.ps1 index 94704651..755389ae 100644 --- a/tools/windows/common.ps1 +++ b/tools/windows/common.ps1 @@ -9,6 +9,18 @@ function Get-BusyMaxStoreConfig { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } +function Get-BusyMaxWindowsSdkDirectory { + param( + [Parameter(Mandatory)][string]$KitsBin, + [version]$MinimumVersion = [version]'10.0.26100.0' + ) + return Get-ChildItem -LiteralPath $KitsBin -Directory | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Where-Object { [version]$_.Name -ge $MinimumVersion } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 +} + function Test-BusyMaxPlaceholder { param([AllowNull()][string]$Value) return [string]::IsNullOrWhiteSpace($Value) -or diff --git a/tools/windows/tests/prerequisite_contract.Tests.ps1 b/tools/windows/tests/prerequisite_contract.Tests.ps1 new file mode 100644 index 00000000..35756b22 --- /dev/null +++ b/tools/windows/tests/prerequisite_contract.Tests.ps1 @@ -0,0 +1,33 @@ +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'common.ps1') +} + +Describe 'BusyMax Windows SDK discovery' { + It 'ignores architecture directories and selects the newest supported SDK' { + $kitsBin = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $kitsBin | Out-Null + foreach ($directory in @( + 'arm', 'arm64', 'x64', 'x86', '10.0.22621.0', + '10.0.26100.0', '10.0.30000.0')) { + New-Item -ItemType Directory -Path (Join-Path $kitsBin $directory) | + Out-Null + } + + $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin + + $sdk.Name | Should -Be '10.0.30000.0' + } + + It 'returns no SDK when only architecture and unsupported folders exist' { + $kitsBin = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $kitsBin | Out-Null + foreach ($directory in @('arm64', 'x64', '10.0.22621.0', 'not-a-version')) { + New-Item -ItemType Directory -Path (Join-Path $kitsBin $directory) | + Out-Null + } + + $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin + + $sdk | Should -BeNullOrEmpty + } +} From cf98ceac44356a62733f95a42efaffb15f84a560 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 17:45:15 -0700 Subject: [PATCH 31/69] Fix Windows package family derivation --- tools/windows/common.ps1 | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tools/windows/common.ps1 b/tools/windows/common.ps1 index 755389ae..721dd9c2 100644 --- a/tools/windows/common.ps1 +++ b/tools/windows/common.ps1 @@ -165,7 +165,9 @@ function Get-BusyMaxPackageFamilyName { if (-not ('BusyMax.PackageIdentity' -as [type])) { Add-Type -TypeDefinition @' using System; +using System.ComponentModel; using System.Runtime.InteropServices; +using System.Text; namespace BusyMax { public static class PackageIdentity { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] @@ -181,17 +183,21 @@ namespace BusyMax { [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern int PackageFamilyNameFromId( ref PACKAGE_ID packageId, ref uint packageFamilyNameLength, - System.Text.StringBuilder packageFamilyName); + StringBuilder packageFamilyName); public static string FamilyName(string name, string publisher) { - var id = new PACKAGE_ID { name = name, publisher = publisher, - resourceId = "", publisherId = "" }; - uint length = 0; - PackageFamilyNameFromId(ref id, ref length, null); - var value = new System.Text.StringBuilder((int)length); + var id = new PACKAGE_ID { name = name, publisher = publisher }; + // PACKAGE_FAMILY_NAME_MAX_LENGTH is 64 characters, excluding the + // terminating null. A fixed buffer avoids a nullable output pointer in + // managed interop and follows Microsoft's documented C# example. + uint length = 65; + var value = new StringBuilder((int)length); int result = PackageFamilyNameFromId(ref id, ref length, value); - if (result != 0) throw new InvalidOperationException( - "PackageFamilyNameFromId failed: " + result); - return value.ToString(); + if (result != 0) throw new Win32Exception(result, + "PackageFamilyNameFromId failed"); + var familyName = value.ToString(); + if (String.IsNullOrWhiteSpace(familyName)) throw new InvalidOperationException( + "PackageFamilyNameFromId returned an empty package family name"); + return familyName; } } } From f2d6c32ff945bd1983434b012889d430ef6f8c4e Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 18:00:08 -0700 Subject: [PATCH 32/69] Expose staged Windows release gates --- .github/workflows/flutter-windows.yml | 61 ++++++++-- tools/windows/build_release.ps1 | 159 +++++++++++++++++--------- 2 files changed, 154 insertions(+), 66 deletions(-) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 62b30da1..bd462463 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -115,21 +115,63 @@ jobs: - name: Run pinned Pester contract tests shell: pwsh - run: | - $result = Invoke-Pester ` - -Path tools/windows/tests -PassThru -Output Detailed - if ($result.FailedCount -ne 0) { - throw 'Windows packaging contract tests failed.' - } + run: >- + ./tools/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage PesterTests - - name: Validate, compile, and package Windows x64 + - name: Restore dependencies and generate sources shell: pwsh run: >- ./tools/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json - -Ci + -Ci -Stage SourceGeneration + + - name: Check formatting, analysis, and platform boundaries + shell: pwsh + run: >- + ./tools/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage StaticAnalysis + + - name: Run Dart and Flutter tests + shell: pwsh + run: >- + ./tools/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage FlutterTests + + - name: Compile Windows x64 release + shell: pwsh + run: >- + ./tools/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage WindowsCompile + + - name: Run native C++ tests + shell: pwsh + run: >- + ./tools/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage NativeTests + + - name: Pack and inspect exact MSIX + shell: pwsh + run: >- + ./tools/windows/build_release.ps1 + -ConfigPath build/windows/store-ci.json + -Ci -Stage Package + + - name: Upload Windows test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: busymax-windows-x64-test-reports + path: build/windows/test-results/** + if-no-files-found: warn - - name: Upload unsigned CI MSIX and test reports + - name: Upload unsigned CI MSIX and package evidence + if: success() uses: actions/upload-artifact@v7 with: name: busymax-windows-x64-ci @@ -138,5 +180,4 @@ jobs: build/windows/store/staging/AppxManifest.xml build/windows/store/*inventory.json build/windows/store/*metadata.json - build/windows/test-results/** if-no-files-found: error diff --git a/tools/windows/build_release.ps1 b/tools/windows/build_release.ps1 index 8710e1ba..65b739f7 100644 --- a/tools/windows/build_release.ps1 +++ b/tools/windows/build_release.ps1 @@ -1,6 +1,10 @@ param( [Parameter(Mandatory)][string]$ConfigPath, - [switch]$Ci + [switch]$Ci, + [ValidateSet( + 'All', 'PesterTests', 'SourceGeneration', 'StaticAnalysis', + 'FlutterTests', 'WindowsCompile', 'NativeTests', 'Package')] + [string]$Stage = 'All' ) . "$PSScriptRoot/common.ps1" @@ -8,63 +12,106 @@ param( $identity = & "$PSScriptRoot/validate_release_config.ps1" ` -ConfigPath $ConfigPath -Ci:$Ci $config = Get-BusyMaxStoreConfig -Path $ConfigPath -$pesterModule = Get-Module -ListAvailable Pester | - Sort-Object Version -Descending | Select-Object -First 1 -if ($null -eq $pesterModule -or $pesterModule.Version -lt [version]'5.0.0') { - throw 'Pester 5 is required to run the Windows packaging contract tests.' + +function Invoke-BusyMaxPesterTests { + $pesterModule = Get-Module -ListAvailable Pester | + Sort-Object Version -Descending | Select-Object -First 1 + if ($null -eq $pesterModule -or $pesterModule.Version -lt [version]'5.0.0') { + throw 'Pester 5 is required to run the Windows packaging contract tests.' + } + Import-Module Pester -MinimumVersion 5.0.0 + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + $pester = Invoke-Pester ` + -Path 'tools\windows\tests' -PassThru -Output Detailed + [pscustomobject]@{ + Result = [string]$pester.Result + PassedCount = $pester.PassedCount + FailedCount = $pester.FailedCount + SkippedCount = $pester.SkippedCount + } | ConvertTo-Json | Set-Content ` + -LiteralPath 'build\windows\test-results\pester-summary.json' ` + -Encoding utf8NoBOM + if ($pester.FailedCount -ne 0) { + throw 'Windows packaging contract tests failed.' + } } -Import-Module Pester -MinimumVersion 5.0.0 -New-Item -ItemType Directory -Force -Path 'build\windows\test-results' | Out-Null -$pester = Invoke-Pester -Path 'tools\windows\tests' -PassThru -Output Detailed -[pscustomobject]@{ - Result = [string]$pester.Result - PassedCount = $pester.PassedCount - FailedCount = $pester.FailedCount - SkippedCount = $pester.SkippedCount -} | ConvertTo-Json | Set-Content ` - -LiteralPath 'build\windows\test-results\pester-summary.json' ` - -Encoding utf8NoBOM -if ($pester.FailedCount -ne 0) { throw 'Windows packaging contract tests failed.' } -& flutter pub get -if ($LASTEXITCODE -ne 0) { throw 'Dependency resolution failed.' } -& flutter gen-l10n -if ($LASTEXITCODE -ne 0) { throw 'Localization generation failed.' } -& dart run build_runner build --delete-conflicting-outputs --force-jit -if ($LASTEXITCODE -ne 0) { throw 'Code generation failed.' } -& git diff --exit-code -- lib/l10n/generated lib/src/db -if ($LASTEXITCODE -ne 0) { throw 'Generated files are not committed.' } -& dart format --output=none --set-exit-if-changed . -if ($LASTEXITCODE -ne 0) { throw 'Formatting check failed.' } -& flutter analyze -if ($LASTEXITCODE -ne 0) { throw 'Flutter analysis failed.' } -& flutter test --machine | Tee-Object ` - -FilePath 'build\windows\test-results\flutter-tests.jsonl' -if ($LASTEXITCODE -ne 0) { throw 'Flutter tests failed.' } -& dart run tool/check_platform_boundaries.dart -if ($LASTEXITCODE -ne 0) { throw 'Platform boundary validation failed.' } +function Invoke-BusyMaxSourceGeneration { + & flutter pub get + if ($LASTEXITCODE -ne 0) { throw 'Dependency resolution failed.' } + & flutter gen-l10n + if ($LASTEXITCODE -ne 0) { throw 'Localization generation failed.' } + & dart run build_runner build --delete-conflicting-outputs --force-jit + if ($LASTEXITCODE -ne 0) { throw 'Code generation failed.' } + & git diff --exit-code -- lib/l10n/generated lib/src/db + if ($LASTEXITCODE -ne 0) { throw 'Generated files are not committed.' } +} -$defines = @( - "--dart-define=BUSYMAX_WINDOWS_STORE_MODE=true", - "--dart-define=BUSYMAX_WINDOWS_AUMID=$($identity.AppUserModelId)", - "--dart-define=BUSYMAX_MSIX_VERSION=$($config.msixVersion)", - "--dart-define=BUSYMAX_PRIVACY_POLICY_URL=$($config.privacyPolicyUrl)", - "--dart-define=BUSYMAX_SUPPORT_URL=$($config.supportUrl)", - "--dart-define=BUSYMAX_HOMEPAGE_URL=$($config.homepageUrl)", - "--dart-define=GOOGLE_OAUTH_CLIENT_ID=$($config.googleOAuthClientId)", - "--dart-define=MICROSOFT_OAUTH_CLIENT_ID=$($config.microsoftOAuthClientId)", - "--dart-define=MICROSOFT_OAUTH_AUTHORITY_TENANT=$($config.microsoftOAuthAuthorityTenant)", - '--dart-define=BUSYMAX_FAKE_DATA=false' -) -if (-not [string]::IsNullOrWhiteSpace($config.googleOAuthClientSecret)) { - $defines += "--dart-define=GOOGLE_OAUTH_CLIENT_SECRET=$($config.googleOAuthClientSecret)" +function Invoke-BusyMaxStaticAnalysis { + & dart format --output=none --set-exit-if-changed . + if ($LASTEXITCODE -ne 0) { throw 'Formatting check failed.' } + & flutter analyze + if ($LASTEXITCODE -ne 0) { throw 'Flutter analysis failed.' } + & dart run tool/check_platform_boundaries.dart + if ($LASTEXITCODE -ne 0) { throw 'Platform boundary validation failed.' } } -& flutter build windows --release -t lib/main_windows.dart @defines -if ($LASTEXITCODE -ne 0) { throw 'Flutter Windows release build failed.' } -& "$PSScriptRoot/run_native_tests.ps1" -if ($LASTEXITCODE -ne 0) { throw 'Windows native tests failed.' } -$metadata = & "$PSScriptRoot/package_store.ps1" ` - -ConfigPath $ConfigPath -Ci:$Ci -$metadata | Format-List | Out-Host -$metadata +function Invoke-BusyMaxFlutterTests { + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + & flutter test --machine | Tee-Object ` + -FilePath 'build\windows\test-results\flutter-tests.jsonl' + if ($LASTEXITCODE -ne 0) { throw 'Flutter tests failed.' } +} + +function Invoke-BusyMaxWindowsCompile { + $defines = @( + '--dart-define=BUSYMAX_WINDOWS_STORE_MODE=true', + "--dart-define=BUSYMAX_WINDOWS_AUMID=$($identity.AppUserModelId)", + "--dart-define=BUSYMAX_MSIX_VERSION=$($config.msixVersion)", + "--dart-define=BUSYMAX_PRIVACY_POLICY_URL=$($config.privacyPolicyUrl)", + "--dart-define=BUSYMAX_SUPPORT_URL=$($config.supportUrl)", + "--dart-define=BUSYMAX_HOMEPAGE_URL=$($config.homepageUrl)", + "--dart-define=GOOGLE_OAUTH_CLIENT_ID=$($config.googleOAuthClientId)", + "--dart-define=MICROSOFT_OAUTH_CLIENT_ID=$($config.microsoftOAuthClientId)", + "--dart-define=MICROSOFT_OAUTH_AUTHORITY_TENANT=$($config.microsoftOAuthAuthorityTenant)", + '--dart-define=BUSYMAX_FAKE_DATA=false' + ) + if (-not [string]::IsNullOrWhiteSpace($config.googleOAuthClientSecret)) { + $defines += "--dart-define=GOOGLE_OAUTH_CLIENT_SECRET=$($config.googleOAuthClientSecret)" + } + & flutter build windows --release -t lib/main_windows.dart @defines + if ($LASTEXITCODE -ne 0) { throw 'Flutter Windows release build failed.' } +} + +function Invoke-BusyMaxNativeTests { + & "$PSScriptRoot/run_native_tests.ps1" + if ($LASTEXITCODE -ne 0) { throw 'Windows native tests failed.' } +} + +function Invoke-BusyMaxPackage { + $metadata = & "$PSScriptRoot/package_store.ps1" ` + -ConfigPath $ConfigPath -Ci:$Ci + $metadata | Format-List | Out-Host + $metadata +} + +switch ($Stage) { + 'PesterTests' { Invoke-BusyMaxPesterTests } + 'SourceGeneration' { Invoke-BusyMaxSourceGeneration } + 'StaticAnalysis' { Invoke-BusyMaxStaticAnalysis } + 'FlutterTests' { Invoke-BusyMaxFlutterTests } + 'WindowsCompile' { Invoke-BusyMaxWindowsCompile } + 'NativeTests' { Invoke-BusyMaxNativeTests } + 'Package' { Invoke-BusyMaxPackage } + 'All' { + Invoke-BusyMaxPesterTests + Invoke-BusyMaxSourceGeneration + Invoke-BusyMaxStaticAnalysis + Invoke-BusyMaxFlutterTests + Invoke-BusyMaxWindowsCompile + Invoke-BusyMaxNativeTests + Invoke-BusyMaxPackage + } +} From b28d75bfab358dc46be8f87d3f0052469b70b106 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 18:13:02 -0700 Subject: [PATCH 33/69] Annotate Windows Flutter test failures --- .github/workflows/flutter-windows.yml | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index bd462463..fae0b26e 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -135,12 +135,46 @@ jobs: -Ci -Stage StaticAnalysis - name: Run Dart and Flutter tests + id: flutter-tests shell: pwsh run: >- ./tools/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage FlutterTests + - name: Report failing Flutter tests + if: ${{ failure() && steps.flutter-tests.conclusion == 'failure' }} + shell: pwsh + run: | + $report = 'build/windows/test-results/flutter-tests.jsonl' + if (-not (Test-Path -LiteralPath $report -PathType Leaf)) { + Write-Host '::error title=Windows Flutter tests failed::The machine test report was not created.' + exit 0 + } + $names = @{} + $failedIds = [System.Collections.Generic.HashSet[int]]::new() + foreach ($line in Get-Content -LiteralPath $report) { + try { $event = $line | ConvertFrom-Json } catch { continue } + if ($event.type -eq 'testStart') { + $names[[int]$event.test.id] = [string]$event.test.name + } + if ($event.type -eq 'testDone' -and $event.result -eq 'error') { + $failedIds.Add([int]$event.testID) | Out-Null + } + } + if ($failedIds.Count -eq 0) { + Write-Host '::error title=Windows Flutter tests failed::No failing test ID was present in the machine report.' + } + foreach ($testId in $failedIds) { + $name = if ($names.ContainsKey($testId)) { + $names[$testId] + } else { + "Unknown test ID $testId" + } + $safeName = $name.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Host "::error title=Windows Flutter test failed::$safeName" + } + - name: Compile Windows x64 release shell: pwsh run: >- From 076020f18eb7263e586cfb14c7e9911ffd0d8b22 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 18:29:13 -0700 Subject: [PATCH 34/69] Scope Linux presentation tests to Linux --- test/app/about_dialog_test.dart | 2 +- test/app/keyboard_shortcuts_dialog_test.dart | 2 +- .../schedule/presentation/schedule_workspace_states_test.dart | 2 ++ test/features/settings/presentation/settings_screen_test.dart | 3 ++- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 3074e993..9e4f6d91 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -66,7 +66,7 @@ void main() { tester.getSize(find.byType(YaruWindowControl)), const Size.square(kYaruWindowControlSize), ); - }); + }, skip: !Platform.isLinux); testWidgets( 'informational titlebar retains its semantic high-contrast divider', diff --git a/test/app/keyboard_shortcuts_dialog_test.dart b/test/app/keyboard_shortcuts_dialog_test.dart index ab1c95d4..92e17793 100644 --- a/test/app/keyboard_shortcuts_dialog_test.dart +++ b/test/app/keyboard_shortcuts_dialog_test.dart @@ -85,7 +85,7 @@ void main() { 'F9', ].map((label) => tester.getTopRight(find.text(label)).dx).toList(); expect(badgeEnds.every((end) => end == badgeEnds.first), isTrue); - }); + }, skip: !Platform.isLinux); for (final textScale in [1.0, 2.0]) { testWidgets('keyboard shortcuts remain scrollable in a short window at ' diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index d9261a0e..a98ea017 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; @@ -285,6 +286,7 @@ void main() { containsPair('searchActive', false), ); }, + skip: !Platform.isLinux, ); } diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 3eb205e7..1af4d855 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -582,7 +583,7 @@ void main() { expect(find.text('Система'), findsNWidgets(2)); expect(find.text('Системная'), findsWidgets); - }); + }, skip: !Platform.isLinux); testWidgets('Settings workspace uses the native window surface', ( tester, From 5ee0614d8999a1d502cc31957ddbe3898866ebad Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 18:42:50 -0700 Subject: [PATCH 35/69] Report Windows test runner failures --- .github/workflows/flutter-windows.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index fae0b26e..a6666de3 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -153,17 +153,37 @@ jobs: } $names = @{} $failedIds = [System.Collections.Generic.HashSet[int]]::new() + $runnerErrors = [System.Collections.Generic.List[string]]::new() + $doneSuccess = $null foreach ($line in Get-Content -LiteralPath $report) { try { $event = $line | ConvertFrom-Json } catch { continue } if ($event.type -eq 'testStart') { $names[[int]$event.test.id] = [string]$event.test.name } - if ($event.type -eq 'testDone' -and $event.result -eq 'error') { + if ($event.type -eq 'testDone' -and + $event.result -notin @('success', 'skipped')) { $failedIds.Add([int]$event.testID) | Out-Null } + if ($event.type -eq 'error') { + if ($null -ne $event.testID) { + $failedIds.Add([int]$event.testID) | Out-Null + } + $summary = ([string]$event.error -split '\r?\n', 2)[0] + if (-not [string]::IsNullOrWhiteSpace($summary)) { + $runnerErrors.Add($summary) | Out-Null + } + } + if ($event.type -eq 'done') { + $doneSuccess = $event.success + } } if ($failedIds.Count -eq 0) { - Write-Host '::error title=Windows Flutter tests failed::No failing test ID was present in the machine report.' + $summary = "No failing test ID was present; machine success=$doneSuccess." + if ($runnerErrors.Count -gt 0) { + $summary += " Runner error: $($runnerErrors[0])" + } + $safeSummary = $summary.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + Write-Host "::error title=Windows Flutter tests failed::$safeSummary" } foreach ($testId in $failedIds) { $name = if ($names.ContainsKey($testId)) { From 0ac74f1264946a4960c4bd2d37a43f4d45a8091b Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 18:56:45 -0700 Subject: [PATCH 36/69] Make source audits portable to Windows --- .gitattributes | 8 ++++++++ test/app/native_ui_audit_test.dart | 29 +++++++++++++++------------ test/app/theme_localization_test.dart | 5 +++-- 3 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f66f76b3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +*.cer binary +*.ico binary +*.msix binary +*.pfx binary +*.png binary +*.zip binary diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index b8320410..b388e60f 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -2316,14 +2316,13 @@ void main() { final matches = []; for (final file in _dartFilesIn('lib')) { final lines = file.readAsLinesSync(); + final path = _normalizedPath(file); for (var index = 0; index < lines.length; index++) { final line = lines[index]; - final location = '${file.path}:${index + 1}'; + final location = '$path:${index + 1}'; if (line.contains('fontFamily:') && - !file.path.endsWith('lib/src/app/busymax_yaru_theme.dart') && - !file.path.endsWith( - 'lib/src/app/windows/windows_busymax_app.dart', - )) { + !path.endsWith('lib/src/app/busymax_yaru_theme.dart') && + !path.endsWith('lib/src/app/windows/windows_busymax_app.dart')) { matches.add('$location: $line'); } if (line.contains('fontSize:') && @@ -2477,11 +2476,12 @@ void main() { for (final file in files) { final lines = file.readAsLinesSync(); + final path = _normalizedPath(file); for (var index = 0; index < lines.length; index++) { final line = lines[index]; - final location = '${file.path}:${index + 1}'; + final location = '$path:${index + 1}'; final isSharedConfirmationFallback = - file.path.endsWith('lib/src/app/busymax_design.dart') && + path.endsWith('lib/src/app/busymax_design.dart') && line.contains('return AlertDialog('); expect( line.contains('AlertDialog'), @@ -2566,7 +2566,7 @@ bool _hasRawMenuItemButton(String line) { } bool _hasRawPopupMenuEntry(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + if (_normalizedPath(file).endsWith('lib/src/app/busymax_design.dart')) { return false; } return line.contains('PopupMenuItem') || @@ -2574,7 +2574,7 @@ bool _hasRawPopupMenuEntry(File file, String line) { } bool _hasRawMenuAnchor(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + if (_normalizedPath(file).endsWith('lib/src/app/busymax_design.dart')) { return false; } return RegExp(r'\bMenuAnchor\s*\(').hasMatch(line); @@ -2589,7 +2589,7 @@ bool _hasRawSwitch(String line) { } bool _hasRawIconButton(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_design.dart')) { + if (_normalizedPath(file).endsWith('lib/src/app/busymax_design.dart')) { return false; } return line.contains('IconButton(') && @@ -2599,15 +2599,18 @@ bool _hasRawIconButton(File file, String line) { } bool _isAllowedFontSizeException(File file, String line) { - if (file.path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { + final path = _normalizedPath(file); + if (path.endsWith('lib/src/app/busymax_yaru_theme.dart')) { return true; } - if (file.path.endsWith('lib/src/app/busymax_app.dart') && + if (path.endsWith('lib/src/app/busymax_app.dart') && line.contains('theme.tooltipTheme.textStyle?.fontSize')) { return true; } - return file.path.endsWith( + return path.endsWith( 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', ) && line.contains('fontSize: 0'); } + +String _normalizedPath(File file) => file.path.replaceAll('\\', '/'); diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index b8805b72..5a9f84ce 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -2182,13 +2182,14 @@ void main() { if (entry is! File) { continue; } + final path = entry.path.replaceAll('\\', '/'); final text = utf8.decode(entry.readAsBytesSync(), allowMalformed: true); - final fileForbidden = entry.path == centralizedAccentMapping + final fileForbidden = path == centralizedAccentMapping ? forbidden : [...forbidden, 'YaruVariant.', 'YaruColors.']; for (final token in fileForbidden) { if (text.contains(token)) { - matches.add('${entry.path}: $token'); + matches.add('$path: $token'); } } } From 22dd7bd70f89aefb5d1fcac156b74b08b4af0ea4 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 19:09:16 -0700 Subject: [PATCH 37/69] Scope Linux integration tests to Linux --- test/google_tasks/oauth/oauth_token_store_test.dart | 1 + test/tools/build_install_snap_local_test.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test/google_tasks/oauth/oauth_token_store_test.dart b/test/google_tasks/oauth/oauth_token_store_test.dart index 82a151a8..b5d3a684 100644 --- a/test/google_tasks/oauth/oauth_token_store_test.dart +++ b/test/google_tasks/oauth/oauth_token_store_test.dart @@ -280,6 +280,7 @@ void main() { expect((await storageFile.stat()).mode & 0x1ff, 0x180); expect((await storageFile.parent.stat()).mode & 0x1ff, 0x1c0); }, + skip: !Platform.isLinux, ); test('portal encrypted token store maps portal failures', () async { diff --git a/test/tools/build_install_snap_local_test.dart b/test/tools/build_install_snap_local_test.dart index 43e777cb..80f2aa01 100644 --- a/test/tools/build_install_snap_local_test.dart +++ b/test/tools/build_install_snap_local_test.dart @@ -249,7 +249,7 @@ void main() { expect(fixture.commandLogContents, isNot(contains('sudo\tsnap install'))); expect(victim.readAsStringSync(), 'do not delete'); }); - }); + }, skip: !Platform.isLinux); } Future _expectUnsafeRootRejected( From 6d3964a36570b0afc33f8e0814acddad49f90750 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 19:09:50 -0700 Subject: [PATCH 38/69] Use accent color for About version tag --- lib/src/app/busymax_about_dialog.dart | 9 ++++----- test/app/about_dialog_test.dart | 12 +++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index 0fa8cbc2..5edb7cdc 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -10,7 +10,6 @@ import '../platform/linux_header_bar_service.dart'; import 'busymax_design.dart'; import 'busymax_dialog_identity.dart'; import 'busymax_dialogs.dart'; -import 'busymax_surface_colors.dart'; const _busyMaxWebsiteUrl = 'https://busystack.org'; const _busyMaxRepositoryUrl = 'https://github.com/busystack/busymax/'; @@ -142,13 +141,13 @@ class _VersionTag extends StatelessWidget { return const SizedBox.shrink(); } final theme = Theme.of(context); - final colors = BusyMaxSurfaceColors.of(context); + final colorScheme = theme.colorScheme; return Center( child: DecoratedBox( decoration: BoxDecoration( - color: colors.control, + color: colorScheme.primary, borderRadius: BorderRadius.circular(BusyMaxRadius.pill), - border: Border.all(color: colors.divider), + border: Border.all(color: colorScheme.primary), ), child: Padding( padding: const EdgeInsets.symmetric( @@ -163,7 +162,7 @@ class _VersionTag extends StatelessWidget { overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: theme.textTheme.labelMedium?.copyWith( - color: colors.foreground, + color: colorScheme.onPrimary, fontWeight: FontWeight.w600, ), ), diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index 9e4f6d91..3d4738c4 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -224,7 +224,7 @@ void main() { ); for (final brightness in Brightness.values) { - testWidgets('about version badge uses the shared neutral outlined pill in ' + testWidgets('about version badge uses the active accent pill in ' '$brightness', (tester) async { const accent = Color(0xFF3584E4); _setPackageInfo(version: '1.2.3', buildNumber: ''); @@ -240,7 +240,6 @@ void main() { expect(find.text('v1.2.3'), findsOneWidget); expect(find.text('v1.2.3+'), findsNothing); - final colors = theme.extension()!; final badgeFinder = find.byWidgetPredicate( (widget) => widget is DecoratedBox && @@ -250,12 +249,15 @@ void main() { ); final badge = tester.widget(badgeFinder); final decoration = badge.decoration as BoxDecoration; - expect(decoration.color, colors.control); + expect(decoration.color, theme.colorScheme.primary); expect( decoration.borderRadius, BorderRadius.circular(BusyMaxRadius.pill), ); - expect((decoration.border! as Border).top.color, colors.divider); + expect( + (decoration.border! as Border).top.color, + theme.colorScheme.primary, + ); expect( find.ancestor( of: find.text('v1.2.3'), @@ -284,7 +286,7 @@ void main() { ); final versionText = tester.widget(find.text('v1.2.3')); final textColor = versionText.style!.color!; - expect(textColor, colors.foreground); + expect(textColor, theme.colorScheme.onPrimary); expect(versionText.style?.fontWeight, FontWeight.w600); }); } From c8cb3ee05a034cdab7957acd9601fa1deec61a06 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 19:26:05 -0700 Subject: [PATCH 39/69] Capture Windows compiler diagnostics --- .github/workflows/flutter-windows.yml | 26 ++++++++++++++++++++++++++ tools/windows/build_release.ps1 | 10 ++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index a6666de3..fa947413 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -196,12 +196,38 @@ jobs: } - name: Compile Windows x64 release + id: windows-compile shell: pwsh run: >- ./tools/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage WindowsCompile + - name: Report Windows compiler failures + if: ${{ failure() && steps.windows-compile.conclusion == 'failure' }} + shell: pwsh + run: | + $log = 'build/windows/test-results/windows-build.log' + if (-not (Test-Path -LiteralPath $log -PathType Leaf)) { + Write-Host '::error title=Windows release compilation failed::The compiler log was not created.' + exit 0 + } + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { + $_ -match '(?i)(cmake error|fatal error|error C\d+|LNK\d+|ninja:.*failed|error:)' + } | Select-Object -Unique -First 20) + if ($diagnostics.Count -eq 0) { + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Last 1) + } + foreach ($diagnostic in $diagnostics) { + $safe = ([string]$diagnostic).Replace('%', '%25') + $safe = $safe.Replace("`r", '%0D').Replace("`n", '%0A') + if ($safe.Length -gt 1000) { $safe = $safe.Substring(0, 1000) } + Write-Host "::error title=Windows release compilation failed::$safe" + } + - name: Run native C++ tests shell: pwsh run: >- diff --git a/tools/windows/build_release.ps1 b/tools/windows/build_release.ps1 index 65b739f7..84c80a12 100644 --- a/tools/windows/build_release.ps1 +++ b/tools/windows/build_release.ps1 @@ -81,8 +81,14 @@ function Invoke-BusyMaxWindowsCompile { if (-not [string]::IsNullOrWhiteSpace($config.googleOAuthClientSecret)) { $defines += "--dart-define=GOOGLE_OAUTH_CLIENT_SECRET=$($config.googleOAuthClientSecret)" } - & flutter build windows --release -t lib/main_windows.dart @defines - if ($LASTEXITCODE -ne 0) { throw 'Flutter Windows release build failed.' } + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + & flutter build windows --release -t lib/main_windows.dart @defines 2>&1 | + Tee-Object -FilePath 'build\windows\test-results\windows-build.log' + $compileExitCode = $LASTEXITCODE + if ($compileExitCode -ne 0) { + throw "Flutter Windows release build failed with exit code $compileExitCode." + } } function Invoke-BusyMaxNativeTests { From fa58b099c984c91cd40dc0472c2a32417950ba9e Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 19:41:21 -0700 Subject: [PATCH 40/69] Fix Windows timezone SDK compilation --- .../flutter_timezone/windows/timezone_native_api.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/third_party/flutter_timezone/windows/timezone_native_api.cpp b/third_party/flutter_timezone/windows/timezone_native_api.cpp index 13dc9a59..e99735dd 100644 --- a/third_party/flutter_timezone/windows/timezone_native_api.cpp +++ b/third_party/flutter_timezone/windows/timezone_native_api.cpp @@ -7,6 +7,11 @@ namespace flutter_timezone { namespace { +// GetUserDefaultGeoName requires space for at least 85 UTF-16 code units. +// GEO_NAME_LENGTH is not exposed by every supported Windows SDK header set, +// so keep the documented capacity local to this narrow Win32 adapter. +constexpr size_t kGeoNameCapacity = 85; + TimezoneOperationResult Failure(const char* code, const char* message) { TimezoneOperationResult result{}; result.error_code = code; @@ -108,12 +113,13 @@ TimezoneOperationResult ResolveLocalTimezone(TimezoneNativeApi& api) { _countof(information.TimeZoneKeyName)); if (key_length == 0 || key_length >= _countof(information.TimeZoneKeyName) || - key_length > static_cast(std::numeric_limits::max())) { + key_length > + static_cast((std::numeric_limits::max)())) { return Failure("windows-timezone-key-invalid", "Windows returned an invalid time-zone key."); } - std::array geo_buffer{}; + std::array geo_buffer{}; const int geo_length = api.GetUserDefaultGeoName( geo_buffer.data(), static_cast(geo_buffer.size())); if (geo_length <= 0 || geo_length > static_cast(geo_buffer.size())) { From 64a2802663df4b8fb887ff69f6fe0acb08717fc9 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 19:55:24 -0700 Subject: [PATCH 41/69] Fix Windows runner SDK compilation --- windows/runner/CMakeLists.txt | 5 +++++ windows/runner/flutter_window.cpp | 1 + windows/runner/windows_startup_task.cpp | 1 + 3 files changed, 7 insertions(+) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 419e1354..132429a4 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -32,6 +32,11 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTT # Disable Windows macros that collide with C++ standard library functions. target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") +# Flutter 3.44.4 targets C++17. C++/WinRT therefore uses the legacy coroutine +# header, which current MSVC toolsets require consumers to acknowledge. +target_compile_definitions(${BINARY_NAME} PRIVATE + "_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS") + # Add dependency libraries and include directories. Add any application-specific # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 7f83e4c7..cae08c35 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,5 +1,6 @@ #include "flutter_window.h" +#include #include #include #include diff --git a/windows/runner/windows_startup_task.cpp b/windows/runner/windows_startup_task.cpp index 5ee814ce..8a46d3e8 100644 --- a/windows/runner/windows_startup_task.cpp +++ b/windows/runner/windows_startup_task.cpp @@ -1,6 +1,7 @@ #include "windows_startup_task.h" #include +#include #include #include From 9976395c6cf04dcc91ba58e6b13192157a3c6c23 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 20:09:55 -0700 Subject: [PATCH 42/69] Capture Windows native test diagnostics --- .github/workflows/flutter-windows.yml | 29 +++++++++++++++++++++++++++ tools/windows/run_native_tests.ps1 | 29 +++++++++++++++++++-------- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index fa947413..6f80ddeb 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -229,12 +229,41 @@ jobs: } - name: Run native C++ tests + id: native-tests shell: pwsh run: >- ./tools/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage NativeTests + - name: Report native C++ test failures + if: ${{ failure() && steps.native-tests.conclusion == 'failure' }} + shell: pwsh + run: | + $logs = @(Get-ChildItem -Path 'build/windows/test-results' ` + -Filter 'native-*.log' -File -ErrorAction SilentlyContinue) + if ($logs.Count -eq 0) { + Write-Host '::error title=Windows native tests failed::No native-test log was created.' + exit 0 + } + $diagnostics = @($logs | ForEach-Object { + Get-Content -LiteralPath $_.FullName + } | Where-Object { + $_ -match '(?i)(cmake error|fatal error|error C\d+|LNK\d+|ninja:.*failed|FAILED|tests failed|exception)' + } | Select-Object -Unique -First 20) + if ($diagnostics.Count -eq 0) { + $diagnostics = @($logs | ForEach-Object { + Get-Content -LiteralPath $_.FullName + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Last 10) + } + foreach ($diagnostic in $diagnostics) { + $safe = ([string]$diagnostic).Replace('%', '%25') + $safe = $safe.Replace("`r", '%0D').Replace("`n", '%0A') + if ($safe.Length -gt 1000) { $safe = $safe.Substring(0, 1000) } + Write-Host "::error title=Windows native tests failed::$safe" + } + - name: Pack and inspect exact MSIX shell: pwsh run: >- diff --git a/tools/windows/run_native_tests.ps1 b/tools/windows/run_native_tests.ps1 index aa1a6df1..8e437e3e 100644 --- a/tools/windows/run_native_tests.ps1 +++ b/tools/windows/run_native_tests.ps1 @@ -10,15 +10,28 @@ if (-not $IsWindows) { throw 'BusyMax native tests require Windows.' } if (-not (Test-Path -LiteralPath (Join-Path $BuildDirectory 'CMakeCache.txt'))) { throw "The configured Windows CMake build was not found: $BuildDirectory" } +$resultDirectory = 'build\windows\test-results' +New-Item -ItemType Directory -Force -Path $resultDirectory | Out-Null & cmake -S windows -B $BuildDirectory ` -Dinclude_flutter_timezone_tests=ON ` - -Dinclude_tray_manager_tests=ON -if ($LASTEXITCODE -ne 0) { throw 'BusyMax native test configuration failed.' } + -Dinclude_tray_manager_tests=ON 2>&1 | Tee-Object ` + -FilePath (Join-Path $resultDirectory 'native-configure.log') +$configureExitCode = $LASTEXITCODE +if ($configureExitCode -ne 0) { + throw "BusyMax native test configuration failed with exit code $configureExitCode." +} & cmake --build $BuildDirectory --config $Configuration ` - --target busymax_runner_native_test flutter_timezone_test tray_manager_test -if ($LASTEXITCODE -ne 0) { throw 'BusyMax native test compilation failed.' } -$resultDirectory = 'build\windows\test-results' -New-Item -ItemType Directory -Force -Path $resultDirectory | Out-Null + --target busymax_runner_native_test flutter_timezone_test tray_manager_test ` + 2>&1 | Tee-Object ` + -FilePath (Join-Path $resultDirectory 'native-build.log') +$buildExitCode = $LASTEXITCODE +if ($buildExitCode -ne 0) { + throw "BusyMax native test compilation failed with exit code $buildExitCode." +} & ctest --test-dir $BuildDirectory -C $Configuration --output-on-failure ` - --output-junit (Join-Path $resultDirectory 'native-tests.xml') -if ($LASTEXITCODE -ne 0) { throw 'BusyMax native tests failed.' } + --output-junit (Join-Path $resultDirectory 'native-tests.xml') 2>&1 | + Tee-Object -FilePath (Join-Path $resultDirectory 'native-tests.log') +$testExitCode = $LASTEXITCODE +if ($testExitCode -ne 0) { + throw "BusyMax native tests failed with exit code $testExitCode." +} From 24c64ee678e00979780a9b1064540e54554729c2 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 20:27:41 -0700 Subject: [PATCH 43/69] Update Windows native test dependency --- third_party/flutter_timezone/windows/CMakeLists.txt | 4 +++- third_party/tray_manager/windows/CMakeLists.txt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/third_party/flutter_timezone/windows/CMakeLists.txt b/third_party/flutter_timezone/windows/CMakeLists.txt index 6515daf1..eeac1318 100644 --- a/third_party/flutter_timezone/windows/CMakeLists.txt +++ b/third_party/flutter_timezone/windows/CMakeLists.txt @@ -73,7 +73,9 @@ enable_testing() include(FetchContent) FetchContent_Declare( googletest - URL https://github.com/google/googletest/archive/release-1.11.0.zip + URL https://github.com/google/googletest/archive/refs/tags/v1.18.0.zip + URL_HASH + SHA256=63b9c77751a5b8f492486005f67533fdc58682b67476fcb91650be5958d5195a ) # Prevent overriding the parent project's compiler/linker settings set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) diff --git a/third_party/tray_manager/windows/CMakeLists.txt b/third_party/tray_manager/windows/CMakeLists.txt index 99b41362..ac9a94eb 100644 --- a/third_party/tray_manager/windows/CMakeLists.txt +++ b/third_party/tray_manager/windows/CMakeLists.txt @@ -33,7 +33,9 @@ if (${include_${PROJECT_NAME}_tests}) include(FetchContent) FetchContent_Declare( googletest - URL https://github.com/google/googletest/archive/release-1.11.0.zip + URL https://github.com/google/googletest/archive/refs/tags/v1.18.0.zip + URL_HASH + SHA256=63b9c77751a5b8f492486005f67533fdc58682b67476fcb91650be5958d5195a ) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) From 58614344fe67f5023a3a6c2a07f353aaec292710 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 20:44:55 -0700 Subject: [PATCH 44/69] Complete Windows timezone test types --- .../windows/test/flutter_timezone_plugin_test.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp b/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp index fc21d43e..9604a26d 100644 --- a/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp +++ b/third_party/flutter_timezone/windows/test/flutter_timezone_plugin_test.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include From 9703c8b8a149b34572858f30acb963946022effc Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 21:01:58 -0700 Subject: [PATCH 45/69] Capture exact MSIX packaging diagnostics --- .github/workflows/flutter-windows.yml | 26 ++++++++++++++++++++++++++ tools/windows/build_release.ps1 | 22 ++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 6f80ddeb..f6891949 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -265,12 +265,38 @@ jobs: } - name: Pack and inspect exact MSIX + id: package shell: pwsh run: >- ./tools/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage Package + - name: Report exact-MSIX packaging failures + if: ${{ failure() && steps.package.conclusion == 'failure' }} + shell: pwsh + run: | + $log = 'build/windows/test-results/package-store.log' + if (-not (Test-Path -LiteralPath $log -PathType Leaf)) { + Write-Host '::error title=Exact MSIX packaging failed::The package log was not created.' + exit 0 + } + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { + $_ -match '(?i)(makeappx.*error|error APPX|throw|exception|failed|missing|prohibited|unexpected|does not|differs|invalid|cannot|could not|refusing)' + } | Select-Object -Unique -First 20) + if ($diagnostics.Count -eq 0) { + $diagnostics = @(Get-Content -LiteralPath $log | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Last 10) + } + foreach ($diagnostic in $diagnostics) { + $safe = ([string]$diagnostic).Replace('%', '%25') + $safe = $safe.Replace("`r", '%0D').Replace("`n", '%0A') + if ($safe.Length -gt 1000) { $safe = $safe.Substring(0, 1000) } + Write-Host "::error title=Exact MSIX packaging failed::$safe" + } + - name: Upload Windows test reports if: always() uses: actions/upload-artifact@v7 diff --git a/tools/windows/build_release.ps1 b/tools/windows/build_release.ps1 index 84c80a12..8a9d78c1 100644 --- a/tools/windows/build_release.ps1 +++ b/tools/windows/build_release.ps1 @@ -97,8 +97,26 @@ function Invoke-BusyMaxNativeTests { } function Invoke-BusyMaxPackage { - $metadata = & "$PSScriptRoot/package_store.ps1" ` - -ConfigPath $ConfigPath -Ci:$Ci + New-Item -ItemType Directory -Force ` + -Path 'build\windows\test-results' | Out-Null + $logPath = 'build\windows\test-results\package-store.log' + try { + $packageOutput = @(& "$PSScriptRoot/package_store.ps1" ` + -ConfigPath $ConfigPath -Ci:$Ci *>&1 | + Tee-Object -FilePath $logPath) + } catch { + ($_ | Out-String).TrimEnd() | Add-Content -LiteralPath $logPath + throw + } + $metadata = @($packageOutput | Where-Object { + $_ -is [pscustomobject] -and + $_.PSObject.Properties.Name -contains 'Package' -and + $_.PSObject.Properties.Name -contains 'SHA256' + } | Select-Object -Last 1) + if ($metadata.Count -ne 1) { + throw 'Windows package generation did not return package metadata.' + } + $metadata = $metadata[0] $metadata | Format-List | Out-Host $metadata } From c44fe1f8aa2a547ac58b8dd03d11c0a383553182 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 21:19:49 -0700 Subject: [PATCH 46/69] Handle Visual Studio runtime aliases --- tools/windows/common.ps1 | 41 ++++++++++++++++--- .../tests/prerequisite_contract.Tests.ps1 | 27 ++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/tools/windows/common.ps1 b/tools/windows/common.ps1 index 721dd9c2..5a1b3083 100644 --- a/tools/windows/common.ps1 +++ b/tools/windows/common.ps1 @@ -237,18 +237,47 @@ function Get-BusyMaxResourceXml { }) -join "`r`n" } +function Get-BusyMaxVCRuntimeDirectory { + param([Parameter(Mandatory)][string]$RedistRoot) + $rankedDirectories = @(Get-ChildItem -LiteralPath $RedistRoot -Directory | + ForEach-Object { + $directory = $_ + try { + [pscustomobject]@{ + Directory = $directory + IsNumericVersion = 1 + Version = [version]$directory.Name + } + } catch { + # Recent Visual Studio layouts can include aliases such as `v145` + # beside numeric redistributable versions. Keep them as a fallback. + [pscustomobject]@{ + Directory = $directory + IsNumericVersion = 0 + Version = [version]'0.0' + } + } + } | Sort-Object -Property ` + @{ Expression = 'IsNumericVersion'; Descending = $true }, ` + @{ Expression = 'Version'; Descending = $true }, ` + @{ Expression = { $_.Directory.Name }; Descending = $true }) + foreach ($entry in $rankedDirectories) { + $candidate = Get-ChildItem -LiteralPath ` + (Join-Path $entry.Directory.FullName 'x64') -Directory ` + -Filter 'Microsoft.VC*.CRT' -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | Select-Object -First 1 + if ($null -ne $candidate) { return $candidate } + } + return $null +} + function Copy-BusyMaxVCRuntime { param( [Parameter(Mandatory)][string]$VisualStudioPath, [Parameter(Mandatory)][string]$Destination ) $redistRoot = Join-Path $VisualStudioPath 'VC\Redist\MSVC' - $crt = Get-ChildItem -LiteralPath $redistRoot -Directory | - Sort-Object { [version]$_.Name } -Descending | - ForEach-Object { - Get-ChildItem -LiteralPath (Join-Path $_.FullName 'x64') ` - -Directory -Filter 'Microsoft.VC*.CRT' -ErrorAction SilentlyContinue - } | Select-Object -First 1 + $crt = Get-BusyMaxVCRuntimeDirectory -RedistRoot $redistRoot if ($null -eq $crt) { throw 'The Visual Studio x64 VC runtime redistributable was not found.' } diff --git a/tools/windows/tests/prerequisite_contract.Tests.ps1 b/tools/windows/tests/prerequisite_contract.Tests.ps1 index 35756b22..ae21019c 100644 --- a/tools/windows/tests/prerequisite_contract.Tests.ps1 +++ b/tools/windows/tests/prerequisite_contract.Tests.ps1 @@ -31,3 +31,30 @@ Describe 'BusyMax Windows SDK discovery' { $sdk | Should -BeNullOrEmpty } } + +Describe 'BusyMax Visual C++ runtime discovery' { + It 'prefers the newest numeric runtime and tolerates toolset aliases' { + $visualStudio = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $redistRoot = Join-Path $visualStudio 'VC\Redist\MSVC' + $numericCrt = Join-Path $redistRoot ` + '14.50.35717\x64\Microsoft.VC145.CRT' + $aliasCrt = Join-Path $redistRoot 'v145\x64\Microsoft.VC145.CRT' + New-Item -ItemType Directory -Path $numericCrt -Force | Out-Null + New-Item -ItemType Directory -Path $aliasCrt -Force | Out-Null + foreach ($name in @( + 'msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll')) { + Set-Content -LiteralPath (Join-Path $numericCrt $name) ` + -Value 'numeric' -Encoding ascii + Set-Content -LiteralPath (Join-Path $aliasCrt $name) ` + -Value 'alias' -Encoding ascii + } + $destination = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $destination | Out-Null + + Copy-BusyMaxVCRuntime -VisualStudioPath $visualStudio ` + -Destination $destination + + Get-Content -LiteralPath (Join-Path $destination 'msvcp140.dll') | + Should -Be 'numeric' + } +} From 0348830f92052a51613769ce1bb0561fab8f227e Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 21:39:01 -0700 Subject: [PATCH 47/69] Validate Flutter Windows AOT format --- tools/windows/validate_package_contents.ps1 | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/windows/validate_package_contents.ps1 b/tools/windows/validate_package_contents.ps1 index 827278be..3f171882 100644 --- a/tools/windows/validate_package_contents.ps1 +++ b/tools/windows/validate_package_contents.ps1 @@ -105,10 +105,22 @@ function Assert-BusyMaxX64Pe { foreach ($nativeBinary in @( Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.exe' Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.dll' - Get-Item -LiteralPath (Join-Path $PackageRoot 'data/app.so') )) { Assert-BusyMaxX64Pe -Path $nativeBinary.FullName } +function Assert-BusyMaxX64Elf { + param([Parameter(Mandatory)][string]$Path) + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 20 -or + $bytes[0] -ne 0x7f -or $bytes[1] -ne 0x45 -or + $bytes[2] -ne 0x4c -or $bytes[3] -ne 0x46 -or + $bytes[4] -ne 2 -or $bytes[5] -ne 1 -or + [BitConverter]::ToUInt16($bytes, 18) -ne 0x3e) { + throw "Flutter AOT artifact is not a little-endian x64 ELF file: $Path" + } +} +Assert-BusyMaxX64Elf -Path ` + (Join-Path $PackageRoot 'data/app.so') $manifestPath = Join-Path $PackageRoot 'AppxManifest.xml' & "$PSScriptRoot/validate_manifest.ps1" -ManifestPath $manifestPath [xml]$packageManifest = Get-Content -LiteralPath $manifestPath -Raw From 7f71cace58bf1fcb55528a11fd0f12e39476e7b4 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 21:55:53 -0700 Subject: [PATCH 48/69] Use schema-valid toast CLSID values --- test/platform/windows/windows_packaging_contract_test.dart | 2 +- tools/windows/AppxManifest.xml.template | 4 ++-- tools/windows/tests/manifest_contract.Tests.ps1 | 4 ++-- tools/windows/validate_manifest.ps1 | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/platform/windows/windows_packaging_contract_test.dart b/test/platform/windows/windows_packaging_contract_test.dart index 1fb569fb..88be459c 100644 --- a/test/platform/windows/windows_packaging_contract_test.dart +++ b/test/platform/windows/windows_packaging_contract_test.dart @@ -80,7 +80,7 @@ void main() { expect(ignorableNamespaces, isNot(contains('desktop4'))); expect(manifestSource, isNot(contains('xmlns:desktop4='))); final comClsid = elements('Class').single.getAttribute('Id'); - expect(toastClsid, '{$busyMaxToastActivatorClsid}'); + expect(toastClsid, busyMaxToastActivatorClsid); expect(comClsid, toastClsid); }); diff --git a/tools/windows/AppxManifest.xml.template b/tools/windows/AppxManifest.xml.template index 7e03407c..910ad66f 100644 --- a/tools/windows/AppxManifest.xml.template +++ b/tools/windows/AppxManifest.xml.template @@ -72,7 +72,7 @@ + ToastActivatorCLSID="7B854A6D-8B2A-45A5-B998-1F51EC5A81D7" /> @@ -80,7 +80,7 @@ Executable="busymax.exe" Arguments="----AppNotificationActivationServer" DisplayName="BusyMax notification activator"> - + diff --git a/tools/windows/tests/manifest_contract.Tests.ps1 b/tools/windows/tests/manifest_contract.Tests.ps1 index 7daf71e5..6cb00b4c 100644 --- a/tools/windows/tests/manifest_contract.Tests.ps1 +++ b/tools/windows/tests/manifest_contract.Tests.ps1 @@ -59,8 +59,8 @@ Describe 'BusyMax rendered manifest contract' { It 'rejects a COM class that differs from the toast activator CLSID' { $rendered = (New-BusyMaxRenderedTestManifest).Replace( - '', - '') + '', + '') $rendered | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM { & (Join-Path $tools 'validate_manifest.ps1') ` -ManifestPath $manifestPath } | diff --git a/tools/windows/validate_manifest.ps1 b/tools/windows/validate_manifest.ps1 index aa5fef92..7d36d7fa 100644 --- a/tools/windows/validate_manifest.ps1 +++ b/tools/windows/validate_manifest.ps1 @@ -140,7 +140,7 @@ $startupArguments = $startupExtension.GetAttribute( if ($startup.Enabled -ne 'false' -or $startupArguments -ne '--start-minimized') { throw 'StartupTask must be disabled by default and start minimized.' } -if ($toast.ToastActivatorCLSID -ne '{7B854A6D-8B2A-45A5-B998-1F51EC5A81D7}') { +if ($toast.ToastActivatorCLSID -cne '7B854A6D-8B2A-45A5-B998-1F51EC5A81D7') { throw 'Toast activator CLSID does not match the committed notification backend.' } if ($toastExtension.NamespaceURI -cne 'http://schemas.microsoft.com/appx/manifest/desktop/windows10' -or From 7d4cada71eb09620dc613bd78e46ab273f9dc728 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 22:22:28 -0700 Subject: [PATCH 49/69] Archive Windows release environment evidence --- .github/workflows/flutter-windows.yml | 9 ++++++++- tools/windows/check_prerequisites.ps1 | 14 ++++++++++++++ tools/windows/run_native_tests.ps1 | 4 +++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index f6891949..9246e1c8 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -104,7 +104,14 @@ jobs: - name: Verify combined Windows build prerequisites shell: pwsh - run: ./tools/windows/check_prerequisites.ps1 | Format-List + run: | + New-Item -ItemType Directory -Force ` + -Path build/windows/test-results | Out-Null + $environment = ./tools/windows/check_prerequisites.ps1 + $environment | ConvertTo-Json | Set-Content ` + -LiteralPath build/windows/test-results/windows-environment.json ` + -Encoding utf8NoBOM + $environment | Format-List - name: Validate non-production CI identity shell: pwsh diff --git a/tools/windows/check_prerequisites.ps1 b/tools/windows/check_prerequisites.ps1 index 8ed15d1e..ba7913a3 100644 --- a/tools/windows/check_prerequisites.ps1 +++ b/tools/windows/check_prerequisites.ps1 @@ -25,6 +25,12 @@ $visualStudio = & $vswhere -latest -products * ` if ([string]::IsNullOrWhiteSpace($visualStudio)) { throw 'Visual Studio C++ x64 desktop tools were not found.' } +$visualStudioVersion = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationVersion +$visualStudioDisplayName = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property displayName $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin if ($null -eq $sdk) { throw 'Windows SDK 10.0.26100.0 or newer is required.' } @@ -40,11 +46,19 @@ if ($RequireWack) { throw 'The Windows App Certification Kit is required.' } } +$windowsVersion = Get-ItemProperty -LiteralPath ` + 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' [pscustomobject]@{ Flutter = $flutterVersion VisualStudio = $visualStudio + VisualStudioDisplayName = $visualStudioDisplayName + VisualStudioVersion = $visualStudioVersion WindowsSdk = $sdk.Name WindowsSdkBin = Join-Path $sdk.FullName 'x64' + HostProductName = [string]$windowsVersion.ProductName + HostEditionId = [string]$windowsVersion.EditionID + HostDisplayVersion = [string]$windowsVersion.DisplayVersion + HostVersion = [Environment]::OSVersion.Version.ToString() HostBuild = [Environment]::OSVersion.Version.Build Windows11ValidationHost = [Environment]::OSVersion.Version.Build -ge 26100 } diff --git a/tools/windows/run_native_tests.ps1 b/tools/windows/run_native_tests.ps1 index 8e437e3e..0ffff5bd 100644 --- a/tools/windows/run_native_tests.ps1 +++ b/tools/windows/run_native_tests.ps1 @@ -28,8 +28,10 @@ $buildExitCode = $LASTEXITCODE if ($buildExitCode -ne 0) { throw "BusyMax native test compilation failed with exit code $buildExitCode." } +$junitPath = [IO.Path]::GetFullPath( + (Join-Path $resultDirectory 'native-tests.xml')) & ctest --test-dir $BuildDirectory -C $Configuration --output-on-failure ` - --output-junit (Join-Path $resultDirectory 'native-tests.xml') 2>&1 | + --output-junit $junitPath 2>&1 | Tee-Object -FilePath (Join-Path $resultDirectory 'native-tests.log') $testExitCode = $LASTEXITCODE if ($testExitCode -ne 0) { From 988f505d1b18bbc727ba85693df4422d8d75e5df Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 23:33:26 -0700 Subject: [PATCH 50/69] Harden Windows validation evidence gates --- tools/windows/check_prerequisites.ps1 | 22 ++++- tools/windows/common.ps1 | 89 +++++++++++++++++++ tools/windows/package_store.ps1 | 18 +++- .../tests/package_contents_contract.Tests.ps1 | 61 +++++++++++++ .../tests/prerequisite_contract.Tests.ps1 | 23 +++++ tools/windows/validate_package_contents.ps1 | 66 ++++++-------- 6 files changed, 233 insertions(+), 46 deletions(-) create mode 100644 tools/windows/tests/package_contents_contract.Tests.ps1 diff --git a/tools/windows/check_prerequisites.ps1 b/tools/windows/check_prerequisites.ps1 index ba7913a3..0fd008c6 100644 --- a/tools/windows/check_prerequisites.ps1 +++ b/tools/windows/check_prerequisites.ps1 @@ -8,8 +8,21 @@ param( if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitOperatingSystem) { throw 'BusyMax Windows builds require 64-bit Windows 11.' } -if ($RequireWindows11 -and [Environment]::OSVersion.Version.Build -lt 26100) { - throw 'Installed-package and WACK validation require Windows 11 24H2/build 26100 or newer.' +$operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem +if ($null -eq $operatingSystem) { + throw 'Win32_OperatingSystem could not be queried for Windows validation.' +} +try { + $hostBuild = [int]$operatingSystem.BuildNumber + $hostProductType = [uint32]$operatingSystem.ProductType +} catch { + throw "Win32_OperatingSystem returned invalid validation data: $($_.Exception.Message)" +} +$windows11ValidationHost = Test-BusyMaxWindows11ValidationHost ` + -Build $hostBuild -ProductType $hostProductType +if ($RequireWindows11) { + Assert-BusyMaxWindows11ValidationHost -Build $hostBuild ` + -ProductType $hostProductType } $flutterVersion = (& flutter --version --machine | ConvertFrom-Json).frameworkVersion if ($flutterVersion -ne '3.44.4') { @@ -59,6 +72,7 @@ $windowsVersion = Get-ItemProperty -LiteralPath ` HostEditionId = [string]$windowsVersion.EditionID HostDisplayVersion = [string]$windowsVersion.DisplayVersion HostVersion = [Environment]::OSVersion.Version.ToString() - HostBuild = [Environment]::OSVersion.Version.Build - Windows11ValidationHost = [Environment]::OSVersion.Version.Build -ge 26100 + HostBuild = $hostBuild + HostProductType = $hostProductType + Windows11ValidationHost = $windows11ValidationHost } diff --git a/tools/windows/common.ps1 b/tools/windows/common.ps1 index 5a1b3083..20c2d3f0 100644 --- a/tools/windows/common.ps1 +++ b/tools/windows/common.ps1 @@ -21,6 +21,95 @@ function Get-BusyMaxWindowsSdkDirectory { Select-Object -First 1 } +function Test-BusyMaxWindows11ValidationHost { + param( + [Parameter(Mandatory)][int]$Build, + [Parameter(Mandatory)][uint32]$ProductType + ) + return $ProductType -eq 1 -and $Build -ge 26100 +} + +function Assert-BusyMaxWindows11ValidationHost { + param( + [Parameter(Mandatory)][int]$Build, + [Parameter(Mandatory)][uint32]$ProductType + ) + if ($ProductType -ne 1) { + throw "Installed-package and WACK validation require a Windows 11 workstation (Win32_OperatingSystem.ProductType 1); found ProductType $ProductType." + } + if ($Build -lt 26100) { + throw "Installed-package and WACK validation require Windows 11 24H2/build 26100 or newer; found build $Build." + } +} + +function Get-BusyMaxPackageFiles { + param([Parameter(Mandatory)][string]$PackageRoot) + if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container)) { + throw "Package root does not exist: $PackageRoot" + } + return @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File -Force) +} + +function Get-BusyMaxPackageRelativePath { + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)][string]$Path + ) + return [IO.Path]::GetRelativePath($PackageRoot, $Path).Replace('\', '/') +} + +function Test-BusyMaxProhibitedPackageFile { + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)][IO.FileInfo]$File + ) + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $File.FullName + return $File.Extension -in @( + '.pfx', '.cer', '.log', '.db', '.sqlite', '.dart', '.cc', '.h', '.pdb', + '.cpp', '.c', '.hpp', '.cmake', '.ps1', '.jsonl', '.yaml', '.yml', + '.md', '.p12', '.pem', '.key', '.crt', '.p7x', '.snap', '.AppImage' + ) -or + ($File.Extension -eq '.so' -and $relative -cne 'data/app.so') -or + $File.Name -match '(?i)(^|[._-])(test|fake|credential|secret|token|development)([._-]|$)|flutter_logo' -or + $relative -match '(?i)(^|/)(linux|snap|snapcraft)(/|$)' +} + +function Assert-BusyMaxPackageHasNoProhibitedFiles { + param([Parameter(Mandatory)][string]$PackageRoot) + $prohibited = @(Get-BusyMaxPackageFiles -PackageRoot $PackageRoot | + Where-Object { + Test-BusyMaxProhibitedPackageFile -PackageRoot $PackageRoot -File $_ + }) + if ($prohibited.Count -gt 0) { + throw "Prohibited package content: $($prohibited.FullName -join ', ')" + } +} + +function Assert-BusyMaxFinalMsixMetadata { + param([Parameter(Mandatory)][string]$PackageRoot) + foreach ($metadataFile in @('AppxBlockMap.xml', '[Content_Types].xml')) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageRoot $metadataFile) ` + -PathType Leaf)) { + throw "Final MSIX metadata is missing: $metadataFile" + } + } +} + +function Get-BusyMaxPackageInventory { + param([Parameter(Mandatory)][string]$PackageRoot) + return @(Get-BusyMaxPackageFiles -PackageRoot $PackageRoot | + Sort-Object FullName | + ForEach-Object { + [pscustomobject]@{ + Path = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName + Length = $_.Length + SHA256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + }) +} + function Test-BusyMaxPlaceholder { param([AllowNull()][string]$Value) return [string]::IsNullOrWhiteSpace($Value) -or diff --git a/tools/windows/package_store.ps1 b/tools/windows/package_store.ps1 index 409963f5..a371f899 100644 --- a/tools/windows/package_store.ps1 +++ b/tools/windows/package_store.ps1 @@ -81,7 +81,8 @@ foreach ($asset in $logicalAssets.GetEnumerator()) { -MaximumTestedVersion $prerequisites.WindowsSdk -Ci:$Ci $stagingInventoryPath = Join-Path $OutputDirectory 'staging-inventory.json' $stagingInventory = @(& "$PSScriptRoot/validate_package_contents.ps1" ` - -PackageRoot $staging -InventoryOutputPath $stagingInventoryPath) + -PackageRoot $staging -InventoryOutputPath $stagingInventoryPath ` + -ValidationMode Staging) if (Test-Path -LiteralPath $package) { Remove-Item -LiteralPath $package -Force } $makeAppx = Join-Path $prerequisites.WindowsSdkBin 'makeappx.exe' & $makeAppx pack /v /d $staging /p $package /o @@ -95,13 +96,22 @@ if (Test-Path -LiteralPath $unpacked) { if ($LASTEXITCODE -ne 0) { throw 'MakeAppx could not unpack the final MSIX.' } $finalInventoryPath = Join-Path $OutputDirectory 'final-msix-inventory.json' $finalInventory = @(& "$PSScriptRoot/validate_package_contents.ps1" ` - -PackageRoot $unpacked -InventoryOutputPath $finalInventoryPath) + -PackageRoot $unpacked -InventoryOutputPath $finalInventoryPath ` + -ValidationMode FinalMsix) $packageMetadataFiles = @('AppxBlockMap.xml', '[Content_Types].xml') +$finalInventoryObjects = @($finalInventory | Where-Object { + $_ -is [pscustomobject] +}) +foreach ($metadataFile in $packageMetadataFiles) { + if ($metadataFile -cnotin $finalInventoryObjects.Path) { + throw "Final MSIX metadata is absent from the exact-package inventory: $metadataFile" + } +} $expectedInventory = @($stagingInventory | Where-Object { $_ -is [pscustomobject] }) -$actualInventory = @($finalInventory | Where-Object { - $_ -is [pscustomobject] -and $_.Path -notin $packageMetadataFiles +$actualInventory = @($finalInventoryObjects | Where-Object { + $_.Path -notin $packageMetadataFiles }) $expectedLines = @($expectedInventory | ForEach-Object { "$($_.Path)|$($_.Length)|$($_.SHA256)" diff --git a/tools/windows/tests/package_contents_contract.Tests.ps1 b/tools/windows/tests/package_contents_contract.Tests.ps1 new file mode 100644 index 00000000..b4422b2d --- /dev/null +++ b/tools/windows/tests/package_contents_contract.Tests.ps1 @@ -0,0 +1,61 @@ +BeforeAll { + $script:tools = Split-Path -Parent $PSScriptRoot + . (Join-Path $script:tools 'common.ps1') +} + +Describe 'BusyMax exact-package file inspection' { + BeforeEach { + $packageRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $packageRoot | Out-Null + } + + It 'rejects a hidden prohibited package file' { + $hiddenSecret = New-Item -ItemType File ` + -Path (Join-Path $packageRoot '.hidden-secret.key') + if ($IsWindows) { + $hiddenSecret.Attributes = $hiddenSecret.Attributes -bor ` + [IO.FileAttributes]::Hidden + } + + { Assert-BusyMaxPackageHasNoProhibitedFiles -PackageRoot $packageRoot } | + Should -Throw -ExpectedMessage '*Prohibited package content*hidden-secret.key*' + } + + It 'requires and inventories hidden final-MSIX metadata' { + $metadataNames = @('AppxBlockMap.xml', '[Content_Types].xml') + foreach ($metadataName in $metadataNames) { + $metadataPath = Join-Path $packageRoot $metadataName + [IO.File]::WriteAllText($metadataPath, 'package metadata') + $metadata = Get-Item -LiteralPath $metadataPath + if ($IsWindows) { + $metadata.Attributes = $metadata.Attributes -bor ` + [IO.FileAttributes]::Hidden + } + } + + { Assert-BusyMaxFinalMsixMetadata -PackageRoot $packageRoot } | + Should -Not -Throw + $inventory = @(Get-BusyMaxPackageInventory -PackageRoot $packageRoot) + $inventory.Path | Should -Contain 'AppxBlockMap.xml' + $inventory.Path | Should -Contain '[Content_Types].xml' + } + + It 'rejects a final-MSIX inventory missing either metadata file' { + [IO.File]::WriteAllText( + (Join-Path $packageRoot 'AppxBlockMap.xml'), 'package metadata') + + { Assert-BusyMaxFinalMsixMetadata -PackageRoot $packageRoot } | + Should -Throw -ExpectedMessage '*Content_Types*.xml*' + } + + It 'wires distinct staging and final-MSIX validation modes' { + $validator = Get-Content ` + -LiteralPath (Join-Path $tools 'validate_package_contents.ps1') -Raw + $packager = Get-Content ` + -LiteralPath (Join-Path $tools 'package_store.ps1') -Raw + + $validator | Should -Match "ValidateSet\('Staging', 'FinalMsix'\)" + $packager | Should -Match '-ValidationMode Staging' + $packager | Should -Match '-ValidationMode FinalMsix' + } +} diff --git a/tools/windows/tests/prerequisite_contract.Tests.ps1 b/tools/windows/tests/prerequisite_contract.Tests.ps1 index ae21019c..6d2d076b 100644 --- a/tools/windows/tests/prerequisite_contract.Tests.ps1 +++ b/tools/windows/tests/prerequisite_contract.Tests.ps1 @@ -32,6 +32,29 @@ Describe 'BusyMax Windows SDK discovery' { } } +Describe 'BusyMax Windows 11 validation-host detection' { + It 'accepts Windows 11 24H2 workstation build 26100' { + Test-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 1 | + Should -BeTrue + { Assert-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 1 } | + Should -Not -Throw + } + + It 'rejects Windows Server build 26100' { + Test-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 3 | + Should -BeFalse + { Assert-BusyMaxWindows11ValidationHost -Build 26100 -ProductType 3 } | + Should -Throw -ExpectedMessage '*ProductType 1*found ProductType 3*' + } + + It 'rejects a workstation older than Windows 11 24H2' { + Test-BusyMaxWindows11ValidationHost -Build 22631 -ProductType 1 | + Should -BeFalse + { Assert-BusyMaxWindows11ValidationHost -Build 22631 -ProductType 1 } | + Should -Throw -ExpectedMessage '*build 26100*found build 22631*' + } +} + Describe 'BusyMax Visual C++ runtime discovery' { It 'prefers the newest numeric runtime and tolerates toolset aliases' { $visualStudio = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) diff --git a/tools/windows/validate_package_contents.ps1 b/tools/windows/validate_package_contents.ps1 index 3f171882..c3ecb8cc 100644 --- a/tools/windows/validate_package_contents.ps1 +++ b/tools/windows/validate_package_contents.ps1 @@ -1,8 +1,16 @@ param( [Parameter(Mandatory)][Alias('StagingPath')][string]$PackageRoot, - [string]$InventoryOutputPath = '' + [string]$InventoryOutputPath = '', + [ValidateSet('Staging', 'FinalMsix')][string]$ValidationMode = 'Staging' ) +. "$PSScriptRoot/common.ps1" + +if ($ValidationMode -eq 'FinalMsix') { + Assert-BusyMaxFinalMsixMetadata -PackageRoot $PackageRoot +} +$packageFiles = @(Get-BusyMaxPackageFiles -PackageRoot $PackageRoot) + if (-not (Test-Path -LiteralPath "$PackageRoot/busymax.exe")) { throw 'Staging directory does not contain busymax.exe.' } @@ -31,41 +39,30 @@ foreach ($asset in $allowedAssets) { throw "Required unqualified package visual asset is missing: $asset" } } -$unexpectedAssets = @(Get-ChildItem ` - -LiteralPath (Join-Path $PackageRoot 'Assets') -Recurse -File | +$unexpectedAssets = @($packageFiles | Where-Object { - $relative = [IO.Path]::GetRelativePath( - $PackageRoot, $_.FullName).Replace('\', '/') - $relative -notin $allowedAssets + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName + $relative.StartsWith('Assets/', [StringComparison]::OrdinalIgnoreCase) -and + $relative -notin $allowedAssets }) if ($unexpectedAssets.Count -gt 0) { throw "Unexpected or unreferenced package visual asset: $($unexpectedAssets.FullName -join ', ')" } -$qualifiedVisualAssets = @(Get-ChildItem ` - -LiteralPath (Join-Path $PackageRoot 'Assets') -File | - Where-Object { $_.Name -match '(?i)\.(scale|targetsize)-' }) +$qualifiedVisualAssets = @($packageFiles | Where-Object { + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName + $relative.StartsWith('Assets/', [StringComparison]::OrdinalIgnoreCase) -and + $_.Name -match '(?i)\.(scale|targetsize)-' + }) if ($qualifiedVisualAssets.Count -gt 0) { throw 'Qualified visual assets require a generated resources.pri and must not be staged by the unqualified-asset packaging path.' } if (Test-Path -LiteralPath (Join-Path $PackageRoot 'resources.pri')) { throw 'The unqualified-asset packaging path must not contain a stale resources.pri.' } -$prohibited = Get-ChildItem -LiteralPath $PackageRoot -Recurse -File | - Where-Object { - $relative = [IO.Path]::GetRelativePath($PackageRoot, $_.FullName).Replace('\', '/') - $_.Extension -in @( - '.pfx', '.cer', '.log', '.db', '.sqlite', '.dart', '.cc', '.h', '.pdb', - '.cpp', '.c', '.hpp', '.cmake', '.ps1', '.jsonl', '.yaml', '.yml', - '.md', '.p12', '.pem', '.key', '.crt', '.p7x', '.snap', '.AppImage' - ) -or - ($_.Extension -eq '.so' -and $relative -cne 'data/app.so') -or - $_.Name -match '(?i)(^|[._-])(test|fake|credential|secret|token|development)([._-]|$)|flutter_logo' -or - $relative -match '(?i)(^|/)(linux|snap|snapcraft)(/|$)' - } -if ($prohibited) { - throw "Prohibited package content: $($prohibited.FullName -join ', ')" -} -$executables = @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File -Filter '*.exe') +Assert-BusyMaxPackageHasNoProhibitedFiles -PackageRoot $PackageRoot +$executables = @($packageFiles | Where-Object { $_.Extension -ieq '.exe' }) if ($executables.Count -ne 1 -or $executables[0].Name -cne 'busymax.exe') { throw "The package must contain only busymax.exe: $($executables.FullName -join ', ')" } @@ -80,9 +77,10 @@ $allowedDlls = @( 'msvcp140_atomic_wait.dll', 'msvcp140_codecvt_ids.dll', 'vccorlib140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll', 'vcruntime140_threads.dll' ) -$unexpectedDlls = @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File -Filter '*.dll' | +$unexpectedDlls = @($packageFiles | Where-Object { $_.Extension -ieq '.dll' } | Where-Object { - $relative = [IO.Path]::GetRelativePath($PackageRoot, $_.FullName).Replace('\', '/') + $relative = Get-BusyMaxPackageRelativePath -PackageRoot $PackageRoot ` + -Path $_.FullName $_.Name -notin $allowedDlls -or $relative -cne $_.Name }) if ($unexpectedDlls) { @@ -103,8 +101,8 @@ function Assert-BusyMaxX64Pe { } } foreach ($nativeBinary in @( - Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.exe' - Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.dll' + Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.exe' -Force + Get-ChildItem -LiteralPath $PackageRoot -File -Filter '*.dll' -Force )) { Assert-BusyMaxX64Pe -Path $nativeBinary.FullName } @@ -163,15 +161,7 @@ foreach ($reference in $logoReferences) { } } -$inventory = @(Get-ChildItem -LiteralPath $PackageRoot -Recurse -File | - Sort-Object FullName | - ForEach-Object { - [pscustomobject]@{ - Path = [IO.Path]::GetRelativePath($PackageRoot, $_.FullName).Replace('\', '/') - Length = $_.Length - SHA256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash - } - }) +$inventory = @(Get-BusyMaxPackageInventory -PackageRoot $PackageRoot) if (-not [string]::IsNullOrWhiteSpace($InventoryOutputPath)) { $inventoryDirectory = Split-Path -Parent $InventoryOutputPath if (-not [string]::IsNullOrWhiteSpace($inventoryDirectory)) { From 5b63d4cf634bd6324140b4c0902859f62e0bb53b Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 31 Aug 2026 23:51:54 -0700 Subject: [PATCH 51/69] Inventory complete MSIX container metadata --- tools/windows/common.ps1 | 42 +++++++++++++++++++ tools/windows/package_store.ps1 | 4 ++ .../tests/package_contents_contract.Tests.ps1 | 24 +++++++++++ 3 files changed, 70 insertions(+) diff --git a/tools/windows/common.ps1 b/tools/windows/common.ps1 index 20c2d3f0..6d78a355 100644 --- a/tools/windows/common.ps1 +++ b/tools/windows/common.ps1 @@ -96,6 +96,48 @@ function Assert-BusyMaxFinalMsixMetadata { } } +function Expand-BusyMaxMsixMetadata { + param( + [Parameter(Mandatory)][string]$PackagePath, + [Parameter(Mandatory)][string]$PackageRoot + ) + if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "MSIX package does not exist: $PackagePath" + } + if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container)) { + throw "Unpacked MSIX directory does not exist: $PackageRoot" + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead( + (Resolve-Path -LiteralPath $PackagePath).Path) + try { + foreach ($metadataFile in @('AppxBlockMap.xml', '[Content_Types].xml')) { + $entries = @($archive.Entries | Where-Object { + $_.FullName -ceq $metadataFile + }) + if ($entries.Count -ne 1) { + throw "Exact MSIX archive must contain one $metadataFile entry; found $($entries.Count)." + } + $destination = Join-Path $PackageRoot $metadataFile + $inputStream = $entries[0].Open() + try { + $outputStream = [IO.File]::Open( + $destination, [IO.FileMode]::Create, [IO.FileAccess]::Write, + [IO.FileShare]::None) + try { + $inputStream.CopyTo($outputStream) + } finally { + $outputStream.Dispose() + } + } finally { + $inputStream.Dispose() + } + } + } finally { + $archive.Dispose() + } +} + function Get-BusyMaxPackageInventory { param([Parameter(Mandatory)][string]$PackageRoot) return @(Get-BusyMaxPackageFiles -PackageRoot $PackageRoot | diff --git a/tools/windows/package_store.ps1 b/tools/windows/package_store.ps1 index a371f899..d2ea852d 100644 --- a/tools/windows/package_store.ps1 +++ b/tools/windows/package_store.ps1 @@ -94,6 +94,10 @@ if (Test-Path -LiteralPath $unpacked) { } & $makeAppx unpack /p $package /d $unpacked /o if ($LASTEXITCODE -ne 0) { throw 'MakeAppx could not unpack the final MSIX.' } +# MakeAppx validates and unpacks package payloads but omits the OPC container +# member [Content_Types].xml. Restore both generated metadata members directly +# from the exact MSIX so validation and inventory cover the complete archive. +Expand-BusyMaxMsixMetadata -PackagePath $package -PackageRoot $unpacked $finalInventoryPath = Join-Path $OutputDirectory 'final-msix-inventory.json' $finalInventory = @(& "$PSScriptRoot/validate_package_contents.ps1" ` -PackageRoot $unpacked -InventoryOutputPath $finalInventoryPath ` diff --git a/tools/windows/tests/package_contents_contract.Tests.ps1 b/tools/windows/tests/package_contents_contract.Tests.ps1 index b4422b2d..48475c44 100644 --- a/tools/windows/tests/package_contents_contract.Tests.ps1 +++ b/tools/windows/tests/package_contents_contract.Tests.ps1 @@ -48,6 +48,29 @@ Describe 'BusyMax exact-package file inspection' { Should -Throw -ExpectedMessage '*Content_Types*.xml*' } + It 'extracts generated metadata from the exact MSIX container' { + $archiveSource = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $archiveSource | Out-Null + [IO.File]::WriteAllText( + (Join-Path $archiveSource 'AppxBlockMap.xml'), 'block map') + [IO.File]::WriteAllText( + (Join-Path $archiveSource '[Content_Types].xml'), 'content types') + [IO.File]::WriteAllText( + (Join-Path $archiveSource 'busymax.exe'), 'payload') + $packagePath = Join-Path $TestDrive ` + "$([guid]::NewGuid().ToString('N')).msix" + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($archiveSource, $packagePath) + + Expand-BusyMaxMsixMetadata -PackagePath $packagePath ` + -PackageRoot $packageRoot + + Get-Content -LiteralPath (Join-Path $packageRoot 'AppxBlockMap.xml') | + Should -Be 'block map' + Get-Content -LiteralPath (Join-Path $packageRoot '[Content_Types].xml') | + Should -Be 'content types' + } + It 'wires distinct staging and final-MSIX validation modes' { $validator = Get-Content ` -LiteralPath (Join-Path $tools 'validate_package_contents.ps1') -Raw @@ -57,5 +80,6 @@ Describe 'BusyMax exact-package file inspection' { $validator | Should -Match "ValidateSet\('Staging', 'FinalMsix'\)" $packager | Should -Match '-ValidationMode Staging' $packager | Should -Match '-ValidationMode FinalMsix' + $packager | Should -Match 'Expand-BusyMaxMsixMetadata' } } From 5d2236ed35979b9b095ca1a07422b89f01adba51 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 15:47:20 -0700 Subject: [PATCH 52/69] Refine BusyMax button color behavior for destructive states --- lib/src/app/busymax_design.dart | 6 +- .../presentation/schedule_views_test.dart | 60 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 63e85db5..4fa19059 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -997,10 +997,12 @@ class BusyMaxPopoverIconButton extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); - final foreground = destructive ? Theme.of(context).colorScheme.error : null; + final colorScheme = Theme.of(context).colorScheme; + final foreground = destructive ? colorScheme.onError : null; + final background = destructive ? colorScheme.error : colors.control; final enabled = onPressed != null; return Material( - color: enabled ? colors.control : colors.disabledControl, + color: enabled ? background : colors.disabledControl, shape: const CircleBorder(), child: YaruIconButton( icon: Icon( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index c66ecbd2..3f0b227f 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1240,7 +1240,21 @@ void main() { ), ) .toList(); - expect(restingSurfaces, hasLength(4)); + expect(restingSurfaces, hasLength(3)); + final destructiveSurfaces = tester + .widgetList( + find.descendant( + of: find.byType(BusyMaxPopoverIconButton), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == Theme.of(actionContext).colorScheme.error && + widget.shape == const CircleBorder(), + ), + ), + ) + .toList(); + expect(destructiveSurfaces, hasLength(1)); for (final button in find.byType(BusyMaxPopoverIconButton).evaluate()) { expect( tester.getSize(find.byWidget(button.widget)), @@ -1257,7 +1271,7 @@ void main() { } expect( tester.widget(find.byIcon(YaruIcons.trash)).color, - Theme.of(actionContext).colorScheme.error, + Theme.of(actionContext).colorScheme.onError, ); expect(tester.widget(find.byIcon(YaruIcons.share)).color, isNull); @@ -1566,6 +1580,48 @@ void main() { ); }, ); + + testWidgets('destructive popover action uses a visible semantic fill in ' + '$brightness mode', (tester) async { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + final colors = theme.extension()!; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: BusyMaxPopoverIconButton( + icon: YaruIcons.trash, + tooltip: 'Delete', + destructive: true, + onPressed: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final action = find.byType(BusyMaxPopoverIconButton); + final destructiveSurface = tester.widget( + find.descendant( + of: action, + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.color == theme.colorScheme.error && + widget.shape == const CircleBorder(), + ), + ), + ); + final icon = tester.widget(find.byIcon(YaruIcons.trash)); + + expect(destructiveSurface.color, theme.colorScheme.error); + expect(destructiveSurface.color, isNot(colors.control)); + expect(icon.color, theme.colorScheme.onError); + }); } testWidgets('popover action retains Yaru keyboard-focus treatment', ( From 134178379c4e8db6212fdee056851b26eeecbe3b Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 16:03:27 -0700 Subject: [PATCH 53/69] Independent sync triggers could create separate engines, concurrently read the same pending operation, and dispatch duplicate remote creates. Fixed by: - Adding a shared per-account synchronization coordinator covering manual, scheduled, mutation-triggered, DAV, task, and calendar sync paths. - Adding a shared database/account replay gate so independently constructed task and calendar replayers cannot process the same queue concurrently. - Routing pending-operation retry/resolution through the coordinated sync path, removing another direct-engine bypass. - Adding concurrency regressions proving simultaneous replayers send task and event creates exactly once. --- lib/src/app/app_bootstrap.dart | 35 ++---- .../sync/account_sync_operations.dart | 67 ++++++++++ .../sync/calendar_pending_ops_replayer.dart | 11 +- .../sync/pending_op_resolution_service.dart | 17 +-- .../sync/pending_ops_replay_coordinator.dart | 16 +++ .../features/sync/pending_ops_replayer.dart | 11 +- .../auth/presentation/auth_routing_test.dart | 1 - .../presentation/settings_screen_test.dart | 1 - .../sync/account_sync_operations_test.dart | 118 ++++++++++++++++++ .../calendar_pending_ops_replayer_test.dart | 45 +++++++ .../pending_op_resolution_service_test.dart | 97 +++++++++----- .../sync/pending_ops_replayer_test.dart | 50 ++++++++ 12 files changed, 401 insertions(+), 68 deletions(-) create mode 100644 lib/src/features/sync/pending_ops_replay_coordinator.dart diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index d61dcf8c..e2deef08 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -572,6 +572,10 @@ final davAccountSyncEngineFactoryProvider = ); }); +final accountSyncCoordinatorProvider = Provider((ref) { + return AccountSyncCoordinator(); +}); + final accountSyncOperationsProvider = Provider((ref) { final accountsRepository = ref.watch(accountsRepositoryProvider); final connectivity = ref.watch(networkConnectivityMonitorProvider); @@ -619,9 +623,12 @@ final accountSyncOperationsProvider = Provider((ref) { } }, ); - return ConnectivityAwareAccountSyncOperations( - inner: routing, - requireNetwork: connectivity.requireNetwork, + return CoordinatedAccountSyncOperations( + coordinator: ref.watch(accountSyncCoordinatorProvider), + inner: ConnectivityAwareAccountSyncOperations( + inner: routing, + requireNetwork: connectivity.requireNetwork, + ), ); }); @@ -858,24 +865,6 @@ final tasksRepositoryForAccountProvider = ); }); -final Provider syncEngineProvider = Provider((ref) { - final accountId = ref.watch(activeAccountProvider); - final apiClient = ref.watch(googleTasksApiClientProvider); - final account = ref.watch(selectedAccountProvider); - if (accountId == null || apiClient == null || account?.id != accountId) { - return null; - } - return SyncEngine( - database: ref.watch(databaseProvider), - apiClient: apiClient, - accountId: accountId, - fullRefreshOnly: account!.provider == BusyProvider.microsoft, - onConflictBlocked: ref - .watch(desktopNotificationServiceProvider) - .notifyConflict, - ); -}); - final pendingMutationSyncRequesterProvider = Provider((ref) { final accountId = ref.watch(activeAccountProvider); @@ -942,7 +931,9 @@ final pendingOpResolutionServiceProvider = database: ref.watch(databaseProvider), apiClient: ref.watch(googleTasksApiClientProvider), accountId: accountId, - syncEngine: ref.watch(syncEngineProvider), + syncTasks: () => ref + .read(accountSyncOperationsProvider) + .syncTasks(accountId, full: false), syncCalendar: () => ref .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false), diff --git a/lib/src/features/sync/account_sync_operations.dart b/lib/src/features/sync/account_sync_operations.dart index 3b4eb6dc..ba36ff37 100644 --- a/lib/src/features/sync/account_sync_operations.dart +++ b/lib/src/features/sync/account_sync_operations.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import '../../providers/busy_provider.dart'; typedef AccountSyncAction = @@ -12,6 +14,71 @@ abstract interface class AccountSyncOperations { Future syncCalendar(String accountId, {required bool full}); } +/// Serializes synchronization for each account while allowing different +/// accounts to synchronize independently. +/// +/// The returned future belongs to the submitted operation, so callers retain +/// their own success or failure result even when they were queued behind a +/// different synchronization trigger. +final class AccountSyncCoordinator { + final Map> _accountTails = {}; + + Future run(String accountId, Future Function() operation) async { + final previous = _accountTails[accountId]; + final release = Completer(); + final tail = release.future; + _accountTails[accountId] = tail; + + if (previous != null) { + await previous; + } + try { + return await operation(); + } finally { + release.complete(); + if (identical(_accountTails[accountId], tail)) { + _accountTails.remove(accountId); + } + } + } +} + +/// Routes every synchronization entry point through one account-scoped gate. +final class CoordinatedAccountSyncOperations implements AccountSyncOperations { + const CoordinatedAccountSyncOperations({ + required AccountSyncOperations inner, + required AccountSyncCoordinator coordinator, + }) : _inner = inner, + _coordinator = coordinator; + + final AccountSyncOperations _inner; + final AccountSyncCoordinator _coordinator; + + @override + Future syncAccount(String accountId, {required bool full}) { + return _coordinator.run( + accountId, + () => _inner.syncAccount(accountId, full: full), + ); + } + + @override + Future syncCalendar(String accountId, {required bool full}) { + return _coordinator.run( + accountId, + () => _inner.syncCalendar(accountId, full: full), + ); + } + + @override + Future syncTasks(String accountId, {required bool full}) { + return _coordinator.run( + accountId, + () => _inner.syncTasks(accountId, full: full), + ); + } +} + final class ConnectivityAwareAccountSyncOperations implements AccountSyncOperations { const ConnectivityAwareAccountSyncOperations({ diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index bea9c7c0..2c1059a4 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -16,6 +16,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 'pending_ops_replay_coordinator.dart'; class CalendarPendingOpsReplayer { CalendarPendingOpsReplayer({ @@ -41,7 +42,15 @@ class CalendarPendingOpsReplayer { final Random _random; final CalendarRepository _repository; - Future replayDueOps() async { + Future replayDueOps() { + return serializePendingOpsReplay( + database: _database, + accountId: _accountId, + replay: _replayDueOps, + ); + } + + Future _replayDueOps() async { final dueOps = await _database.pendingOpsDao.pendingOpsForReplay( _accountId, _nowUtc(), diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 9647e684..72f66a29 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -6,27 +6,26 @@ import '../task_lists/data/task_lists_repository.dart'; import '../tasks/data/tasks_repository.dart'; import '../tasks/domain/task_remote_client.dart'; import '../tasks/domain/task_remote_error.dart'; -import 'sync_engine.dart'; class PendingOpResolutionService { PendingOpResolutionService({ required AppDatabase database, TaskRemoteClient? apiClient, required String accountId, - SyncEngine? syncEngine, + required Future Function() syncTasks, Future Function()? syncCalendar, DateTime Function()? nowUtc, }) : _database = database, _apiClient = apiClient, _accountId = accountId, - _syncEngine = syncEngine, + _syncTasks = syncTasks, _syncCalendar = syncCalendar, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); final AppDatabase _database; final TaskRemoteClient? _apiClient; final String _accountId; - final SyncEngine? _syncEngine; + final Future Function() _syncTasks; final Future Function()? _syncCalendar; final DateTime Function() _nowUtc; @@ -84,20 +83,14 @@ class PendingOpResolutionService { } Future _syncAfterResolution(PendingOp op) async { - if (op.entityType == 'calendar') { + if (op.entityType == 'calendar' || op.entityType == 'event') { final syncCalendar = _syncCalendar; if (syncCalendar != null) { await syncCalendar(); return; } } - final syncEngine = _syncEngine; - if (syncEngine == null) { - throw StateError( - 'No synchronization path is available for this operation.', - ); - } - await syncEngine.incrementalSync(); + await _syncTasks(); } TaskRemoteClient get _requiredTaskClient { diff --git a/lib/src/features/sync/pending_ops_replay_coordinator.dart b/lib/src/features/sync/pending_ops_replay_coordinator.dart new file mode 100644 index 00000000..8f180984 --- /dev/null +++ b/lib/src/features/sync/pending_ops_replay_coordinator.dart @@ -0,0 +1,16 @@ +import '../../db/app_database.dart'; +import 'account_sync_operations.dart'; + +final Expando _coordinators = + Expando('pending-operation replay coordinators'); + +/// Prevents independently-created replayers from dispatching pending writes +/// concurrently for the same database and account. +Future serializePendingOpsReplay({ + required AppDatabase database, + required String accountId, + required Future Function() replay, +}) { + final coordinator = _coordinators[database] ??= AccountSyncCoordinator(); + return coordinator.run(accountId, replay); +} diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index 2887c549..2264b59f 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -8,6 +8,7 @@ import '../tasks/domain/task_remote_client.dart'; import '../tasks/domain/task_remote_error.dart'; import '../tasks/domain/task_remote_models.dart'; import 'conflict_detector.dart'; +import 'pending_ops_replay_coordinator.dart'; import '../task_lists/data/task_lists_repository.dart'; import '../tasks/data/tasks_repository.dart'; import '../tasks/domain/task_checklist_item.dart'; @@ -34,7 +35,15 @@ class PendingOpsReplayer { final Random _random; final DateTime Function() _nowUtc; - Future replayDueOps() async { + Future replayDueOps() { + return serializePendingOpsReplay( + database: _database, + accountId: _accountId, + replay: _replayDueOps, + ); + } + + Future _replayDueOps() async { final ops = await _database.pendingOpsDao.pendingOpsForReplay( _accountId, _nowUtc(), diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index fae5e266..a9e4b918 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -717,7 +717,6 @@ Future _pumpApp( signedInSyncRunnerProvider.overrideWithValue( onSignedIn ?? (accountId, initial) async {}, ), - syncEngineProvider.overrideWithValue(null), linuxHeaderBarServiceProvider.overrideWith((ref) { final service = LinuxHeaderBarService(isLinux: false); ref.onDispose(service.dispose); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 1af4d855..77419f2b 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1020,7 +1020,6 @@ ProviderContainer _container({ if (activeAccountIdOverride != _useDefaultActiveAccountId) activeAccountProvider.overrideWithValue(activeAccountIdOverride), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - syncEngineProvider.overrideWithValue(null), buildConfigProvider.overrideWithValue(buildConfig), if (useFlutterHeader) linuxHeaderBarServiceProvider.overrideWith((ref) { diff --git a/test/features/sync/account_sync_operations_test.dart b/test/features/sync/account_sync_operations_test.dart index 4551e597..74d376b8 100644 --- a/test/features/sync/account_sync_operations_test.dart +++ b/test/features/sync/account_sync_operations_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:busymax/src/features/sync/account_sync_operations.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -33,4 +35,120 @@ void main() { expect(calls, ['tasks:true', 'calendar:true']); }, ); + + test('serializes every sync entry point for the same account', () async { + final inner = _BlockingAccountSyncOperations(); + final operations = CoordinatedAccountSyncOperations( + inner: inner, + coordinator: AccountSyncCoordinator(), + ); + + final tasks = operations.syncTasks('account', full: false); + await _waitFor(() => inner.calls.length == 1); + final calendar = operations.syncCalendar('account', full: false); + final account = operations.syncAccount('account', full: true); + await Future.delayed(Duration.zero); + + expect(inner.calls, ['tasks:account:false']); + + inner.releases[0].complete(); + await _waitFor(() => inner.calls.length == 2); + expect(inner.calls, ['tasks:account:false', 'calendar:account:false']); + + inner.releases[1].complete(); + await _waitFor(() => inner.calls.length == 3); + expect(inner.calls, [ + 'tasks:account:false', + 'calendar:account:false', + 'account:account:true', + ]); + + inner.releases[2].complete(); + await Future.wait([tasks, calendar, account]); + expect(inner.maxActive, 1); + }); + + test('allows different accounts to synchronize concurrently', () async { + final inner = _BlockingAccountSyncOperations(); + final operations = CoordinatedAccountSyncOperations( + inner: inner, + coordinator: AccountSyncCoordinator(), + ); + + final first = operations.syncTasks('first', full: false); + final second = operations.syncCalendar('second', full: false); + await _waitFor(() => inner.calls.length == 2); + + expect(inner.calls, ['tasks:first:false', 'calendar:second:false']); + expect(inner.maxActive, 2); + + for (final release in inner.releases) { + release.complete(); + } + await Future.wait([first, second]); + }); + + test('a failed sync does not poison later work for the account', () async { + final coordinator = AccountSyncCoordinator(); + final releaseFailure = Completer(); + final failure = StateError('sync failed'); + var secondRan = false; + + final first = coordinator.run('account', () async { + await releaseFailure.future; + throw failure; + }); + final firstExpectation = expectLater(first, throwsA(same(failure))); + final second = coordinator.run('account', () async { + secondRan = true; + }); + + expect(secondRan, isFalse); + releaseFailure.complete(); + await firstExpectation; + await second; + + expect(secondRan, isTrue); + }); +} + +final class _BlockingAccountSyncOperations implements AccountSyncOperations { + final calls = []; + final releases = >[]; + var active = 0; + var maxActive = 0; + + @override + Future syncAccount(String accountId, {required bool full}) { + return _record('account:$accountId:$full'); + } + + @override + Future syncCalendar(String accountId, {required bool full}) { + return _record('calendar:$accountId:$full'); + } + + @override + Future syncTasks(String accountId, {required bool full}) { + return _record('tasks:$accountId:$full'); + } + + Future _record(String call) async { + final release = Completer(); + calls.add(call); + releases.add(release); + active += 1; + if (active > maxActive) maxActive = active; + await release.future; + active -= 1; + } +} + +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition.'); } diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 64905350..dba42460 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; @@ -76,6 +77,39 @@ void main() { }, ); + test('concurrent replayers dispatch a queued event create once', () async { + await CalendarRepository(database: database).createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'account|google|cal-1', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + final createGate = Completer(); + client.createEventGate = createGate; + + CalendarPendingOpsReplayer replayer() => CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + final first = replayer().replayDueOps(); + await _waitFor(() => client.calls.length == 1); + final second = replayer().replayDueOps(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(client.calls, ['createEvent:cal-1:Planning']); + + createGate.complete(); + expect(await first, 1); + expect(await second, 0); + expect(client.calls, ['createEvent:cal-1:Planning']); + }); + test('event replay preserves a do-not-send guest update choice', () async { await CalendarRepository(database: database).createLocalEvent( EventEditorDraft.newEvent( @@ -2077,6 +2111,7 @@ class _FakeCalendarClient GoogleCalendarApiError? deleteError; Object? calendarDeleteError; GoogleCalendarApiError? calendarListDeleteError; + Completer? createEventGate; @override BusyProvider get provider => BusyProvider.google; @@ -2107,6 +2142,7 @@ class _FakeCalendarClient calls.add('createEvent:$calendarId:${mutation.title}'); createdMutations.add(mutation); guestUpdatePolicies.add(guestUpdatePolicy); + await createEventGate?.future; _createdCount += 1; return _event( 'server-event-$_createdCount', @@ -2359,3 +2395,12 @@ class _FakeMicrosoftCalendarClient extends _FakeCalendarClient { CalendarProviderCapabilities get capabilities => microsoftCalendarProviderCapabilities; } + +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition.'); +} diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 4252483c..0a3881e5 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -9,7 +9,6 @@ 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/pending_op_resolution_service.dart'; -import 'package:busymax/src/features/sync/sync_engine.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_client.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_error.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; @@ -18,20 +17,20 @@ import 'package:busymax/src/providers/busy_provider.dart'; void main() { late AppDatabase database; late _FakeTaskRemoteClient apiClient; - late _FakeSyncEngine syncEngine; late PendingOpResolutionService service; + late int taskSyncCalls; late int calendarSyncCalls; setUp(() async { database = AppDatabase(NativeDatabase.memory()); apiClient = _FakeTaskRemoteClient(); - syncEngine = _FakeSyncEngine(); + taskSyncCalls = 0; calendarSyncCalls = 0; service = PendingOpResolutionService( database: database, apiClient: apiClient, accountId: 'account', - syncEngine: syncEngine, + syncTasks: () async => taskSyncCalls += 1, syncCalendar: () async => calendarSyncCalls += 1, nowUtc: () => DateTime.utc(2026, 6, 4), ); @@ -69,7 +68,7 @@ void main() { expect(task.pendingDelete, isFalse); expect(task.pendingMove, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -97,7 +96,7 @@ void main() { expect(task.localDirty, isFalse); expect(task.pendingDelete, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -122,7 +121,7 @@ void main() { expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -149,7 +148,7 @@ void main() { expect(list.localDirty, isFalse); expect(list.pendingDelete, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -179,7 +178,7 @@ void main() { expect(await database.taskListsDao.listTaskLists('account'), isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -211,7 +210,7 @@ void main() { )).single; expect(task.localDirty, isTrue); expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); }, ); @@ -242,7 +241,7 @@ void main() { )).single; expect(list.localDirty, isTrue); expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); }, ); @@ -282,7 +281,7 @@ void main() { expect(sourceTasks.single.pendingMove, isFalse); expect(destinationTasks, isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -318,7 +317,7 @@ void main() { expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); expect(await database.tasksDao.listTasks('account', 'list-2'), isEmpty); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }, ); @@ -345,7 +344,7 @@ void main() { expect(task.localDirty, isFalse); expect(task.pendingMove, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); - expect(syncEngine.incrementalSyncCalls, 1); + expect(taskSyncCalls, 1); }); test('discard blocked calendar removal restores the source', () async { @@ -380,7 +379,7 @@ void main() { expect(source.hidden, isFalse); expect(await database.pendingOpsDao.getOp('op-1'), equals(null)); expect(calendarSyncCalls, 1); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); }); test( @@ -400,7 +399,7 @@ void main() { expect(await database.select(database.calendarEvents).get(), isEmpty); expect(await database.select(database.pendingOps).get(), isEmpty); expect(calendarSyncCalls, 0); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); expect(sourceId, isNotEmpty); }, ); @@ -441,7 +440,7 @@ void main() { expect(await database.select(database.calendarEvents).get(), isEmpty); expect(await database.select(database.pendingOps).get(), isEmpty); expect(calendarSyncCalls, 0); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); }, ); @@ -468,8 +467,58 @@ void main() { expect(source.summary, 'Provider name'); expect(await database.select(database.pendingOps).get(), isEmpty); expect(calendarSyncCalls, 1); - expect(syncEngine.incrementalSyncCalls, 0); + expect(taskSyncCalls, 0); }); + + test( + 'retry routes task synchronization through the shared callback', + () async { + var taskSyncCalls = 0; + final coordinatedService = PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async => taskSyncCalls += 1, + syncCalendar: () async => calendarSyncCalls += 1, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await _enqueueBlockedOp( + database, + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task-1', + ); + + await coordinatedService.retryNow('op-1'); + + expect(taskSyncCalls, 1); + expect(calendarSyncCalls, 0); + }, + ); + + test( + 'retry routes event synchronization through the calendar callback', + () async { + var taskSyncCalls = 0; + final coordinatedService = PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async => taskSyncCalls += 1, + syncCalendar: () async => calendarSyncCalls += 1, + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await _enqueueBlockedOp( + database, + entityType: 'event', + operation: 'patch', + operationType: 'event.patch', + ); + + await coordinatedService.retryNow('op-1'); + + expect(calendarSyncCalls, 1); + expect(taskSyncCalls, 0); + }, + ); } class _FakeTaskRemoteClient implements TaskRemoteClient { @@ -503,18 +552,6 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakeSyncEngine implements SyncEngine { - var incrementalSyncCalls = 0; - - @override - Future fullSync() async {} - - @override - Future incrementalSync() async { - incrementalSyncCalls += 1; - } -} - Future _insertAccount(AppDatabase database) { return database .into(database.accounts) diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index efe34bab..52c59baa 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; @@ -154,6 +155,44 @@ void main() { expect(lists.map((list) => list.id), isNot(contains('local-tasklist-1'))); }); + test('concurrent replayers dispatch a queued task create once', () async { + await database.tasksDao.upsertTask( + _task('list-1', 'local-task-1', title: 'Draft'), + ); + await _enqueue( + database, + id: '01', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + localTempId: 'local-task-1', + request: { + 'body': {'title': 'Draft'}, + }, + ); + final createGate = Completer(); + apiClient.createTaskGate = createGate; + + PendingOpsReplayer replayer() => PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + final first = replayer().replayDueOps(); + await _waitFor(() => apiClient.calls.length == 1); + final second = replayer().replayDueOps(); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(apiClient.calls, ['create_task:list-1']); + + createGate.complete(); + expect(await first, 1); + expect(await second, 0); + expect(apiClient.calls, ['create_task:list-1']); + }); + test( 'Google subtask is moved under its parent after a root insert', () async { @@ -1157,6 +1196,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { GoogleTasksApiError? moveTaskError; TaskListDto? remoteTaskList; TaskDto? remoteTask; + Completer? createTaskGate; bool persistTaskPatches = false; int _taskPatchRevision = 0; TasksPageDto remoteTasksPage = const TasksPageDto(items: [], rawJson: {}); @@ -1206,6 +1246,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { }) async { calls.add('create_task:$taskListId'); createParentTaskIds.add(parentTaskId); + await createTaskGate?.future; return _taskDto('task-server', title: create.fields['title'].toString()); } @@ -1513,5 +1554,14 @@ TaskDto _taskDto( ); } +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(deadline)) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Timed out waiting for condition.'); +} + const _now = '2026-06-04T00:00:00.000Z'; final _later = DateTime.utc(2026, 6, 4, 1); From fc54da6a378e867a4886e754e2065c2372d9f602 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 16:13:43 -0700 Subject: [PATCH 54/69] Prevent lost in-flight calendar edits --- lib/src/db/daos/pending_ops_dao.dart | 43 +++++++ .../calendar/data/calendar_repository.dart | 64 +++++++++- .../sync/calendar_pending_ops_replayer.dart | 65 ++++++++-- .../calendar_pending_ops_replayer_test.dart | 119 ++++++++++++++++++ 4 files changed, 277 insertions(+), 14 deletions(-) diff --git a/lib/src/db/daos/pending_ops_dao.dart b/lib/src/db/daos/pending_ops_dao.dart index 3f236ed5..3d709bca 100644 --- a/lib/src/db/daos/pending_ops_dao.dart +++ b/lib/src/db/daos/pending_ops_dao.dart @@ -31,6 +31,20 @@ class PendingOpsDao extends DatabaseAccessor return query.go(); } + /// Deletes [snapshot] only when the queued payload still matches the + /// revision that a replayer actually sent. + Future deleteOpIfUnchanged(PendingOp snapshot) async { + final query = delete(pendingOps) + ..where( + (row) => + row.id.equals(snapshot.id) & + row.requestJson.equals(snapshot.requestJson) & + row.updatedAtUtc.equals(snapshot.updatedAtUtc) & + row.attemptCount.equals(snapshot.attemptCount), + ); + return await query.go() == 1; + } + Future getOp(String id) { final query = select(pendingOps)..where((row) => row.id.equals(id)); return query.getSingleOrNull(); @@ -77,4 +91,33 @@ class PendingOpsDao extends DatabaseAccessor ), ); } + + /// Records a failed attempt only if the queued payload has not been edited + /// since [snapshot] was read by the replayer. + Future updateAttemptIfUnchanged({ + required PendingOp snapshot, + required int attemptCount, + required DateTime nextAttemptAtUtc, + String? lastErrorCode, + String? lastErrorMessage, + }) async { + final query = update(pendingOps) + ..where( + (row) => + row.id.equals(snapshot.id) & + row.requestJson.equals(snapshot.requestJson) & + row.updatedAtUtc.equals(snapshot.updatedAtUtc) & + row.attemptCount.equals(snapshot.attemptCount), + ); + final updated = await query.write( + PendingOpsCompanion( + attemptCount: Value(attemptCount), + nextAttemptAtUtc: Value(nextAttemptAtUtc.toIso8601String()), + lastErrorCode: Value(lastErrorCode), + lastErrorMessage: Value(lastErrorMessage), + updatedAtUtc: Value(DateTime.now().toUtc().toIso8601String()), + ), + ); + return updated == 1; + } } diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 2dd371d1..fb02050d 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -700,6 +700,13 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(id))).getSingleOrNull(); + final pendingPatchFields = existing == null + ? const {} + : await _pendingCalendarPatchFields(existing.id); + final preservePendingColor = + pendingPatchFields.contains('backgroundColor') || + pendingPatchFields.contains('foregroundColor') || + pendingPatchFields.contains('colorId'); final preservePendingRemoval = existing != null && await _hasActiveCalendarRemoval(existing.id); final isDeleted = preservePendingRemoval || source.isDeleted; @@ -711,17 +718,37 @@ class CalendarRepository { accountId: accountId, provider: source.provider.storageValue, providerCalendarId: source.providerCalendarId, - summary: source.summary, - description: Value(source.description), + summary: pendingPatchFields.contains('summary') + ? existing!.summary + : source.summary, + description: Value( + pendingPatchFields.contains('description') + ? existing!.description + : source.description, + ), primaryCalendar: Value(source.primaryCalendar), selected: Value(existing?.selected ?? source.selected), remindersEnabled: Value(existing?.remindersEnabled ?? true), hidden: Value(isDeleted || source.hidden), readOnly: Value(source.readOnly), - backgroundColor: Value(source.backgroundColor), - foregroundColor: Value(source.foregroundColor), - colorId: Value(source.colorId), - timeZone: Value(source.timeZone), + backgroundColor: Value( + preservePendingColor + ? existing!.backgroundColor + : source.backgroundColor, + ), + foregroundColor: Value( + preservePendingColor + ? existing!.foregroundColor + : source.foregroundColor, + ), + colorId: Value( + preservePendingColor ? existing!.colorId : source.colorId, + ), + timeZone: Value( + pendingPatchFields.contains('timeZone') + ? existing!.timeZone + : source.timeZone, + ), accessRole: Value(source.accessRole), dataOwner: Value(source.dataOwner), isRemovable: Value(source.isRemovable), @@ -3029,6 +3056,31 @@ class CalendarRepository { .get(); } + Future> _pendingCalendarPatchFields(String sourceId) async { + final operations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.calendarSourceId.equals(sourceId) & + row.operationType.equals('calendar.patch') & + row.state.equals('pending'), + )) + .get(); + const projectedFields = { + 'summary', + 'description', + 'timeZone', + 'backgroundColor', + 'foregroundColor', + 'colorId', + }; + return { + for (final operation in operations) + ..._calendarPendingRequest( + operation, + ).keys.where(projectedFields.contains), + }; + } + Future _hasActiveCalendarRemoval(String sourceId) async { final operations = await (_database.select(_database.pendingOps)..where( diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 2c1059a4..6904099e 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -80,7 +80,7 @@ class CalendarPendingOpsReplayer { try { await _replay(op); - await _database.pendingOpsDao.deleteOp(op.id); + await _acknowledge(op); applied += 1; } on GoogleCalendarApiError catch (error) { if (_isSuccessfulMissingDelete(op, error.statusCode)) { @@ -1463,9 +1463,19 @@ class CalendarPendingOpsReplayer { PendingOp op, String errorCode, String errorMessage, - ) { + ) async { final nextAttempt = _nextAttempt(op.attemptCount); - return _database.pendingOpsDao.updateAttempt( + if (_requiresRevisionMatch(op)) { + await _database.pendingOpsDao.updateAttemptIfUnchanged( + snapshot: op, + attemptCount: op.attemptCount + 1, + nextAttemptAtUtc: nextAttempt, + lastErrorCode: errorCode, + lastErrorMessage: errorMessage, + ); + return; + } + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, nextAttemptAtUtc: nextAttempt, @@ -1474,24 +1484,63 @@ class CalendarPendingOpsReplayer { ); } - Future _blockOp( + Future _blockOp( PendingOp op, String errorCode, String errorMessage, ) async { + final blockedUntil = DateTime.utc(9999, 12, 31); + final updated = _requiresRevisionMatch(op) + ? await _database.pendingOpsDao.updateAttemptIfUnchanged( + snapshot: op, + attemptCount: op.attemptCount + 1, + nextAttemptAtUtc: blockedUntil, + lastErrorCode: errorCode, + lastErrorMessage: errorMessage, + ) + : await _updateAttempt( + op, + nextAttemptAtUtc: blockedUntil, + errorCode: errorCode, + errorMessage: errorMessage, + ); + if (!updated) return false; + await _repository.restoreSourceAfterRemovalFailure(op); + return true; + } + + Future _updateAttempt( + PendingOp op, { + required DateTime nextAttemptAtUtc, + required String errorCode, + required String errorMessage, + }) async { await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, - nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + nextAttemptAtUtc: nextAttemptAtUtc, lastErrorCode: errorCode, lastErrorMessage: errorMessage, ); - await _repository.restoreSourceAfterRemovalFailure(op); + return true; + } + + Future _acknowledge(PendingOp op) async { + if (_requiresRevisionMatch(op)) { + await _database.pendingOpsDao.deleteOpIfUnchanged(op); + return; + } + await _database.pendingOpsDao.deleteOp(op.id); + } + + bool _requiresRevisionMatch(PendingOp op) { + return _operationType(op) == 'calendar.patch'; } Future _blockConflict(PendingOp op, String message) async { - await _blockOp(op, 'conflict', message); - await _onConflictBlocked?.call(message); + if (await _blockOp(op, 'conflict', message)) { + await _onConflictBlocked?.call(message); + } throw const _PendingOpBlocked(); } diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index dba42460..b95ff848 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_provider_capabilities.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; @@ -1417,6 +1418,61 @@ void main() { ); }); + test( + 'calendar patch edited in flight remains queued and is replayed', + () async { + final repository = CalendarRepository(database: database); + const sourceId = 'account|google|cal-1'; + await repository.renameLocalSource(sourceId, 'First rename'); + final patchGate = Completer(); + client.calendarPatchGate = patchGate; + + CalendarPendingOpsReplayer replayer() => CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + final firstReplay = replayer().replayDueOps(); + await _waitFor( + () => client.calls.contains('updateCalendar:cal-1:First rename'), + ); + + await repository.renameLocalSource(sourceId, 'Second rename'); + patchGate.complete(); + + expect(await firstReplay, 1); + final pendingAfterFirst = await database.pendingOpsDao + .pendingOpsForReplay('account', _later); + expect(pendingAfterFirst, hasLength(1)); + expect( + jsonDecode(pendingAfterFirst.single.requestJson), + containsPair('summary', 'Second rename'), + ); + expect( + (await database.select(database.calendarSources).getSingle()).summary, + 'Second rename', + ); + + client.calendarPatchGate = null; + expect(await replayer().replayDueOps(), 1); + + expect(client.calls, [ + 'updateCalendar:cal-1:First rename', + 'updateCalendar:cal-1:Second rename', + ]); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + expect( + (await database.select(database.calendarSources).getSingle()).summary, + 'Second rename', + ); + }, + ); + test( 'personal Google calendar patch updates the CalendarList entry', () async { @@ -1570,6 +1626,63 @@ void main() { ); }); + test( + 'calendar color edited in flight remains queued and is replayed', + () async { + final repository = CalendarRepository(database: database); + const sourceId = 'account|google|cal-1'; + final firstColor = googleCalendarColorChoices[0]; + final secondColor = googleCalendarColorChoices[1]; + await repository.setSourceColor(sourceId, firstColor); + final patchGate = Completer(); + client.calendarPatchGate = patchGate; + + CalendarPendingOpsReplayer replayer() => CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + final firstReplay = replayer().replayDueOps(); + await _waitFor(() => client.calendarMutations.length == 1); + + await repository.setSourceColor(sourceId, secondColor); + patchGate.complete(); + + expect(await firstReplay, 1); + final pendingAfterFirst = await database.pendingOpsDao + .pendingOpsForReplay('account', _later); + expect(pendingAfterFirst, hasLength(1)); + expect( + jsonDecode(pendingAfterFirst.single.requestJson), + containsPair('backgroundColor', secondColor.backgroundColor), + ); + expect( + (await database.select(database.calendarSources).getSingle()) + .backgroundColor, + secondColor.backgroundColor, + ); + + client.calendarPatchGate = null; + expect(await replayer().replayDueOps(), 1); + + expect( + client.calendarMutations.map((mutation) => mutation.backgroundColor), + [firstColor.backgroundColor, secondColor.backgroundColor], + ); + expect( + await database.pendingOpsDao.pendingOpsForReplay('account', _later), + isEmpty, + ); + expect( + (await database.select(database.calendarSources).getSingle()) + .backgroundColor, + secondColor.backgroundColor, + ); + }, + ); + test( 'Google calendar delete rejected with 403 restores the source', () async { @@ -2099,6 +2212,7 @@ class _FakeCalendarClient final calls = []; final createdMutations = []; final updatedMutations = []; + final calendarMutations = []; final guestUpdatePolicies = []; final invitationResponses = []; int _createdCount = 0; @@ -2112,6 +2226,7 @@ class _FakeCalendarClient Object? calendarDeleteError; GoogleCalendarApiError? calendarListDeleteError; Completer? createEventGate; + Completer? calendarPatchGate; @override BusyProvider get provider => BusyProvider.google; @@ -2364,6 +2479,8 @@ class _FakeCalendarClient CalendarMutation mutation, ) async { calls.add('updateCalendar:$calendarId:${mutation.summary}'); + calendarMutations.add(mutation); + await calendarPatchGate?.future; return CalendarSourceDto( provider: BusyProvider.google, providerCalendarId: calendarId, @@ -2378,6 +2495,8 @@ class _FakeCalendarClient CalendarMutation mutation, ) async { calls.add('updateCalendarListEntry:$calendarId:${mutation.summary}'); + calendarMutations.add(mutation); + await calendarPatchGate?.future; return CalendarSourceDto( provider: BusyProvider.google, providerCalendarId: calendarId, From 8688453a09b6e654dadf511e5ea1ba603378ae1c Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 16:25:50 -0700 Subject: [PATCH 55/69] Make calendar event creation retry-safe --- .../calendar_create_identity.dart | 18 ++ .../calendar_providers/calendar_mutation.dart | 2 + .../calendar/data/calendar_repository.dart | 35 ++-- .../sync/calendar_pending_ops_replayer.dart | 66 ++++++- .../microsoft_calendar_mapper.dart | 1 + .../data/calendar_repository_test.dart | 11 ++ .../calendar_pending_ops_replayer_test.dart | 168 +++++++++++++++++- .../google_calendar_mapper_test.dart | 22 +++ .../microsoft_calendar_mapper_test.dart | 8 + 9 files changed, 302 insertions(+), 29 deletions(-) create mode 100644 lib/src/calendar_providers/calendar_create_identity.dart diff --git a/lib/src/calendar_providers/calendar_create_identity.dart b/lib/src/calendar_providers/calendar_create_identity.dart new file mode 100644 index 00000000..a937e6ea --- /dev/null +++ b/lib/src/calendar_providers/calendar_create_identity.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +const calendarEventGoogleCreateIdKey = '_googleCreateEventId'; +const calendarEventMicrosoftTransactionIdKey = '_microsoftTransactionId'; + +/// Google event IDs accept lowercase base32hex characters. A SHA-256 digest +/// uses only the permitted 0-9 and a-f subset and remains stable across retries. +String googleCalendarCreateEventId(String operationId) { + return sha256 + .convert(utf8.encode('busymax:event-create:$operationId')) + .toString(); +} + +/// Pending cloud operations use UUIDs, which are suitable Graph transaction +/// identifiers and are already persisted before the request is sent. +String microsoftCalendarCreateTransactionId(String operationId) => operationId; diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 1d220044..8a7b851d 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -70,6 +70,7 @@ class CalendarEventMutation { this.hideAttendees, this.allowNewTimeProposals, this.providerEventId, + this.transactionId, this.providerRaw, }); @@ -101,5 +102,6 @@ class CalendarEventMutation { final bool? hideAttendees; final bool? allowNewTimeProposals; final String? providerEventId; + final String? transactionId; final Map? providerRaw; } diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index fb02050d..f0b42d54 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -4,8 +4,9 @@ import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; -import '../../../calendar_providers/calendar_mutation.dart'; import '../../../calendar_providers/calendar_colors.dart'; +import '../../../calendar_providers/calendar_create_identity.dart'; +import '../../../calendar_providers/calendar_mutation.dart'; import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../calendar_providers/calendar_sync_dto.dart'; import '../../../core/time/provider_date_time.dart'; @@ -1114,17 +1115,29 @@ class CalendarRepository { providerCalendarId: draft.providerCalendarId, providerEventId: localEventId, ); - final requestJson = jsonEncode( - _eventRequest( - draft, - provider, - isCreate: true, - startTimeZone: startTimeZone, - endTimeZone: endTimeZone, - conference: conferenceRequest, - guestUpdatePolicy: guestUpdatePolicy, - ), + final request = _eventRequest( + draft, + provider, + isCreate: true, + startTimeZone: startTimeZone, + endTimeZone: endTimeZone, + conference: conferenceRequest, + guestUpdatePolicy: guestUpdatePolicy, ); + switch (provider) { + case BusyProvider.google: + request[calendarEventGoogleCreateIdKey] = googleCalendarCreateEventId( + operationId, + ); + case BusyProvider.microsoft: + request[calendarEventMicrosoftTransactionIdKey] = + microsoftCalendarCreateTransactionId(operationId); + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + case BusyProvider.webCal: + break; + } + final requestJson = jsonEncode(request); await _database.transaction(() async { await _database .into(_database.calendarEvents) diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 6904099e..faebabec 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -3,8 +3,9 @@ import 'dart:math'; import 'package:drift/drift.dart'; -import '../../calendar_providers/calendar_mutation.dart'; +import '../../calendar_providers/calendar_create_identity.dart'; import '../../calendar_providers/calendar_description.dart'; +import '../../calendar_providers/calendar_mutation.dart'; import '../../calendar_providers/calendar_sync_dto.dart'; import '../../calendar_providers/cloud_calendar_client.dart'; import '../../core/time/provider_date_time.dart'; @@ -342,16 +343,61 @@ class CalendarPendingOpsReplayer { Future _createEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); + final request = await _eventCreateRequest(op); + final googleEventId = _client.provider == BusyProvider.google + ? request[calendarEventGoogleCreateIdKey]?.toString() + : null; + late final CalendarEventDto event; + try { + event = await _client.createEvent( + calendarId: providerCalendarId, + mutation: _eventMutation( + request, + fallbackTimeZone: await _fallbackTimeZone(op), + providerEventId: googleEventId, + transactionId: request[calendarEventMicrosoftTransactionIdKey] + ?.toString(), + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + } on GoogleCalendarApiError catch (error) { + if (_client.provider != BusyProvider.google || + googleEventId == null || + error.statusCode != 409) { + rethrow; + } + event = await _client.getEvent( + calendarId: providerCalendarId, + eventId: googleEventId, + ); + } + await _replaceLocalEvent(op, event); + } + + Future> _eventCreateRequest(PendingOp op) async { final request = _request(op); - final event = await _client.createEvent( - calendarId: providerCalendarId, - mutation: _eventMutation( - request, - fallbackTimeZone: await _fallbackTimeZone(op), + final identity = switch (_client.provider) { + BusyProvider.google => ( + key: calendarEventGoogleCreateIdKey, + value: googleCalendarCreateEventId(op.id), ), - guestUpdatePolicy: _guestUpdatePolicy(request), - ); - await _replaceLocalEvent(op, event); + BusyProvider.microsoft => ( + key: calendarEventMicrosoftTransactionIdKey, + value: microsoftCalendarCreateTransactionId(op.id), + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + if (identity == null || + request[identity.key]?.toString().trim().isNotEmpty == true) { + return request; + } + request[identity.key] = identity.value; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(op.id))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + return request; } Future _patchEvent(PendingOp op) async { @@ -1288,6 +1334,7 @@ class CalendarPendingOpsReplayer { Map request, { String? fallbackTimeZone, String? providerEventId, + String? transactionId, Map? providerRaw, }) { final clearFields = _eventClearFields(request); @@ -1338,6 +1385,7 @@ class CalendarPendingOpsReplayer { hideAttendees: request['hideAttendees'] as bool?, allowNewTimeProposals: request['allowNewTimeProposals'] as bool?, providerEventId: providerEventId, + transactionId: transactionId, providerRaw: providerRaw, ); } diff --git a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart index 563985de..8123ea98 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart @@ -121,6 +121,7 @@ Map microsoftEventMutationToJson( 'responseRequested': mutation.responseRequested, 'hideAttendees': mutation.hideAttendees, 'allowNewTimeProposals': mutation.allowNewTimeProposals, + 'transactionId': mutation.transactionId, if (mutation.reminders is Map) ..._reminderPatch(mutation.reminders!), if (_onlineMeetingProvider(mutation.conference) != null) ...{ 'isOnlineMeeting': true, diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index eb0dc99e..9a10a41d 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/calendar_providers/calendar_create_identity.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; @@ -385,6 +386,10 @@ void main() { request[calendarEventGuestUpdatePolicyKey], CalendarGuestUpdatePolicy.doNotSend.name, ); + expect( + request[calendarEventGoogleCreateIdKey], + googleCalendarCreateEventId(operation.id), + ); expect(createRequest['requestId'], isNotEmpty); expect(createRequest['conferenceSolutionKey'], {'type': 'hangoutsMeet'}); expect(jsonDecode(event.conferenceJson!), conference); @@ -419,6 +424,8 @@ void main() { ); final event = await database.select(database.calendarEvents).getSingle(); + final operation = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(operation.requestJson) as Map; expect(jsonDecode(event.rawJson!), { 'importance': 'high', 'responseRequested': false, @@ -426,6 +433,10 @@ void main() { 'allowNewTimeProposals': false, 'isOrganizer': true, }); + expect( + request[calendarEventMicrosoftTransactionIdKey], + microsoftCalendarCreateTransactionId(operation.id), + ); }); test( diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index b95ff848..10c441ae 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/calendar_providers/calendar_create_identity.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_provider_capabilities.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; @@ -69,7 +70,10 @@ void main() { isEmpty, ); final rows = await database.select(database.calendarEvents).get(); - expect(rows.single.providerEventId, 'server-event-1'); + expect( + rows.single.providerEventId, + client.createdMutations.single.providerEventId, + ); expect(rows.single.startTimeZone, 'America/Vancouver'); expect(rows.single.endTimeZone, 'America/Vancouver'); expect(client.createdMutations.single.startTimeZone, 'America/Vancouver'); @@ -111,6 +115,121 @@ void main() { expect(client.calls, ['createEvent:cal-1:Planning']); }); + test('Google create retry reuses its ID and reconciles a 409', () async { + final repository = CalendarRepository(database: database); + final operationId = await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'account|google|cal-1', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + final expectedEventId = googleCalendarCreateEventId(operationId); + final queued = await database.pendingOpsDao.getOp(operationId); + expect( + jsonDecode(queued!.requestJson), + containsPair(calendarEventGoogleCreateIdKey, expectedEventId), + ); + client.createEventResponseError = StateError('response lost'); + + final firstApplied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + final secondApplied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + + expect(firstApplied, 0); + expect(secondApplied, 1); + expect( + client.createdMutations.map((mutation) => mutation.providerEventId), + [expectedEventId, expectedEventId], + ); + expect(client.distinctCreatedEventCount, 1); + expect(client.calls, [ + 'createEvent:cal-1:Planning', + 'createEvent:cal-1:Planning', + 'getEvent:cal-1:$expectedEventId', + ]); + final local = await database.select(database.calendarEvents).getSingle(); + expect(local.providerEventId, expectedEventId); + expect(await database.pendingOpsDao.getOp(operationId), equals(null)); + }); + + test('Microsoft create retry reuses its transaction ID', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-ms', + summary: 'Microsoft calendar', + ), + ); + final operationId = await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'account|microsoft|cal-ms', + providerCalendarId: 'cal-ms', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + final expectedTransactionId = microsoftCalendarCreateTransactionId( + operationId, + ); + final queued = await database.pendingOpsDao.getOp(operationId); + expect( + jsonDecode(queued!.requestJson), + containsPair( + calendarEventMicrosoftTransactionIdKey, + expectedTransactionId, + ), + ); + final microsoftClient = _FakeMicrosoftCalendarClient() + ..createEventResponseError = StateError('response lost'); + + final firstApplied = await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(); + final secondApplied = await CalendarPendingOpsReplayer( + database: database, + client: microsoftClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + + expect(firstApplied, 0); + expect(secondApplied, 1); + expect( + microsoftClient.createdMutations.map( + (mutation) => mutation.transactionId, + ), + [expectedTransactionId, expectedTransactionId], + ); + expect(microsoftClient.distinctCreatedEventCount, 1); + expect(microsoftClient.calls, [ + 'createEvent:cal-ms:Planning', + 'createEvent:cal-ms:Planning', + ]); + final local = await (database.select( + database.calendarEvents, + )..where((row) => row.provider.equals('microsoft'))).getSingle(); + expect(local.providerEventId, 'server-event-1'); + expect(await database.pendingOpsDao.getOp(operationId), equals(null)); + }); + test('event replay preserves a do-not-send guest update choice', () async { await CalendarRepository(database: database).createLocalEvent( EventEditorDraft.newEvent( @@ -789,10 +908,7 @@ void main() { var deleteRequest = jsonDecode(deleteOperation.requestJson) as Map; expect(deleteRequest[calendarEventCopyConfirmedKey], isTrue); - expect( - deleteRequest[calendarEventCopyDestinationEventIdKey], - contains('server-event-1'), - ); + expect(deleteRequest[calendarEventCopyDestinationEventIdKey], isNotEmpty); client.calls.clear(); final deleted = await CalendarPendingOpsReplayer( @@ -2215,6 +2331,7 @@ class _FakeCalendarClient final calendarMutations = []; final guestUpdatePolicies = []; final invitationResponses = []; + final Map _createdEventsByIdentity = {}; int _createdCount = 0; int transientUpdateFailures = 0; CalendarEventDto? syncEvent; @@ -2227,6 +2344,9 @@ class _FakeCalendarClient GoogleCalendarApiError? calendarListDeleteError; Completer? createEventGate; Completer? calendarPatchGate; + Object? createEventResponseError; + + int get distinctCreatedEventCount => _createdEventsByIdentity.length; @override BusyProvider get provider => BusyProvider.google; @@ -2258,14 +2378,39 @@ class _FakeCalendarClient createdMutations.add(mutation); guestUpdatePolicies.add(guestUpdatePolicy); await createEventGate?.future; + final identity = provider == BusyProvider.google + ? mutation.providerEventId + : mutation.transactionId; + final existing = identity == null + ? null + : _createdEventsByIdentity[identity]; + if (existing != null) { + if (provider == BusyProvider.google) { + throw const GoogleCalendarApiError( + statusCode: 409, + code: 'ALREADY_EXISTS', + message: 'The requested identifier already exists.', + ); + } + return existing; + } _createdCount += 1; - return _event( - 'server-event-$_createdCount', + final event = _event( + provider == BusyProvider.google && identity != null + ? identity + : 'server-event-$_createdCount', title: mutation.title ?? '', providerCalendarId: calendarId, startTimeZone: mutation.startTimeZone, endTimeZone: mutation.endTimeZone, ); + if (identity != null) { + _createdEventsByIdentity[identity] = event; + } + final responseError = createEventResponseError; + createEventResponseError = null; + if (responseError != null) throw responseError; + return event; } @override @@ -2321,7 +2466,12 @@ class _FakeCalendarClient required String eventId, }) async { calls.add('getEvent:$calendarId:$eventId'); - return remoteEvent ?? _event(eventId, title: 'Base'); + final remote = remoteEvent; + if (remote != null) return remote; + for (final created in _createdEventsByIdentity.values) { + if (created.providerEventId == eventId) return created; + } + return _event(eventId, title: 'Base'); } @override @@ -2398,7 +2548,7 @@ class _FakeCalendarClient String? endTimeZone, }) { return CalendarEventDto( - provider: BusyProvider.google, + provider: provider, providerCalendarId: providerCalendarId, providerEventId: id, etagOrChangeKey: etagOrChangeKey, diff --git a/test/google_calendar/google_calendar_mapper_test.dart b/test/google_calendar/google_calendar_mapper_test.dart index 50fb610b..29910407 100644 --- a/test/google_calendar/google_calendar_mapper_test.dart +++ b/test/google_calendar/google_calendar_mapper_test.dart @@ -1,8 +1,21 @@ +import 'package:busymax/src/calendar_providers/calendar_create_identity.dart'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('Google create identity is stable and uses the valid ID alphabet', () { + final first = googleCalendarCreateEventId( + '00112233-4455-6677-8899-aabbccddeeff', + ); + final second = googleCalendarCreateEventId( + '00112233-4455-6677-8899-aabbccddeeff', + ); + + expect(first, second); + expect(first, matches(RegExp(r'^[0-9a-v]{5,1024}$'))); + }); + test('Google calendar source maps data owner and personal name', () { final source = googleCalendarSourceFromJson({ 'id': 'shared@example.com', @@ -47,4 +60,13 @@ void main() { conference, ); }); + + test('Google event create serializes the client-assigned event ID', () { + expect( + googleEventMutationToJson( + const CalendarEventMutation(providerEventId: '0123456789abcdef'), + )['id'], + '0123456789abcdef', + ); + }); } diff --git a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart index 3905b9fc..0ff91a9b 100644 --- a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart @@ -148,4 +148,12 @@ void main() { expect(body['isOnlineMeeting'], isTrue); expect(body['onlineMeetingProvider'], 'teamsForBusiness'); }); + + test('Microsoft event create serializes the transaction ID', () { + final body = microsoftEventMutationToJson( + const CalendarEventMutation(transactionId: 'transaction-1'), + ); + + expect(body['transactionId'], 'transaction-1'); + }); } From c0d86e04f6b33432255c136cc1e94f7eb06f2f45 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 16:39:54 -0700 Subject: [PATCH 56/69] Order task mutations after creation --- .../features/sync/pending_ops_replayer.dart | 163 +++++++++---- .../features/tasks/data/tasks_repository.dart | 151 +++++++++--- .../sync/pending_ops_replayer_test.dart | 224 ++++++++++++++++++ .../tasks/data/tasks_repository_test.dart | 98 ++++++++ 4 files changed, 559 insertions(+), 77 deletions(-) diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index 2264b59f..f955ee50 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -49,38 +49,54 @@ class PendingOpsReplayer { _nowUtc(), ); var applied = 0; + final handledIds = {}; + var madeProgress = true; + + while (madeProgress) { + madeProgress = false; + for (final originalOp in ops) { + if (handledIds.contains(originalOp.id)) continue; + final op = await _readOp(originalOp.id); + if (op == null || !_isTaskOp(op)) { + handledIds.add(originalOp.id); + continue; + } + if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { + continue; + } + if (op.operation == 'create_task' && !await _claimTaskCreate(op)) { + continue; + } + handledIds.add(op.id); + madeProgress = true; - for (final originalOp in ops) { - final op = await _readOp(originalOp.id); - if (op == null || !_isTaskOp(op)) { - continue; - } - if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { - continue; - } - - try { - await _replay(op); - await _database.pendingOpsDao.deleteOp(op.id); - applied += 1; - } on TaskRemoteError catch (error) { - if (_isSuccessfulMissingDelete(op, error)) { - await _applyDeleteSideEffect(op); + try { + await _replay(op); await _database.pendingOpsDao.deleteOp(op.id); applied += 1; - } else if (_isRetryableStatus(error.statusCode)) { - await _scheduleRetry(op, error.statusCode.toString(), error.message); - } else { - await _blockOp(op, error.statusCode.toString(), error.message); + } on TaskRemoteError catch (error) { + if (_isSuccessfulMissingDelete(op, error)) { + await _applyDeleteSideEffect(op); + await _database.pendingOpsDao.deleteOp(op.id); + applied += 1; + } else if (_isRetryableStatus(error.statusCode)) { + await _scheduleRetry( + op, + error.statusCode.toString(), + error.message, + ); + } else { + await _blockOp(op, error.statusCode.toString(), error.message); + } + } on _PendingOpBlocked { + continue; + } on Object catch (error) { + await _scheduleRetry( + op, + error.runtimeType.toString(), + error.toString(), + ); } - } on _PendingOpBlocked { - continue; - } on Object catch (error) { - await _scheduleRetry( - op, - error.runtimeType.toString(), - error.toString(), - ); } } @@ -149,7 +165,11 @@ class PendingOpsReplayer { 'AND task_list_id = ?', [dto.id, _accountId, tempId], ); - await _replacePendingReference(tempId, dto.id); + await _replacePendingReference( + tempId, + dto.id, + completedCreateOpId: op.id, + ); await _database.taskListsDao.deleteTaskList(_accountId, tempId); }); } @@ -204,6 +224,7 @@ class PendingOpsReplayer { taskListId: op.taskListId!, tempTaskId: tempId, serverTask: dto, + completedCreateOpId: op.id, ); } @@ -504,13 +525,13 @@ class PendingOpsReplayer { required String taskListId, required String tempTaskId, required TaskDto serverTask, + required String completedCreateOpId, }) async { await _database.transaction(() async { final localTask = await (_database.select(_database.tasks)..where( (row) => row.accountId.equals(_accountId) & - row.taskListId.equals(taskListId) & row.id.equals(tempTaskId), )) .getSingleOrNull(); @@ -537,15 +558,27 @@ class PendingOpsReplayer { 'UPDATE tasks SET parent = ? WHERE account_id = ? AND parent = ?', [serverTask.id, _accountId, tempTaskId], ); - await _replacePendingReference(tempTaskId, serverTask.id); - await _database.tasksDao.deleteTask(_accountId, taskListId, tempTaskId); + await _replacePendingReference( + tempTaskId, + serverTask.id, + completedCreateOpId: completedCreateOpId, + ); + await _database.tasksDao.deleteTask( + _accountId, + localTask?.taskListId ?? taskListId, + tempTaskId, + ); }); } Future _replacePendingReference( String oldValue, - String newValue, - ) async { + String newValue, { + required String completedCreateOpId, + }) async { + final ops = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); await _database.customStatement( 'UPDATE pending_ops SET task_list_id = ? WHERE account_id = ? ' 'AND task_list_id = ?', @@ -561,21 +594,33 @@ class PendingOpsReplayer { [newValue, _accountId, oldValue], ); - final ops = await (_database.select( - _database.pendingOps, - )..where((row) => row.accountId.equals(_accountId))).get(); for (final op in ops) { final request = _request(op); final rewritten = _replaceJsonReference(request, oldValue, newValue); final rewrittenJson = jsonEncode(rewritten); - if (rewrittenJson == op.requestJson) { + final referenceChanged = + op.taskListId == oldValue || + op.taskId == oldValue || + op.localTempId == oldValue || + rewrittenJson != op.requestJson; + final unblock = + op.id != completedCreateOpId && + referenceChanged && + op.nextAttemptAtUtc?.startsWith('9999-12-31') == true; + if (rewrittenJson == op.requestJson && !unblock) { continue; } await (_database.update( _database.pendingOps, )..where((row) => row.id.equals(op.id))).write( PendingOpsCompanion( - requestJson: Value(rewrittenJson), + requestJson: rewrittenJson == op.requestJson + ? const Value.absent() + : Value(rewrittenJson), + state: unblock ? const Value('pending') : const Value.absent(), + nextAttemptAtUtc: unblock ? const Value(null) : const Value.absent(), + lastErrorCode: unblock ? const Value(null) : const Value.absent(), + lastErrorMessage: unblock ? const Value(null) : const Value.absent(), updatedAtUtc: Value(_now()), ), ); @@ -620,25 +665,57 @@ class PendingOpsReplayer { PendingOp op, String errorCode, String errorMessage, - ) { + ) async { final nextAttempt = _nextAttempt(op.attemptCount); - return _database.pendingOpsDao.updateAttempt( + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, nextAttemptAtUtc: nextAttempt, lastErrorCode: errorCode, lastErrorMessage: errorMessage, ); + if (op.operation == 'create_task') { + await _setPendingOpState(op.id, 'retry'); + } } - Future _blockOp(PendingOp op, String errorCode, String errorMessage) { - return _database.pendingOpsDao.updateAttempt( + Future _blockOp( + PendingOp op, + String errorCode, + String errorMessage, + ) async { + await _database.pendingOpsDao.updateAttempt( id: op.id, attemptCount: op.attemptCount + 1, nextAttemptAtUtc: DateTime.utc(9999, 12, 31), lastErrorCode: errorCode, lastErrorMessage: errorMessage, ); + if (op.operation == 'create_task') { + await _setPendingOpState(op.id, 'failed'); + } + } + + Future _claimTaskCreate(PendingOp op) async { + final query = _database.update(_database.pendingOps) + ..where( + (row) => + row.id.equals(op.id) & + row.state.equals(op.state) & + row.attemptCount.equals(op.attemptCount) & + row.requestJson.equals(op.requestJson) & + row.updatedAtUtc.equals(op.updatedAtUtc), + ); + return await query.write( + const PendingOpsCompanion(state: Value('in_progress')), + ) == + 1; + } + + Future _setPendingOpState(String id, String state) { + return (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(id))) + .write(PendingOpsCompanion(state: Value(state))); } DateTime _nextAttempt(int attemptCount) { diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index cd762bb9..3f0ca4f4 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -824,27 +824,38 @@ class TasksRepository { return; } final now = _now(); - final baseline = await _baselineRow(taskListId, taskId); - await _writeLocalTask( - taskListId, - taskId, - TasksCompanion( - pendingDelete: const Value(true), - localDirty: const Value(true), - updatedLocalAtUtc: Value(now), - ), - ); - await _enqueue( - operation: 'delete_task', - taskListId: taskListId, - taskId: taskId, - request: const {}, - baselineUpdatedUtc: baseline?.updatedUtc, - baselineRawJson: baseline?.rawJson, - createdAtUtc: now, - ); + var queuedRemoteDelete = false; + await _database.transaction(() async { + final pendingCreate = await _pendingUnsentTaskCreate(taskId); + if (pendingCreate != null) { + await _cancelPendingTaskCreation(pendingCreate); + return; + } + final baseline = await _baselineRow(taskListId, taskId); + await _writeLocalTask( + taskListId, + taskId, + TasksCompanion( + pendingDelete: const Value(true), + localDirty: const Value(true), + updatedLocalAtUtc: Value(now), + ), + ); + await _enqueue( + operation: 'delete_task', + taskListId: taskListId, + taskId: taskId, + request: const {}, + baselineUpdatedUtc: baseline?.updatedUtc, + baselineRawJson: baseline?.rawJson, + createdAtUtc: now, + ); + queuedRemoteDelete = true; + }); await _rebuildTaskNotifications(); - _onMutationQueued?.call(); + if (queuedRemoteDelete) { + _onMutationQueued?.call(); + } } Future moveTask(TaskMoveInput input) async { @@ -2342,7 +2353,7 @@ class TasksRepository { }) async { final operationId = _uuid.v4(); await _database.transaction(() async { - final predecessor = await _latestPendingTaskEdit( + final predecessor = await _latestPendingTaskOperation( operation: operation, taskListId: taskListId, taskId: taskId, @@ -2368,41 +2379,101 @@ class TasksRepository { return operationId; } - Future _latestPendingTaskEdit({ + Future _latestPendingTaskOperation({ required String operation, required String? taskListId, required String? taskId, }) async { - if ((operation != 'patch_task' && operation != 'update_task') || + if (!_taskMutationOperations.contains(operation) || taskListId == null || taskId == null) { return null; } + final pendingCreate = await _pendingTaskCreate(taskId); + final pendingLocalId = pendingCreate?.localTempId ?? pendingCreate?.taskId; final query = _database.select(_database.pendingOps) ..where( (row) => row.accountId.equals(_accountId) & row.entityType.equals('task') & - row.taskListId.equals(taskListId) & - row.taskId.equals(taskId) & - (row.operation.equals('patch_task') | - row.operation.equals('update_task')), + row.operation.isIn(_taskMutationPredecessorOperations) & + (pendingLocalId == null + ? row.taskListId.equals(taskListId) & row.taskId.equals(taskId) + : row.taskId.equals(pendingLocalId) | + row.localTempId.equals(pendingLocalId)), ) ..orderBy([ (row) => OrderingTerm.desc(row.createdAtUtc), (row) => OrderingTerm.desc(row.updatedAtUtc), ]); - final edits = await query.get(); + final operations = await query.get(); final predecessorIds = { - for (final edit in edits) - if (edit.dependsOnOpId != null) edit.dependsOnOpId!, + for (final operation in operations) + if (operation.dependsOnOpId != null) operation.dependsOnOpId!, }; - for (final edit in edits) { - if (!predecessorIds.contains(edit.id)) { - return edit; + for (final operation in operations) { + if (!predecessorIds.contains(operation.id)) { + return operation; + } + } + return operations.isEmpty ? null : operations.first; + } + + Future _pendingTaskCreate(String taskId) { + final query = _database.select(_database.pendingOps) + ..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('task') & + row.operation.equals('create_task') & + (row.taskId.equals(taskId) | row.localTempId.equals(taskId)), + ) + ..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)]); + return query.get().then((operations) => operations.firstOrNull); + } + + Future _pendingUnsentTaskCreate(String taskId) async { + final create = await _pendingTaskCreate(taskId); + return create != null && + create.state == 'pending' && + create.attemptCount == 0 + ? create + : null; + } + + Future _cancelPendingTaskCreation(PendingOp create) async { + final localTaskId = create.localTempId ?? create.taskId; + if (localTaskId == null) return; + final operations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(_accountId))).get(); + final discardedIds = {create.id}; + var changed = true; + while (changed) { + changed = false; + for (final operation in operations) { + final targetsLocalTask = + (operation.entityType == 'task' || + operation.entityType == 'task_checklist_item') && + (operation.taskId == localTaskId || + operation.localTempId == localTaskId); + if (discardedIds.contains(operation.id) || + (!targetsLocalTask && + !discardedIds.contains(operation.dependsOnOpId))) { + continue; + } + discardedIds.add(operation.id); + changed = true; } } - return edits.isEmpty ? null : edits.first; + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).go(); + await (_database.delete(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & row.id.equals(localTaskId), + )) + .go(); } Future _baselineRow(String taskListId, String taskId) { @@ -2540,6 +2611,18 @@ class TasksRepository { String _now() => _nowUtc().toIso8601String(); } +const _taskMutationOperations = { + 'patch_task', + 'update_task', + 'move_task', + 'delete_task', +}; + +const _taskMutationPredecessorOperations = { + 'create_task', + ..._taskMutationOperations, +}; + TaskChecklistItemEntity taskChecklistItemFromDto(TaskChecklistItemDto dto) { final raw = { ...dto.rawJson, diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index 52c59baa..6efb02cf 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -193,6 +193,227 @@ void main() { expect(apiClient.calls, ['create_task:list-1']); }); + test('task edit waits while its creation is retrying', () async { + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final localTask = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.patchTask( + 'list-1', + localTask.id, + const TaskPatchInput({'title': 'Edited offline'}), + ); + apiClient.createTaskError = const GoogleTasksApiError( + statusCode: 503, + message: 'Temporarily unavailable', + ); + + final firstApplied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(firstApplied, 0); + expect(apiClient.calls, ['create_task:list-1']); + var operations = await database.select(database.pendingOps).get(); + final pendingCreate = operations.singleWhere( + (operation) => operation.operation == 'create_task', + ); + final pendingPatch = operations.singleWhere( + (operation) => operation.operation == 'patch_task', + ); + expect(pendingCreate.state, 'retry'); + expect(pendingCreate.attemptCount, 1); + expect(pendingPatch.dependsOnOpId, pendingCreate.id); + expect(pendingPatch.attemptCount, 0); + + apiClient.createTaskError = null; + final secondApplied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 5), + ).replayDueOps(); + + expect(secondApplied, 2); + expect(apiClient.calls, [ + 'create_task:list-1', + 'create_task:list-1', + 'patch_task:task-server', + ]); + expect(apiClient.taskPatchFields.single['title'], 'Edited offline'); + operations = await database.select(database.pendingOps).get(); + expect(operations, isEmpty); + }); + + test('deleting a task whose create is in flight queues after it', () async { + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final localTask = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + final gate = Completer(); + apiClient.createTaskGate = gate; + final firstReplay = PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + await _waitFor(() => apiClient.calls.isNotEmpty); + + await repository.deleteTask('list-1', localTask.id); + + var operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (operation) => operation.operation == 'create_task', + ); + final delete = operations.singleWhere( + (operation) => operation.operation == 'delete_task', + ); + expect(create.state, 'in_progress'); + expect(delete.dependsOnOpId, create.id); + + gate.complete(); + expect(await firstReplay, 1); + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(apiClient.calls, ['create_task:list-1', 'delete_task:task-server']); + operations = await database.select(database.pendingOps).get(); + expect(operations, isEmpty); + }); + + test('server ID rewrite revives a legacy blocked task edit', () async { + await database.tasksDao.upsertTask( + _task('list-1', 'local-task-1', title: 'Draft'), + ); + await _enqueue( + database, + id: '01', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + localTempId: 'local-task-1', + request: { + 'body': {'title': 'Draft'}, + }, + ); + await _enqueue( + database, + id: '02', + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'local-task-1', + request: {'title': 'Edited offline'}, + ); + await (database.update( + database.pendingOps, + )..where((row) => row.id.equals('02'))).write( + const PendingOpsCompanion( + state: Value('failed'), + attemptCount: Value(1), + nextAttemptAtUtc: Value('9999-12-31T00:00:00.000Z'), + lastErrorCode: Value('400'), + lastErrorMessage: Value('Invalid temporary task ID'), + ), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + + final revived = await database.pendingOpsDao.getOp('02'); + expect(revived!.taskId, 'task-server'); + expect(revived.state, 'pending'); + expect(revived.nextAttemptAtUtc, equals(null)); + expect(revived.lastErrorCode, equals(null)); + expect(revived.lastErrorMessage, equals(null)); + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(apiClient.calls, ['create_task:list-1', 'patch_task:task-server']); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); + + test('new task can move to another list before creation sync', () async { + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final localTask = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.moveTask( + TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: localTask.id, + destinationTaskListId: 'list-2', + ), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 2, + ); + + expect(apiClient.calls, ['create_task:list-1', 'move_task:task-server']); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + final destination = await database.tasksDao.listTasks('account', 'list-2'); + expect(destination.single.id, 'task-server'); + }); + test( 'Google subtask is moved under its parent after a root insert', () async { @@ -1191,6 +1412,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { final createParentTaskIds = []; final moveParentTaskIds = []; GoogleTasksApiError? patchTaskListError; + GoogleTasksApiError? createTaskError; GoogleTasksApiError? deleteTaskError; GoogleTasksApiError? clearCompletedError; GoogleTasksApiError? moveTaskError; @@ -1247,6 +1469,8 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { calls.add('create_task:$taskListId'); createParentTaskIds.add(parentTaskId); await createTaskGate?.future; + final error = createTaskError; + if (error != null) throw error; return _taskDto('task-server', title: create.fields['title'].toString()); } diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index 57bdda1f..bb97e930 100644 --- a/test/features/tasks/data/tasks_repository_test.dart +++ b/test/features/tasks/data/tasks_repository_test.dart @@ -62,6 +62,104 @@ void main() { expect(mutationQueuedCalls, 1); }); + test('new task mutations form a chain rooted at its creation', () async { + var tick = 0; + repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, tick++), + ); + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final task = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + + await repository.patchTask( + 'list-1', + task.id, + const TaskPatchInput({'title': 'Patched'}), + ); + await repository.updateTaskFull( + 'list-1', + task.id, + TaskPutInput({'title': 'Updated'}), + ); + await repository.moveTask( + TaskMoveInput(sourceTaskListId: 'list-1', taskId: task.id), + ); + + final operations = await database.select(database.pendingOps).get(); + final create = operations.singleWhere( + (operation) => operation.operation == 'create_task', + ); + final patch = operations.singleWhere( + (operation) => operation.operation == 'patch_task', + ); + final update = operations.singleWhere( + (operation) => operation.operation == 'update_task', + ); + final move = operations.singleWhere( + (operation) => operation.operation == 'move_task', + ); + expect(patch.dependsOnOpId, create.id); + expect(update.dependsOnOpId, patch.id); + expect(move.dependsOnOpId, update.id); + }); + + test('deleting an unsent task cancels its pending operation chain', () async { + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final task = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + await repository.patchTask( + 'list-1', + task.id, + const TaskPatchInput({'title': 'Changed'}), + ); + await repository.moveTask( + TaskMoveInput(sourceTaskListId: 'list-1', taskId: task.id), + ); + + await repository.deleteTask('list-1', task.id); + + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + }); + + test('deleting an attempted create waits for its server identity', () async { + await repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Draft'), + ); + final task = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + final create = await database.select(database.pendingOps).getSingle(); + await (database.update( + database.pendingOps, + )..where((row) => row.id.equals(create.id))).write( + const PendingOpsCompanion(state: Value('retry'), attemptCount: Value(1)), + ); + + await repository.deleteTask('list-1', task.id); + + final operations = await database.select(database.pendingOps).get(); + final delete = operations.singleWhere( + (operation) => operation.operation == 'delete_task', + ); + expect(operations, hasLength(2)); + expect(delete.dependsOnOpId, create.id); + }); + test('Google subtask queues a dependent hierarchy move', () async { await database.tasksDao.upsertTask(_task(id: 'parent', position: '1')); From 36a6f5bd154d414381e9f10b7297c31d38f8d3b3 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 16:49:35 -0700 Subject: [PATCH 57/69] Rebuild DAV notification schedules --- lib/src/app/app_bootstrap.dart | 9 +- test/app/app_bootstrap_provider_test.dart | 255 ++++++++++++++++++++++ 2 files changed, 262 insertions(+), 2 deletions(-) diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index e2deef08..84568ee9 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -28,6 +28,7 @@ import '../features/auth/data/auth_repository.dart'; import '../features/connectivity/network_connectivity_service.dart'; import '../features/feedback/data/feedback_api_client.dart'; import '../features/notifications/desktop_notification_service.dart'; +import '../features/notifications/notification_schedule_service.dart'; import '../features/notifications/notification_scheduler.dart'; import '../features/sync/account_sync_operations.dart'; import '../features/sync/all_accounts_sync_scheduler.dart'; @@ -564,8 +565,12 @@ final davAccountSyncEngineFactoryProvider = secretStore: ref.read(secretStoreProvider), httpClient: ref.read(baseHttpClientProvider), accountId: accountId, - rebuildNotifications: (accountId, affectedObjectIds) => - ref.read(notificationSchedulerProvider).checkNow(), + rebuildNotifications: (accountId, _) async { + await NotificationScheduleService( + database: ref.read(databaseProvider), + ).rebuildUpcomingNotifications(accountId); + await ref.read(notificationSchedulerProvider).checkNow(); + }, reportPendingMutationFailure: (accountId, error) => ref .read(desktopNotificationServiceProvider) .notifySyncFailure(error), diff --git a/test/app/app_bootstrap_provider_test.dart b/test/app/app_bootstrap_provider_test.dart index e6a73efd..730779f4 100644 --- a/test/app/app_bootstrap_provider_test.dart +++ b/test/app/app_bootstrap_provider_test.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/dav/dav_provider_profile.dart'; import 'package:drift/native.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -8,11 +10,15 @@ import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; +import 'package:busymax/src/features/notifications/notification_scheduler.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; void main() { test('repositories are not created without an active account', () async { @@ -204,6 +210,255 @@ void main() { expect(account.authState, accountAuthStateReauthRequired); }, ); + + test( + 'production DAV sync rebuilds schedules before checking notifications', + () async { + final database = AppDatabase(NativeDatabase.memory()); + final secrets = InMemorySecretStore(); + await _seedNextcloudNotificationAccount(database, secrets); + final eventStartUtc = DateTime.now().toUtc().add(const Duration(days: 2)); + var syncReports = 0; + final client = MockClient((request) async { + if (request.method == 'REPORT' && + request.body.contains('sync-collection')) { + syncReports += 1; + if (syncReports == 1) { + return http.Response( + _davSyncResponse(token: 'token-1', etag: '"v1"'), + 207, + ); + } + return http.Response( + _davSyncResponse(token: 'token-2', deleted: true), + 207, + ); + } + if (request.method == 'REPORT' && + request.body.contains('calendar-multiget')) { + return http.Response(_davMultigetResponse(eventStartUtc), 207); + } + fail('Unexpected ${request.method} ${request.url}'); + }); + final scheduler = _TrackingNotificationScheduler(database); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + secretStoreProvider.overrideWithValue(secrets), + baseHttpClientProvider.overrideWithValue(client), + notificationSchedulerProvider.overrideWithValue(scheduler), + ], + ); + addTearDown(() async { + container.dispose(); + client.close(); + await database.close(); + }); + + await container + .read(davAccountSyncEngineFactoryProvider)('nextcloud-account') + .synchronize(); + + expect(syncReports, 1); + final objects = await database.select(database.davObjects).get(); + expect(objects, hasLength(1)); + expect(objects.single.lastParseStatus, 'parsed'); + final events = await database.select(database.calendarEvents).get(); + expect(events, hasLength(1)); + expect(events.single.remindersJson, contains('DISPLAY')); + var schedules = await database + .select(database.notificationSchedule) + .get(); + expect(schedules, hasLength(1)); + expect(schedules.single.sourceType, 'event'); + expect(schedules.single.title, 'Remote reminder'); + expect(scheduler.scheduleCountsAtCheck, [1]); + + await container + .read(davAccountSyncEngineFactoryProvider)('nextcloud-account') + .synchronize(); + + schedules = await database.select(database.notificationSchedule).get(); + expect(schedules, isEmpty); + expect(scheduler.scheduleCountsAtCheck, [1, 0]); + }, + ); +} + +const _nextcloudCollectionHref = '/cloud/remote.php/dav/calendars/alex/work/'; +const _nextcloudEventHref = '${_nextcloudCollectionHref}reminder.ics'; + +Future _seedNextcloudNotificationAccount( + AppDatabase database, + InMemorySecretStore secrets, +) async { + final now = DateTime.now().toUtc(); + final timestamp = now.toIso8601String(); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'nextcloud-account', + provider: 'nextcloud', + authority: 'https://cloud.example.test/cloud', + providerAccountId: 'alex', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await secrets.saveCredential( + 'nextcloud-account', + NextcloudSecretRecord( + canonicalServer: Uri.parse('https://cloud.example.test/cloud'), + loginName: 'alex', + appPassword: 'secret-app-password', + ), + ); + await database + .into(database.davAccountServices) + .insert( + DavAccountServicesCompanion.insert( + accountId: 'nextcloud-account', + providerProfileVersion: const Value(davProviderProfileVersion), + canonicalServiceUri: + 'https://cloud.example.test/cloud/remote.php/dav/', + canonicalOrigin: 'https://cloud.example.test', + principalHref: const Value( + 'https://cloud.example.test/cloud/remote.php/dav/principals/users/alex/', + ), + calendarHomeHref: const Value( + 'https://cloud.example.test/cloud/remote.php/dav/calendars/alex/', + ), + capabilitiesJson: const Value( + '{"hasPrincipal":true,"hasCalendarHome":true}', + ), + discoveredAtUtc: timestamp, + lastValidatedAtUtc: Value(timestamp), + ), + ); + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: 'nextcloud-collection', + accountId: 'nextcloud-account', + hrefKey: _nextcloudCollectionHref, + requestUri: 'https://cloud.example.test$_nextcloudCollectionHref', + displayName: 'Work', + supportedComponentMask: const Value(1), + supportedReportsJson: const Value( + '["{DAV:}sync-collection",' + '"{urn:ietf:params:xml:ns:caldav}calendar-multiget"]', + ), + currentUserPrivilegesJson: const Value( + '["{DAV:}read","{DAV:}write"]', + ), + readOnly: const Value(false), + eventProjectionEnabled: const Value(true), + lastInventoryAtUtc: Value(timestamp), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'dav-calendar-nextcloud-collection', + accountId: 'nextcloud-account', + provider: 'nextcloud', + providerCalendarId: _nextcloudCollectionHref, + davCollectionId: const Value('nextcloud-collection'), + summary: 'Work', + createdAtLocal: now.millisecondsSinceEpoch, + updatedAtLocal: now.millisecondsSinceEpoch, + ), + ); +} + +String _davSyncResponse({ + required String token, + String? etag, + bool deleted = false, +}) => + ''' + + ${etag == null && !deleted ? '' : ''' + $_nextcloudEventHref + ${deleted ? 'HTTP/1.1 404 Not Found' : '''$etag + HTTP/1.1 200 OK'''} + '''} + $token +'''; + +String _davMultigetResponse(DateTime eventStartUtc) { + final start = _icalUtc(eventStartUtc); + final end = _icalUtc(eventStartUtc.add(const Duration(hours: 1))); + return ''' + + + $_nextcloudEventHref + + "v1" + + HTTP/1.1 200 OK + +'''; +} + +String _icalUtc(DateTime value) { + final utc = value.toUtc(); + String twoDigits(int part) => part.toString().padLeft(2, '0'); + return '${utc.year.toString().padLeft(4, '0')}' + '${twoDigits(utc.month)}${twoDigits(utc.day)}T' + '${twoDigits(utc.hour)}${twoDigits(utc.minute)}' + '${twoDigits(utc.second)}Z'; +} + +final class _TrackingNotificationScheduler implements NotificationScheduler { + _TrackingNotificationScheduler(this.database); + + final AppDatabase database; + final List scheduleCountsAtCheck = []; + + @override + Future checkNow() async { + scheduleCountsAtCheck.add( + await database + .select(database.notificationSchedule) + .get() + .then((rows) => rows.length), + ); + } + + @override + Future handleActivation({ + required String notificationScheduleId, + required String action, + }) async {} + + @override + void start() {} + + @override + void stop() {} } Future _seedSignedInGoogleAccount(AppDatabase database) { From 355deda3d41c1f75ed6a4fba93a186e2f788a159 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 16:59:36 -0700 Subject: [PATCH 58/69] Resolve discarded event operations --- .../calendar/data/calendar_repository.dart | 179 ++++++++++++++++++ .../sync/pending_op_resolution_service.dart | 21 +- .../pending_op_resolution_service_test.dart | 88 +++++++++ 3 files changed, 281 insertions(+), 7 deletions(-) diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index f0b42d54..1d6bbd08 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -17,6 +17,8 @@ import '../../../dav/mutation/dav_pending_operations.dart'; import '../../../dav/mutation/dav_projection_mutations.dart'; import '../../../dav/storage/dav_object_repository.dart'; import '../../../db/app_database.dart'; +import '../../../google_calendar/google_calendar_mapper.dart'; +import '../../../microsoft_calendar/microsoft_calendar_mapper.dart'; import '../../accounts/domain/account_collection_creation_capabilities.dart'; import '../../notifications/notification_schedule_service.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; @@ -821,6 +823,183 @@ class CalendarRepository { await _onNotificationScheduleChanged?.call(); } + Future discardPendingEventCreation(PendingOp op) async { + if (_calendarOperationType(op) != 'event.create') return; + final eventId = op.eventId; + if (eventId == null) return; + final accountOperations = await (_database.select( + _database.pendingOps, + )..where((row) => row.accountId.equals(op.accountId))).get(); + final discardedIds = {op.id}; + var changed = true; + while (changed) { + changed = false; + for (final operation in accountOperations) { + if (discardedIds.contains(operation.id) || + (operation.eventId != eventId && + !discardedIds.contains(operation.dependsOnOpId))) { + continue; + } + discardedIds.add(operation.id); + changed = true; + } + } + final createdEventIds = { + for (final operation in accountOperations) + if (discardedIds.contains(operation.id) && + _calendarOperationType(operation) == 'event.create' && + operation.eventId != null) + operation.eventId!, + }; + await _database.transaction(() async { + for (final operation in accountOperations) { + if (!discardedIds.contains(operation.id) || + operation.entityType != 'event' || + operation.eventId == null || + createdEventIds.contains(operation.eventId)) { + continue; + } + await _restoreEventProjectionAfterDiscard( + operation, + ignoredOperationIds: discardedIds, + ); + } + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).go(); + await (_database.delete(_database.calendarEvents)..where( + (row) => + row.accountId.equals(op.accountId) & + row.id.isIn(createdEventIds), + )) + .go(); + }); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + op.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + + Future restoreEventAfterMutationDiscard(PendingOp op) async { + if (op.entityType != 'event' || + _calendarOperationType(op) == 'event.create') { + return; + } + await _database.transaction( + () => + _restoreEventProjectionAfterDiscard(op, ignoredOperationIds: {op.id}), + ); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + op.accountId, + ); + await _onNotificationScheduleChanged?.call(); + } + + Future _restoreEventProjectionAfterDiscard( + PendingOp op, { + required Set ignoredOperationIds, + }) async { + final eventId = op.eventId; + if (eventId == null) return; + final target = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingleOrNull(); + if (target == null) return; + final request = _jsonMap(op.requestJson); + final seriesProviderEventId = request[calendarEventTargetProviderIdKey] + ?.toString(); + final affectedEvents = seriesProviderEventId == null + ? [target] + : await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(target.accountId) & + row.provider.equals(target.provider) & + row.providerCalendarId.equals(target.providerCalendarId) & + (row.id.equals(target.id) | + row.providerEventId.equals(seriesProviderEventId) | + row.providerRecurringEventId.equals( + seriesProviderEventId, + )), + )) + .get(); + final affectedIds = {for (final event in affectedEvents) event.id}; + final remainingOperations = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(op.accountId) & + row.entityType.equals('event') & + row.id.isNotIn(ignoredOperationIds) & + row.eventId.isIn(affectedIds), + )) + .get(); + if (remainingOperations.isNotEmpty) return; + + for (final event in affectedEvents) { + final operationBaseline = event.id == eventId + ? _jsonMap(op.baselineRawJson) + : const {}; + final baseline = operationBaseline.isNotEmpty + ? operationBaseline + : _jsonMap(event.baselineRawJson); + if (baseline.isEmpty) { + throw StateError('The event recovery baseline is unavailable.'); + } + final provider = BusyProviderCodec.requireStorageValue(event.provider); + baseline.putIfAbsent('id', () => event.providerEventId); + switch (provider) { + case BusyProvider.google: + if (event.providerRecurringEventId case final recurringId?) { + baseline.putIfAbsent('recurringEventId', () => recurringId); + } + if (event.providerOriginalStartKey case final originalStart?) { + baseline.putIfAbsent( + 'originalStartTime', + () => originalStart.length == 10 + ? {'date': originalStart} + : {'dateTime': originalStart}, + ); + } + case BusyProvider.microsoft: + if (event.providerRecurringEventId case final recurringId?) { + baseline.putIfAbsent('seriesMasterId', () => recurringId); + } + if (event.providerOriginalStartKey case final originalStart?) { + baseline.putIfAbsent('originalStart', () => originalStart); + } + case BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal: + break; + } + final restored = switch (provider) { + BusyProvider.google => googleCalendarEventFromJson( + event.providerCalendarId, + baseline, + ), + BusyProvider.microsoft => microsoftCalendarEventFromJson( + event.providerCalendarId, + baseline, + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => throw StateError( + 'Provider event recovery is unavailable for ${provider.storageValue}.', + ), + }; + final restoredId = CalendarRepository.eventId( + accountId: event.accountId, + provider: provider, + providerCalendarId: restored.providerCalendarId, + providerEventId: restored.providerEventId, + providerOriginalStartKey: restored.providerOriginalStartKey, + ); + if (restoredId != event.id) { + throw StateError('The event recovery baseline identity changed.'); + } + await upsertEvent(accountId: event.accountId, event: restored); + } + } + Future restoreSourceAfterPatchDiscard(PendingOp op) async { if (_calendarOperationType(op) != 'calendar.patch') return; final sourceId = op.calendarSourceId; diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 72f66a29..5aa97cde 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -13,7 +13,7 @@ class PendingOpResolutionService { TaskRemoteClient? apiClient, required String accountId, required Future Function() syncTasks, - Future Function()? syncCalendar, + required Future Function() syncCalendar, DateTime Function()? nowUtc, }) : _database = database, _apiClient = apiClient, @@ -26,7 +26,7 @@ class PendingOpResolutionService { final TaskRemoteClient? _apiClient; final String _accountId; final Future Function() _syncTasks; - final Future Function()? _syncCalendar; + final Future Function() _syncCalendar; final DateTime Function() _nowUtc; Future retryNow(String opId) async { @@ -66,6 +66,16 @@ class PendingOpResolutionService { return false; } + if (op.entityType == 'event') { + final repository = CalendarRepository(database: _database, now: _nowUtc); + if (_operationType(op) == 'event.create') { + await repository.discardPendingEventCreation(op); + return false; + } + await repository.restoreEventAfterMutationDiscard(op); + return true; + } + if (op.entityType == 'task' && op.taskListId != null && op.taskId != null) { if (op.operation == 'move_task') { await _refreshOrRemoveMovedTask(op); @@ -84,11 +94,8 @@ class PendingOpResolutionService { Future _syncAfterResolution(PendingOp op) async { if (op.entityType == 'calendar' || op.entityType == 'event') { - final syncCalendar = _syncCalendar; - if (syncCalendar != null) { - await syncCalendar(); - return; - } + await _syncCalendar(); + return; } await _syncTasks(); } diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 0a3881e5..3c011d7e 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -470,6 +470,94 @@ void main() { expect(taskSyncCalls, 0); }); + test('discard blocked event creation removes its local projection', () async { + final repository = _calendarRepository(database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + summary: 'Calendar', + ), + ); + final sourceId = CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + ); + final operationId = await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: sourceId, + providerCalendarId: 'calendar@example.com', + start: DateTime.utc(2026, 6, 5, 9), + end: DateTime.utc(2026, 6, 5, 10), + ).copyWith(title: 'Unsent event'), + ); + await _blockOperation(database, operationId); + + await service.discard(operationId); + + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 0); + expect(taskSyncCalls, 0); + }); + + test('discard blocked event patch restores its provider baseline', () async { + final repository = _calendarRepository(database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + summary: 'Calendar', + ), + ); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + providerEventId: 'event-1', + title: 'Provider title', + startDateTime: '2026-06-05T09:00:00.000Z', + startTimeZone: 'UTC', + endDateTime: '2026-06-05T10:00:00.000Z', + endTimeZone: 'UTC', + organizerJson: {'self': true}, + updatedAtServer: '2026-06-04T00:00:00.000Z', + rawJson: { + 'id': 'event-1', + 'summary': 'Provider title', + 'start': {'dateTime': '2026-06-05T09:00:00.000Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-05T10:00:00.000Z', 'timeZone': 'UTC'}, + 'organizer': {'self': true}, + 'updated': '2026-06-04T00:00:00.000Z', + }, + ), + ); + final event = await database.select(database.calendarEvents).getSingle(); + final detail = await repository.loadEventDetail(event.id); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(title: 'Optimistic title'), + ); + final operation = await database.select(database.pendingOps).getSingle(); + await _blockOperation(database, operation.id); + + await service.discard(operation.id); + + final restored = await database.select(database.calendarEvents).getSingle(); + expect(restored.title, 'Provider title'); + expect(restored.syncStatus, 'synced'); + expect(restored.isDeleted, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 1); + expect(taskSyncCalls, 0); + }); + test( 'retry routes task synchronization through the shared callback', () async { From 26750da2202bd34f5e6ad27be0ad5c58adc62edd Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 17:08:21 -0700 Subject: [PATCH 59/69] Reconcile missing Microsoft calendars --- .../calendar/data/calendar_repository.dart | 99 +++++++++++++++ .../features/sync/calendar_sync_engine.dart | 31 +++-- lib/src/schedule/schedule_repository.dart | 6 +- .../schedule/schedule_search_test.dart | 42 +++++++ .../sync/calendar_sync_engine_test.dart | 117 ++++++++++++++++++ 5 files changed, 286 insertions(+), 9 deletions(-) diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 1d6bbd08..af7d62e8 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -764,6 +764,105 @@ class CalendarRepository { }); } + /// Reconciles a provider's complete calendar-list snapshot. + /// + /// A missing provider calendar is tombstoned locally together with its + /// synchronized event cache. Calendar creations that are still queued are + /// excluded because they cannot be expected to appear in the provider + /// snapshot yet. + Future reconcileProviderSourceSnapshot({ + required String accountId, + required BusyProvider provider, + required Set activeProviderCalendarIds, + }) async { + final now = _now().millisecondsSinceEpoch; + await _database.transaction(() async { + final pendingCreates = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(accountId) & + row.operationType.equals('calendar.create'), + )) + .get(); + final pendingCreateSourceIds = { + for (final operation in pendingCreates) + if (operation.calendarSourceId != null) operation.calendarSourceId!, + }; + final pendingCreateProviderIds = { + for (final operation in pendingCreates) + if (operation.providerCalendarId != null) + operation.providerCalendarId!, + }; + final sources = + await (_database.select(_database.calendarSources)..where( + (row) => + row.accountId.equals(accountId) & + row.provider.equals(provider.storageValue), + )) + .get(); + final missingSourceIds = { + for (final source in sources) + if (!activeProviderCalendarIds.contains(source.providerCalendarId) && + !pendingCreateSourceIds.contains(source.id) && + !pendingCreateProviderIds.contains(source.providerCalendarId)) + source.id, + }; + if (missingSourceIds.isEmpty) return; + + final missingEventIds = { + for (final event + in await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(accountId) & + row.calendarSourceId.isIn(missingSourceIds), + )) + .get()) + event.id, + }; + + await (_database.update(_database.calendarSources)..where( + (row) => + row.accountId.equals(accountId) & row.id.isIn(missingSourceIds), + )) + .write( + CalendarSourcesCompanion( + isDeleted: const Value(true), + hidden: const Value(true), + updatedAtLocal: Value(now), + ), + ); + await (_database.update(_database.calendarEvents)..where( + (row) => + row.accountId.equals(accountId) & + row.calendarSourceId.isIn(missingSourceIds) & + row.syncStatus.equals('synced') & + row.isDeleted.equals(false), + )) + .write( + CalendarEventsCompanion( + isDeleted: const Value(true), + updatedAtLocal: Value(now), + ), + ); + if (missingEventIds.isNotEmpty) { + await (_database.delete(_database.notificationSchedule)..where( + (row) => + row.accountId.equals(accountId) & + row.sourceType.equals('event') & + row.sourceId.isIn(missingEventIds), + )) + .go(); + } + await (_database.delete(_database.syncCursors)..where( + (row) => + row.accountId.equals(accountId) & + row.provider.equals(provider.storageValue) & + row.projectionSourceId.isIn(missingSourceIds), + )) + .go(); + }); + } + Future restoreSourceAfterRemovalFailure(PendingOp op) async { final operationType = op.operationType ?? '${op.entityType}.${op.operation}'; diff --git a/lib/src/features/sync/calendar_sync_engine.dart b/lib/src/features/sync/calendar_sync_engine.dart index 9cf3f3f1..6d234125 100644 --- a/lib/src/features/sync/calendar_sync_engine.dart +++ b/lib/src/features/sync/calendar_sync_engine.dart @@ -1,4 +1,5 @@ import '../../calendar_providers/cloud_calendar_client.dart'; +import '../../calendar_providers/calendar_sync_dto.dart'; import '../../db/app_database.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../calendar/data/calendar_repository.dart'; @@ -37,10 +38,7 @@ class CalendarSyncEngine { Future fullSync() async { await _replayPendingOps(); - final calendars = await _client.listCalendars(); - for (final calendar in calendars) { - await _repository.upsertSource(accountId: _accountId, source: calendar); - } + final calendars = await _refreshCalendarSources(); final window = _calendarSyncWindow(_nowUtc()); for (final calendar in calendars.where((source) => !source.isDeleted)) { @@ -61,10 +59,7 @@ class CalendarSyncEngine { Future incrementalSync() async { await _replayPendingOps(); - final calendars = await _client.listCalendars(); - for (final calendar in calendars) { - await _repository.upsertSource(accountId: _accountId, source: calendar); - } + final calendars = await _refreshCalendarSources(); final window = _calendarSyncWindow(_nowUtc()); final rangeStartValue = window.start.toIso8601String(); @@ -121,6 +116,26 @@ class CalendarSyncEngine { await _onNotificationScheduleChanged?.call(); } + Future> _refreshCalendarSources() async { + // listCalendars() returns only after every page has been retrieved, so an + // absent Microsoft calendar can be treated as a provider-side deletion. + final calendars = await _client.listCalendars(); + for (final calendar in calendars) { + await _repository.upsertSource(accountId: _accountId, source: calendar); + } + if (provider == BusyProvider.microsoft) { + await _repository.reconcileProviderSourceSnapshot( + accountId: _accountId, + provider: provider, + activeProviderCalendarIds: { + for (final calendar in calendars) + if (!calendar.isDeleted) calendar.providerCalendarId, + }, + ); + } + return calendars; + } + Future _syncCalendarRange({ required String providerCalendarId, required bool primaryCalendar, diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 7b3b6f40..495f5b05 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -390,7 +390,11 @@ class ScheduleRepository { ]) ..where(_database.calendarEvents.accountId.isIn(accountIds)) ..where(_database.calendarEvents.isDeleted.equals(false)) - ..where(_database.calendarEvents.isCancelled.equals(false)); + ..where(_database.calendarEvents.isCancelled.equals(false)) + ..where( + _database.calendarSources.isDeleted.isNull() | + _database.calendarSources.isDeleted.equals(false), + ); if (filters.sourceFilterActive) { query.where( _database.calendarEvents.calendarSourceId.isIn(filters.sourceIds), diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index dd5f292e..81237f9d 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -198,6 +198,48 @@ void main() { }, ); + test('events from a deleted calendar source are excluded', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: BusyProvider.microsoft); + final calendarRepository = CalendarRepository(database: database); + await calendarRepository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'calendar', + summary: 'Deleted calendar', + ), + ); + await calendarRepository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'calendar', + providerEventId: 'event', + title: 'Pending local edit', + startDateTime: '2026-06-11T09:00:00.000Z', + endDateTime: '2026-06-11T10:00:00.000Z', + ), + ); + await (database.update(database.calendarSources) + ..where((row) => row.providerCalendarId.equals('calendar'))) + .write(const CalendarSourcesCompanion(isDeleted: Value(true))); + await (database.update(database.calendarEvents) + ..where((row) => row.providerEventId.equals('event'))) + .write(const CalendarEventsCompanion(syncStatus: Value('pending'))); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 11)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeTasks: false, + ), + ); + + expect(items, isEmpty); + }); + test('Microsoft timed calendar event projects its display times', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); diff --git a/test/features/sync/calendar_sync_engine_test.dart b/test/features/sync/calendar_sync_engine_test.dart index d7ed8c9b..1f39ebcf 100644 --- a/test/features/sync/calendar_sync_engine_test.dart +++ b/test/features/sync/calendar_sync_engine_test.dart @@ -4,6 +4,7 @@ import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/calendar_providers/cloud_calendar_client.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.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/providers/busy_provider.dart'; import 'package:drift/drift.dart'; @@ -401,6 +402,118 @@ void main() { expect(event.isDeleted, isFalse); }); + test( + 'Microsoft full sync tombstones calendars absent from the collection', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-deleted', + summary: 'Deleted in Outlook', + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, source); + final eventId = await _insertEvent( + database, + provider: BusyProvider.microsoft, + providerCalendarId: source.providerCalendarId, + remindersJson: const { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 30, + }, + ); + final notificationService = NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 7, 10), + ); + await notificationService.rebuildUpcomingEventNotifications('account'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + + await CalendarSyncEngine( + database: database, + client: _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [], + pages: const [], + ), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ).fullSync(); + + final storedSource = + await (database.select( + database.calendarSources, + )..where((row) => row.id.equals('account|microsoft|cal-deleted'))) + .getSingle(); + final storedEvent = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(storedSource.isDeleted, isTrue); + expect(storedSource.hidden, isTrue); + expect(storedEvent.isDeleted, isTrue); + expect( + await database.select(database.notificationSchedule).get(), + isEmpty, + ); + }, + ); + + test( + 'Microsoft incremental reconciliation preserves a queued calendar create', + () async { + const missingSource = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-deleted', + summary: 'Deleted in Outlook', + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, missingSource); + final repository = CalendarRepository(database: database); + final localSourceId = await repository.createLocalSource( + accountId: 'account', + summary: 'Offline calendar', + ); + await (database.update( + database.pendingOps, + )..where((row) => row.calendarSourceId.equals(localSourceId))).write( + const PendingOpsCompanion( + nextAttemptAtUtc: Value('9999-12-31T00:00:00.000Z'), + ), + ); + + await CalendarSyncEngine( + database: database, + client: _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [], + pages: const [], + ), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ).incrementalSync(); + + final sources = await database.select(database.calendarSources).get(); + expect( + sources.singleWhere((source) => source.id == localSourceId).isDeleted, + isFalse, + ); + expect( + sources + .singleWhere((source) => source.providerCalendarId == 'cal-deleted') + .isDeleted, + isTrue, + ); + expect( + await (database.select( + database.pendingOps, + )..where((row) => row.calendarSourceId.equals(localSourceId))).get(), + hasLength(1), + ); + }, + ); + test( 'Microsoft primary sync persists and reuses the terminal delta link', () async { @@ -564,10 +677,12 @@ Future _insertEvent( AppDatabase database, { required BusyProvider provider, required String providerCalendarId, + Object? remindersJson, }) async { final event = _event( provider: provider, providerCalendarId: providerCalendarId, + remindersJson: remindersJson, ); await CalendarRepository( database: database, @@ -583,6 +698,7 @@ Future _insertEvent( CalendarEventDto _event({ required BusyProvider provider, required String providerCalendarId, + Object? remindersJson, }) { return CalendarEventDto( provider: provider, @@ -591,6 +707,7 @@ CalendarEventDto _event({ title: 'Planning', startDateTime: '2026-07-15T09:00:00.000Z', endDateTime: '2026-07-15T10:00:00.000Z', + remindersJson: remindersJson, updatedAtServer: '2026-07-01T00:00:00.000Z', rawJson: const { 'id': 'event-1', From 4d70a4b94d5c6d371007b8f182f8d4557b42d69b Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 17:14:16 -0700 Subject: [PATCH 60/69] Reconcile task lists on every sync --- lib/src/features/sync/sync_engine.dart | 58 +++++++++++++++++------- test/features/sync/sync_engine_test.dart | 54 +++++++++++++++++++++- 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/lib/src/features/sync/sync_engine.dart b/lib/src/features/sync/sync_engine.dart index 386c3404..8e7b6211 100644 --- a/lib/src/features/sync/sync_engine.dart +++ b/lib/src/features/sync/sync_engine.dart @@ -36,14 +36,18 @@ class SyncEngine { final DateTime Function() _nowUtc; Future fullSync() { - return _runSync(mode: 'full', updatedMin: null, markMissing: true); + return _runSync(mode: 'full', updatedMin: null, markMissingTasks: true); } Future incrementalSync() async { if (_fullRefreshOnly) { // Microsoft To Do sync uses full refresh in this version. Graph delta // endpoints exist in the low-level client but are not used by app sync. - await _runSync(mode: 'incremental', updatedMin: null, markMissing: true); + await _runSync( + mode: 'incremental', + updatedMin: null, + markMissingTasks: true, + ); return; } @@ -58,14 +62,14 @@ class SyncEngine { await _runSync( mode: 'incremental', updatedMin: updatedMin, - markMissing: false, + markMissingTasks: false, ); } Future _runSync({ required String mode, required DateTime? updatedMin, - required bool markMissing, + required bool markMissingTasks, }) async { final runId = _uuid.v4(); final startedAt = _now(); @@ -91,6 +95,7 @@ class SyncEngine { onConflictBlocked: _onConflictBlocked, ).replayDueOps(); final listIds = await _pullTaskLists(); + await _reconcileTaskListMembership(listIds); taskListsSeen = listIds.length; final tasksByList = >{}; for (final taskListId in listIds) { @@ -99,8 +104,8 @@ class SyncEngine { tasksSeen += taskIds.length; } - if (markMissing) { - await _markMissingRows(listIds, tasksByList); + if (markMissingTasks) { + await _markMissingTasks(tasksByList); } await NotificationScheduleService( database: _database, @@ -281,30 +286,49 @@ class SyncEngine { row.localCreated); } - Future _markMissingRows( - Set seenTaskListIds, - Map> seenTaskIdsByList, - ) async { + Future _reconcileTaskListMembership(Set seenTaskListIds) async { final lists = await _database.taskListsDao.listTaskLists(_accountId); - for (final list in lists) { - if (!seenTaskListIds.contains(list.id) && !list.localDirty) { + await _database.transaction(() async { + for (final list in lists) { + if (seenTaskListIds.contains(list.id)) { + if (list.serverMissing) { + await (_database.update(_database.taskLists)..where( + (row) => + row.accountId.equals(_accountId) & row.id.equals(list.id), + )) + .write(const TaskListsCompanion(serverMissing: Value(false))); + } + continue; + } + if (list.localDirty) continue; await (_database.update(_database.taskLists)..where( (row) => row.accountId.equals(_accountId) & row.id.equals(list.id), )) .write(const TaskListsCompanion(serverMissing: Value(true))); + await (_database.update(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(list.id) & + row.localDirty.equals(false), + )) + .write(const TasksCompanion(serverMissing: Value(true))); } - } + }); + } - for (final list in lists) { - final seenTaskIds = seenTaskIdsByList[list.id] ?? const {}; - final tasks = await _database.tasksDao.listTasks(_accountId, list.id); + Future _markMissingTasks( + Map> seenTaskIdsByList, + ) async { + for (final entry in seenTaskIdsByList.entries) { + final seenTaskIds = entry.value; + final tasks = await _database.tasksDao.listTasks(_accountId, entry.key); for (final task in tasks) { if (!seenTaskIds.contains(task.id) && !task.localDirty) { await (_database.update(_database.tasks)..where( (row) => row.accountId.equals(_accountId) & - row.taskListId.equals(list.id) & + row.taskListId.equals(entry.key) & row.id.equals(task.id), )) .write(const TasksCompanion(serverMissing: Value(true))); diff --git a/test/features/sync/sync_engine_test.dart b/test/features/sync/sync_engine_test.dart index 0232f4e8..40582f83 100644 --- a/test/features/sync/sync_engine_test.dart +++ b/test/features/sync/sync_engine_test.dart @@ -96,6 +96,57 @@ void main() { expect(apiClient.lastUpdatedMin, DateTime.utc(2026, 6, 4, 0, 8)); }); + test( + 'Google incremental sync reconciles lists but not omitted tasks', + () async { + await (database.update( + database.accounts, + )..where((row) => row.id.equals('account'))).write( + const AccountsCompanion( + lastSuccessfulSyncAtUtc: Value('2026-06-04T00:10:00.000Z'), + ), + ); + await database.taskListsDao.upsertTaskList(_localTaskList('list-1')); + await database.taskListsDao.upsertTaskList( + _localTaskList('deleted-list'), + ); + await database.tasksDao.upsertTask(_localTask('unchanged-task')); + await database.tasksDao.upsertTask( + _localTask('cached-task', taskListId: 'deleted-list'), + ); + apiClient.taskListsPages = [ + TaskListsPageDto(items: [_taskListDto('list-1')], rawJson: const {}), + ]; + apiClient.taskPages['list-1'] = [ + const TasksPageDto(items: [], rawJson: {}), + ]; + + await SyncEngine( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).incrementalSync(); + + final lists = await database.taskListsDao.listTaskLists('account'); + final activeTasks = await database.tasksDao.listTasks( + 'account', + 'list-1', + ); + final deletedListTasks = await database.tasksDao.listTasks( + 'account', + 'deleted-list', + ); + expect( + lists.singleWhere((list) => list.id == 'deleted-list').serverMissing, + isTrue, + ); + expect(activeTasks.single.serverMissing, isFalse); + expect(deletedListTasks.single.serverMissing, isTrue); + expect(apiClient.lastUpdatedMin, DateTime.utc(2026, 6, 4, 0, 8)); + }, + ); + test('full-refresh-only incremental sync does not send updatedMin', () async { await (database.update( database.accounts, @@ -648,10 +699,11 @@ TasksCompanion _localTask( String id, { String title = 'Local task', bool localDirty = false, + String taskListId = 'list-1', }) { return TasksCompanion.insert( accountId: 'account', - taskListId: 'list-1', + taskListId: taskListId, id: id, title: title, rawJson: '{}', From c9fd28024a1eb0dd1760522d1744a9e08a247bfc Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 17:19:00 -0700 Subject: [PATCH 61/69] Checkpoint task syncs at run start --- lib/src/db/daos/sync_runs_dao.dart | 13 ++++++++ lib/src/features/sync/sync_engine.dart | 18 +++++----- test/features/sync/sync_engine_test.dart | 42 +++++++++++++++++++++--- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/lib/src/db/daos/sync_runs_dao.dart b/lib/src/db/daos/sync_runs_dao.dart index 60543110..c9329f9f 100644 --- a/lib/src/db/daos/sync_runs_dao.dart +++ b/lib/src/db/daos/sync_runs_dao.dart @@ -44,4 +44,17 @@ class SyncRunsDao extends DatabaseAccessor ..limit(limit); return query.get(); } + + Future latestSuccessfulRun(String accountId) { + final query = select(syncRuns) + ..where( + (row) => + row.accountId.equals(accountId) & + row.status.equals('success') & + row.finishedAtUtc.isNotNull(), + ) + ..orderBy([(row) => OrderingTerm.desc(row.startedAtUtc)]) + ..limit(1); + return query.getSingleOrNull(); + } } diff --git a/lib/src/features/sync/sync_engine.dart b/lib/src/features/sync/sync_engine.dart index 8e7b6211..97fe0a3e 100644 --- a/lib/src/features/sync/sync_engine.dart +++ b/lib/src/features/sync/sync_engine.dart @@ -51,13 +51,15 @@ class SyncEngine { return; } - final account = await (_database.select( - _database.accounts, - )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); - final lastSync = account?.lastSuccessfulSyncAtUtc == null + final lastSuccessfulRun = await _database.syncRunsDao.latestSuccessfulRun( + _accountId, + ); + // A completion-time checkpoint can skip changes made to an early list + // while later lists are still being synchronized. + final checkpoint = lastSuccessfulRun == null ? null - : DateTime.parse(account!.lastSuccessfulSyncAtUtc!).toUtc(); - final updatedMin = lastSync?.subtract(const Duration(minutes: 2)); + : DateTime.parse(lastSuccessfulRun.startedAtUtc).toUtc(); + final updatedMin = checkpoint?.subtract(const Duration(minutes: 2)); await _runSync( mode: 'incremental', @@ -113,7 +115,7 @@ class SyncEngine { ).rebuildUpcomingTaskNotifications(_accountId); final finishedAt = _now(); - await _updateAccountSyncTimestamps(mode, finishedAt); + await _updateAccountSyncCompletion(mode, finishedAt); await _database.syncRunsDao.finishRun( id: runId, finishedAtUtc: DateTime.parse(finishedAt), @@ -337,7 +339,7 @@ class SyncEngine { } } - Future _updateAccountSyncTimestamps(String mode, String timestamp) { + Future _updateAccountSyncCompletion(String mode, String timestamp) { final update = _database.update(_database.accounts) ..where((row) => row.id.equals(_accountId)); return update.write( diff --git a/test/features/sync/sync_engine_test.dart b/test/features/sync/sync_engine_test.dart index 40582f83..8ad1f2fc 100644 --- a/test/features/sync/sync_engine_test.dart +++ b/test/features/sync/sync_engine_test.dart @@ -71,14 +71,20 @@ void main() { expect(tasks.single.serverMissing, isTrue); }); - test('incremental sync uses updatedMin with two minute overlap', () async { + test('incremental sync checkpoints from the successful run start', () async { await (database.update( database.accounts, )..where((row) => row.id.equals('account'))).write( const AccountsCompanion( - lastSuccessfulSyncAtUtc: Value('2026-06-04T00:10:00.000Z'), + lastSuccessfulSyncAtUtc: Value('2026-06-04T00:15:00.000Z'), ), ); + await _insertSuccessfulRun( + database, + id: 'previous-run', + startedAtUtc: '2026-06-04T00:10:00.000Z', + finishedAtUtc: '2026-06-04T00:15:00.000Z', + ); apiClient.taskListsPages = [ TaskListsPageDto(items: [_taskListDto('list-1')], rawJson: const {}), ]; @@ -90,10 +96,14 @@ void main() { database: database, apiClient: apiClient, accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4), + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 20), ).incrementalSync(); expect(apiClient.lastUpdatedMin, DateTime.utc(2026, 6, 4, 0, 8)); + final account = await (database.select( + database.accounts, + )..where((row) => row.id.equals('account'))).getSingle(); + expect(account.lastSuccessfulSyncAtUtc, '2026-06-04T00:20:00.000Z'); }); test( @@ -103,9 +113,15 @@ void main() { database.accounts, )..where((row) => row.id.equals('account'))).write( const AccountsCompanion( - lastSuccessfulSyncAtUtc: Value('2026-06-04T00:10:00.000Z'), + lastSuccessfulSyncAtUtc: Value('2026-06-04T00:15:00.000Z'), ), ); + await _insertSuccessfulRun( + database, + id: 'previous-run', + startedAtUtc: '2026-06-04T00:10:00.000Z', + finishedAtUtc: '2026-06-04T00:15:00.000Z', + ); await database.taskListsDao.upsertTaskList(_localTaskList('list-1')); await database.taskListsDao.upsertTaskList( _localTaskList('deleted-list'), @@ -655,6 +671,24 @@ class FakeTaskRemoteClient ) => throw UnimplementedError(); } +Future _insertSuccessfulRun( + AppDatabase database, { + required String id, + required String startedAtUtc, + required String finishedAtUtc, +}) { + return database.syncRunsDao.insertRun( + SyncRunsCompanion.insert( + id: id, + accountId: 'account', + mode: 'incremental', + startedAtUtc: startedAtUtc, + finishedAtUtc: Value(finishedAtUtc), + status: 'success', + ), + ); +} + Future _insertAccount(AppDatabase database) { return database .into(database.accounts) From 46a551ae57d3e1c26ee1306b0b71cf9335b401f2 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 17:26:50 -0700 Subject: [PATCH 62/69] Add sidebar provider indicators --- .../presentation/schedule_sidebar.dart | 121 +++++++++++--- ...edule_sidebar_collection_actions_test.dart | 147 +++++++++++++++++- 2 files changed, 246 insertions(+), 22 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index b53ba814..ba2f8430 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -547,6 +547,12 @@ class _AccountHeaderRow extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final secondaryLabel = account.secondaryLabel; + final providerName = _accountProviderName(context, account.provider); + final accountSemanticsLabel = [ + providerName, + account.displayLabel, + if (secondaryLabel != null && secondaryLabel.isNotEmpty) secondaryLabel, + ].join(', '); return Padding( padding: const EdgeInsetsDirectional.only( start: BusyMaxSpacing.md, @@ -557,25 +563,46 @@ class _AccountHeaderRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - account.displayLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge, - ), - if (secondaryLabel != null && secondaryLabel.isNotEmpty) - Text( - secondaryLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + child: Semantics( + key: ValueKey(('account-identity', account.id)), + container: true, + label: accountSemanticsLabel, + child: ExcludeSemantics( + child: Row( + children: [ + _AccountProviderIndicator( + key: ValueKey(('account-provider-indicator', account.id)), + provider: account.provider, + providerName: providerName, ), - ), - ], + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + account.displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge, + ), + if (secondaryLabel != null && + secondaryLabel.isNotEmpty) + Text( + secondaryLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), ), ), const SizedBox(width: BusyMaxSpacing.xs), @@ -645,6 +672,64 @@ class _AccountHeaderRow extends StatelessWidget { } } +class _AccountProviderIndicator extends StatelessWidget { + const _AccountProviderIndicator({ + super.key, + required this.provider, + required this.providerName, + }); + + static const double _diameter = 24; + + final BusyProvider provider; + final String providerName; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Tooltip( + message: providerName, + excludeFromSemantics: true, + child: SizedBox.square( + dimension: _diameter, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.surfaceContainerHighest, + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Center( + child: Text( + _accountProviderLetter(provider), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} + +String _accountProviderLetter(BusyProvider provider) => switch (provider) { + BusyProvider.google => 'G', + BusyProvider.microsoft => 'M', + BusyProvider.nextcloud => 'N', + BusyProvider.appleICloud => 'A', + BusyProvider.webCal => 'W', +}; + +String _accountProviderName(BuildContext context, BusyProvider provider) => + switch (provider) { + BusyProvider.google => context.l10n.googleProvider, + BusyProvider.microsoft => context.l10n.microsoftProvider, + BusyProvider.nextcloud => context.l10n.nextcloudProvider, + BusyProvider.appleICloud => context.l10n.appleICloudProvider, + BusyProvider.webCal => 'WebCal', + }; + class _AccountCalendarSources extends ConsumerWidget { const _AccountCalendarSources({required this.account}); diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart index e7870b38..7b30f94b 100644 --- a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -1,5 +1,6 @@ import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_collection_creation_service.dart'; @@ -54,6 +55,136 @@ void main() { } }); + testWidgets( + 'same-email accounts use provider-derived indicators and semantics', + (tester) async { + final semantics = tester.ensureSemantics(); + const email = 'shared@example.test'; + await _pumpSidebar(tester, [ + _account( + BusyProvider.google, + id: 'google-shared', + displayName: 'Shared account', + email: email, + ), + _account( + BusyProvider.microsoft, + id: 'microsoft-shared', + displayName: 'Shared account', + email: email, + ), + _account( + BusyProvider.nextcloud, + id: 'nextcloud-shared', + displayName: 'Shared account', + email: email, + ), + ]); + + for (final expected in const [ + ('google-shared', 'G', 'Google'), + ('microsoft-shared', 'M', 'Microsoft'), + ('nextcloud-shared', 'N', 'Nextcloud'), + ]) { + final indicator = find.byKey( + ValueKey(('account-provider-indicator', expected.$1)), + ); + expect(indicator, findsOneWidget); + expect( + find.descendant(of: indicator, matching: find.text(expected.$2)), + findsOneWidget, + ); + expect( + tester + .widget( + find.descendant(of: indicator, matching: find.byType(Tooltip)), + ) + .message, + expected.$3, + ); + expect( + find.bySemanticsLabel('${expected.$3}, Shared account, $email'), + findsOneWidget, + ); + } + semantics.dispose(); + }, + ); + + testWidgets( + 'provider indicator stays visible with truncated text in both themes', + (tester) async { + for (final brightness in [Brightness.light, Brightness.dark]) { + final theme = BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: const Color(0xFFE95464), + ); + const accountId = 'google-long'; + const displayName = + 'A very long account display name that must be truncated'; + const email = 'a-very-long-address-for-sidebar@example.test'; + await _pumpSidebar( + tester, + [ + _account( + BusyProvider.google, + id: accountId, + displayName: displayName, + email: email, + ), + ], + theme: theme, + width: 240, + ); + + final indicator = find.byKey( + const ValueKey(('account-provider-indicator', accountId)), + ); + final indicatorRect = tester.getRect(indicator); + expect(indicatorRect.size, const Size.square(24)); + expect(indicatorRect.left, greaterThanOrEqualTo(0)); + expect(indicatorRect.right, lessThanOrEqualTo(240)); + final decoration = + tester + .widget( + find.descendant( + of: indicator, + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + final indicatorContext = tester.element(indicator); + final colorScheme = Theme.of(indicatorContext).colorScheme; + expect(decoration.shape, BoxShape.circle); + expect(decoration.color, colorScheme.surfaceContainerHighest); + final letter = tester.widget( + find.descendant(of: indicator, matching: find.text('G')), + ); + expect(letter.style?.color, colorScheme.onSurface); + + final name = tester.widget(find.text(displayName)); + final address = tester.widget(find.text(email)); + expect(name.maxLines, 1); + expect(name.overflow, TextOverflow.ellipsis); + expect(address.maxLines, 1); + expect(address.overflow, TextOverflow.ellipsis); + expect( + tester.getTopLeft(find.text(displayName)).dy, + lessThan(tester.getTopLeft(find.text(email)).dy), + ); + + await tester.tap( + find.byKey(const ValueKey(('account-collapse', accountId))), + ); + await tester.pumpAndSettle(); + expect(indicator, findsOneWidget); + expect(tester.getRect(indicator).size, const Size.square(24)); + expect(tester.takeException(), isNull); + } + }, + ); + testWidgets('Apple has no empty collection Options menu', (tester) async { await _pumpSidebar(tester, [_account(BusyProvider.appleICloud)]); @@ -343,6 +474,8 @@ Future _pumpSidebar( NetworkAvailability? networkAvailability, List calendarSources = const [], List taskLists = const [], + ThemeData? theme, + double width = 320, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(900, 1200); @@ -369,8 +502,9 @@ Future _pumpSidebar( ], child: localizedTestApp( locale: locale, + theme: theme, child: SizedBox( - width: 320, + width: width, height: 800, child: ScheduleSidebar( selectedDate: DateTime(2026, 8, 29), @@ -438,14 +572,19 @@ AccountEntity _account( String? id, bool calendarsEnabled = true, bool tasksEnabled = true, + String? displayName, + String? email, }) => AccountEntity( id: id ?? '${provider.storageValue}-account', provider: provider, authority: 'https://example.test', providerAccountId: 'identity', - displayName: provider == BusyProvider.webCal - ? 'WebCal account' - : provider.displayName, + displayName: + displayName ?? + (provider == BusyProvider.webCal + ? 'WebCal account' + : provider.displayName), + email: email, authState: accountAuthStateSignedIn, calendarsEnabled: calendarsEnabled, tasksEnabled: tasksEnabled, From ad3a73c53cd12fbbe874c24892f42735f145d27a Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 17:44:43 -0700 Subject: [PATCH 63/69] Compact sidebar provider indicators --- .../presentation/schedule_sidebar.dart | 68 +++++++++++-------- ...edule_sidebar_collection_actions_test.dart | 14 ++-- 2 files changed, 47 insertions(+), 35 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index ba2f8430..6a8234de 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -547,11 +547,13 @@ class _AccountHeaderRow extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final secondaryLabel = account.secondaryLabel; + final hasSecondaryLabel = + secondaryLabel != null && secondaryLabel.isNotEmpty; final providerName = _accountProviderName(context, account.provider); final accountSemanticsLabel = [ providerName, account.displayLabel, - if (secondaryLabel != null && secondaryLabel.isNotEmpty) secondaryLabel, + if (hasSecondaryLabel) secondaryLabel, ].join(', '); return Padding( padding: const EdgeInsetsDirectional.only( @@ -568,37 +570,45 @@ class _AccountHeaderRow extends StatelessWidget { container: true, label: accountSemanticsLabel, child: ExcludeSemantics( - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AccountProviderIndicator( - key: ValueKey(('account-provider-indicator', account.id)), - provider: account.provider, - providerName: providerName, - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - account.displayLabel, + if (hasSecondaryLabel) + Text( + account.displayLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge, + ), + Row( + children: [ + _AccountProviderIndicator( + key: ValueKey(( + 'account-provider-indicator', + account.id, + )), + provider: account.provider, + providerName: providerName, + ), + const SizedBox(width: BusyMaxSpacing.xs), + Expanded( + child: Text( + hasSecondaryLabel + ? secondaryLabel + : account.displayLabel, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge, - ), - if (secondaryLabel != null && - secondaryLabel.isNotEmpty) - Text( - secondaryLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( + style: hasSecondaryLabel + ? Theme.of( + context, + ).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ) + : Theme.of(context).textTheme.labelLarge, + ), + ), + ], ), ], ), @@ -679,7 +689,7 @@ class _AccountProviderIndicator extends StatelessWidget { required this.providerName, }); - static const double _diameter = 24; + static const double _diameter = 16; final BusyProvider provider; final String providerName; diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart index 7b30f94b..0f2c54e4 100644 --- a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -141,7 +141,7 @@ void main() { const ValueKey(('account-provider-indicator', accountId)), ); final indicatorRect = tester.getRect(indicator); - expect(indicatorRect.size, const Size.square(24)); + expect(indicatorRect.size, const Size.square(16)); expect(indicatorRect.left, greaterThanOrEqualTo(0)); expect(indicatorRect.right, lessThanOrEqualTo(240)); final decoration = @@ -169,17 +169,19 @@ void main() { expect(name.overflow, TextOverflow.ellipsis); expect(address.maxLines, 1); expect(address.overflow, TextOverflow.ellipsis); - expect( - tester.getTopLeft(find.text(displayName)).dy, - lessThan(tester.getTopLeft(find.text(email)).dy), - ); + final nameRect = tester.getRect(find.text(displayName)); + final addressRect = tester.getRect(find.text(email)); + expect(nameRect.top, lessThan(addressRect.top)); + expect(nameRect.left, closeTo(indicatorRect.left, 0.01)); + expect(addressRect.left, greaterThan(indicatorRect.right)); + expect(addressRect.center.dy, closeTo(indicatorRect.center.dy, 0.01)); await tester.tap( find.byKey(const ValueKey(('account-collapse', accountId))), ); await tester.pumpAndSettle(); expect(indicator, findsOneWidget); - expect(tester.getRect(indicator).size, const Size.square(24)); + expect(tester.getRect(indicator).size, const Size.square(16)); expect(tester.takeException(), isNull); } }, From e8f088570cf8b1934736d93554b9688ebd4a2e88 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 17:50:33 -0700 Subject: [PATCH 64/69] Move provider indicators to trailing edge --- .../presentation/schedule_sidebar.dart | 18 ++++++------ ...edule_sidebar_collection_actions_test.dart | 29 ++++++++++--------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index 6a8234de..ba1ac842 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -583,15 +583,6 @@ class _AccountHeaderRow extends StatelessWidget { ), Row( children: [ - _AccountProviderIndicator( - key: ValueKey(( - 'account-provider-indicator', - account.id, - )), - provider: account.provider, - providerName: providerName, - ), - const SizedBox(width: BusyMaxSpacing.xs), Expanded( child: Text( hasSecondaryLabel @@ -608,6 +599,15 @@ class _AccountHeaderRow extends StatelessWidget { : Theme.of(context).textTheme.labelLarge, ), ), + const SizedBox(width: BusyMaxSpacing.xs), + _AccountProviderIndicator( + key: ValueKey(( + 'account-provider-indicator', + account.id, + )), + provider: account.provider, + providerName: providerName, + ), ], ), ], diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart index 0f2c54e4..c5cb1945 100644 --- a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -172,8 +172,8 @@ void main() { final nameRect = tester.getRect(find.text(displayName)); final addressRect = tester.getRect(find.text(email)); expect(nameRect.top, lessThan(addressRect.top)); - expect(nameRect.left, closeTo(indicatorRect.left, 0.01)); - expect(addressRect.left, greaterThan(indicatorRect.right)); + expect(nameRect.left, closeTo(addressRect.left, 0.01)); + expect(addressRect.right, lessThan(indicatorRect.left)); expect(addressRect.center.dy, closeTo(indicatorRect.center.dy, 0.01)); await tester.tap( @@ -505,17 +505,20 @@ Future _pumpSidebar( child: localizedTestApp( locale: locale, theme: theme, - child: SizedBox( - width: width, - height: 800, - child: ScheduleSidebar( - selectedDate: DateTime(2026, 8, 29), - firstWeekday: DateTime.monday, - items: const [], - onDateSelected: (_) {}, - onMonthSelected: (_) {}, - onYearSelected: (_) {}, - onWeekSelected: (_) {}, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: 800, + child: ScheduleSidebar( + selectedDate: DateTime(2026, 8, 29), + firstWeekday: DateTime.monday, + items: const [], + onDateSelected: (_) {}, + onMonthSelected: (_) {}, + onYearSelected: (_) {}, + onWeekSelected: (_) {}, + ), ), ), ), From 4bc4cc264a22df0ce3d77bda3293bc19212f55bb Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 17:55:03 -0700 Subject: [PATCH 65/69] Show Nextcloud hosts in sidebar --- .../presentation/schedule_sidebar.dart | 24 +++++++++++- ...edule_sidebar_collection_actions_test.dart | 38 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index ba1ac842..67bc52a4 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -546,7 +546,7 @@ class _AccountHeaderRow extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final secondaryLabel = account.secondaryLabel; + final secondaryLabel = _accountHeaderSecondaryLabel(account); final hasSecondaryLabel = secondaryLabel != null && secondaryLabel.isNotEmpty; final providerName = _accountProviderName(context, account.provider); @@ -682,6 +682,28 @@ class _AccountHeaderRow extends StatelessWidget { } } +String? _accountHeaderSecondaryLabel(AccountEntity account) { + final secondaryLabel = account.secondaryLabel; + if (secondaryLabel != null && secondaryLabel.isNotEmpty) { + return secondaryLabel; + } + if (account.provider != BusyProvider.nextcloud) return null; + + final authority = Uri.tryParse(account.authority); + if (authority == null || authority.host.isEmpty) return null; + + final scheme = authority.scheme.toLowerCase(); + final hasDefaultPort = + (scheme == 'https' && authority.port == 443) || + (scheme == 'http' && authority.port == 80); + if (!authority.hasPort || hasDefaultPort) return authority.host; + + final host = authority.host.contains(':') + ? '[${authority.host}]' + : authority.host; + return '$host:${authority.port}'; +} + class _AccountProviderIndicator extends StatelessWidget { const _AccountProviderIndicator({ super.key, diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart index c5cb1945..354c19b4 100644 --- a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -111,6 +111,41 @@ void main() { }, ); + testWidgets('Nextcloud uses its server host when email is unavailable', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await _pumpSidebar(tester, [ + _account( + BusyProvider.nextcloud, + id: 'nextcloud-custom-port', + authority: + 'https://cloud.example.test:8443/nextcloud/index.php?token=secret', + displayName: 'albert', + ), + _account( + BusyProvider.nextcloud, + id: 'nextcloud-default-port', + authority: 'https://work.example.test:443/nextcloud', + displayName: 'work-user', + ), + ]); + + expect(find.text('cloud.example.test:8443'), findsOneWidget); + expect(find.text('work.example.test'), findsOneWidget); + expect(find.textContaining('/nextcloud'), findsNothing); + expect(find.textContaining('token=secret'), findsNothing); + expect( + find.bySemanticsLabel('Nextcloud, albert, cloud.example.test:8443'), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Nextcloud, work-user, work.example.test'), + findsOneWidget, + ); + semantics.dispose(); + }); + testWidgets( 'provider indicator stays visible with truncated text in both themes', (tester) async { @@ -579,10 +614,11 @@ AccountEntity _account( bool tasksEnabled = true, String? displayName, String? email, + String authority = 'https://example.test', }) => AccountEntity( id: id ?? '${provider.storageValue}-account', provider: provider, - authority: 'https://example.test', + authority: authority, providerAccountId: 'identity', displayName: displayName ?? From 25c773e693fd3893f218f8dc9d604313fe4134d8 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 18:06:18 -0700 Subject: [PATCH 66/69] Restore rebased events from provider snapshots --- lib/src/app/app_bootstrap.dart | 28 +++ .../calendar_providers/calendar_mutation.dart | 1 + .../calendar/data/calendar_repository.dart | 185 +++++++++++++----- .../sync/calendar_pending_ops_replayer.dart | 6 +- .../sync/pending_op_resolution_service.dart | 12 +- .../calendar_pending_ops_replayer_test.dart | 175 ++++++++++++++++- 6 files changed, 350 insertions(+), 57 deletions(-) diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 84568ee9..207f84de 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:uuid/uuid.dart'; import '../config/build_config.dart'; +import '../calendar_providers/cloud_calendar_client.dart'; import '../db/app_database.dart'; import '../dav/auth/dav_account_onboarding_service.dart'; import '../dav/auth/nextcloud_app_password_revoker.dart'; @@ -487,6 +488,30 @@ final taskRemoteApiClientForAccountProvider = }; }); +final calendarRemoteApiClientForAccountProvider = + Provider.family((ref, accountId) { + final accounts = ref.watch(accountsStreamProvider).valueOrNull; + AccountEntity? account; + for (final candidate in accounts ?? const []) { + if (candidate.id == accountId) { + account = candidate; + break; + } + } + if (account == null) return null; + return switch (account.provider) { + BusyProvider.google => ref.watch( + googleCalendarApiClientForAccountProvider(accountId), + ), + BusyProvider.microsoft => ref.watch( + microsoftCalendarApiClientForAccountProvider(accountId), + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => null, + }; + }); + typedef SyncEngineForAccountFactory = SyncEngine Function(String accountId, BusyProvider provider); @@ -935,6 +960,9 @@ final pendingOpResolutionServiceProvider = return PendingOpResolutionService( database: ref.watch(databaseProvider), apiClient: ref.watch(googleTasksApiClientProvider), + calendarClient: ref.watch( + calendarRemoteApiClientForAccountProvider(accountId), + ), accountId: accountId, syncTasks: () => ref .read(accountSyncOperationsProvider) diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 8a7b851d..c8d0d902 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -35,6 +35,7 @@ const calendarEventDestinationSourceIdKey = '_destinationSourceId'; const calendarEventCopyConfirmationRequiredKey = '_copyConfirmationRequired'; const calendarEventCopyConfirmedKey = '_copyConfirmed'; const calendarEventCopyDestinationEventIdKey = '_copyDestinationEventId'; +const calendarEventSemanticBaselineKey = '__busymaxSemanticBaseline'; enum CalendarGuestUpdatePolicy { send, doNotSend } diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index af7d62e8..25bf37fd 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -26,6 +26,12 @@ import '../domain/event_move_policy.dart'; import '../presentation/event_editor_draft.dart'; import 'calendar_event_detail.dart'; +typedef CalendarEventRecoveryFetcher = + Future Function({ + required String calendarId, + required String eventId, + }); + class CalendarSourceEntity { const CalendarSourceEntity({ required this.id, @@ -979,14 +985,43 @@ class CalendarRepository { await _onNotificationScheduleChanged?.call(); } - Future restoreEventAfterMutationDiscard(PendingOp op) async { + Future restoreEventAfterMutationDiscard( + PendingOp op, { + CalendarEventRecoveryFetcher? fetchProviderEvent, + }) async { if (op.entityType != 'event' || _calendarOperationType(op) == 'event.create') { return; } + final eventId = op.eventId; + if (eventId == null) { + throw StateError('The event recovery identity is unavailable.'); + } + final target = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingleOrNull(); + if (target == null) return; + final provider = BusyProviderCodec.requireStorageValue(target.provider); + CalendarEventDto? providerSnapshot; + final operationBaseline = _jsonMap(op.baselineRawJson); + final recoveryBaseline = operationBaseline.isNotEmpty + ? operationBaseline + : _jsonMap(target.baselineRawJson); + if (!_isCompleteProviderEventBaseline(provider, recoveryBaseline)) { + if (fetchProviderEvent == null) { + throw StateError('The provider event recovery client is unavailable.'); + } + providerSnapshot = await fetchProviderEvent( + calendarId: target.providerCalendarId, + eventId: target.providerEventId, + ); + } await _database.transaction( - () => - _restoreEventProjectionAfterDiscard(op, ignoredOperationIds: {op.id}), + () => _restoreEventProjectionAfterDiscard( + op, + ignoredOperationIds: {op.id}, + providerSnapshot: providerSnapshot, + ), ); await _notificationScheduleService().rebuildUpcomingEventNotifications( op.accountId, @@ -997,6 +1032,7 @@ class CalendarRepository { Future _restoreEventProjectionAfterDiscard( PendingOp op, { required Set ignoredOperationIds, + CalendarEventDto? providerSnapshot, }) async { final eventId = op.eventId; if (eventId == null) return; @@ -1034,57 +1070,19 @@ class CalendarRepository { if (remainingOperations.isNotEmpty) return; for (final event in affectedEvents) { + final provider = BusyProviderCodec.requireStorageValue(event.provider); final operationBaseline = event.id == eventId ? _jsonMap(op.baselineRawJson) : const {}; - final baseline = operationBaseline.isNotEmpty - ? operationBaseline - : _jsonMap(event.baselineRawJson); - if (baseline.isEmpty) { - throw StateError('The event recovery baseline is unavailable.'); - } - final provider = BusyProviderCodec.requireStorageValue(event.provider); - baseline.putIfAbsent('id', () => event.providerEventId); - switch (provider) { - case BusyProvider.google: - if (event.providerRecurringEventId case final recurringId?) { - baseline.putIfAbsent('recurringEventId', () => recurringId); - } - if (event.providerOriginalStartKey case final originalStart?) { - baseline.putIfAbsent( - 'originalStartTime', - () => originalStart.length == 10 - ? {'date': originalStart} - : {'dateTime': originalStart}, + final restored = event.id == eventId && providerSnapshot != null + ? providerSnapshot + : _eventFromRecoveryBaseline( + event, + provider, + operationBaseline.isNotEmpty + ? operationBaseline + : _jsonMap(event.baselineRawJson), ); - } - case BusyProvider.microsoft: - if (event.providerRecurringEventId case final recurringId?) { - baseline.putIfAbsent('seriesMasterId', () => recurringId); - } - if (event.providerOriginalStartKey case final originalStart?) { - baseline.putIfAbsent('originalStart', () => originalStart); - } - case BusyProvider.appleICloud || - BusyProvider.nextcloud || - BusyProvider.webCal: - break; - } - final restored = switch (provider) { - BusyProvider.google => googleCalendarEventFromJson( - event.providerCalendarId, - baseline, - ), - BusyProvider.microsoft => microsoftCalendarEventFromJson( - event.providerCalendarId, - baseline, - ), - BusyProvider.appleICloud || - BusyProvider.nextcloud || - BusyProvider.webCal => throw StateError( - 'Provider event recovery is unavailable for ${provider.storageValue}.', - ), - }; final restoredId = CalendarRepository.eventId( accountId: event.accountId, provider: provider, @@ -1099,6 +1097,93 @@ class CalendarRepository { } } + CalendarEventDto _eventFromRecoveryBaseline( + CalendarEvent event, + BusyProvider provider, + Map baseline, + ) { + if (!_isCompleteProviderEventBaseline(provider, baseline)) { + throw StateError('A complete event recovery baseline is unavailable.'); + } + baseline.putIfAbsent('id', () => event.providerEventId); + switch (provider) { + case BusyProvider.google: + if (event.providerRecurringEventId case final recurringId?) { + baseline.putIfAbsent('recurringEventId', () => recurringId); + } + if (event.providerOriginalStartKey case final originalStart?) { + baseline.putIfAbsent( + 'originalStartTime', + () => originalStart.length == 10 + ? {'date': originalStart} + : {'dateTime': originalStart}, + ); + } + case BusyProvider.microsoft: + if (event.providerRecurringEventId case final recurringId?) { + baseline.putIfAbsent('seriesMasterId', () => recurringId); + } + if (event.providerOriginalStartKey case final originalStart?) { + baseline.putIfAbsent('originalStart', () => originalStart); + } + case BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal: + break; + } + return switch (provider) { + BusyProvider.google => googleCalendarEventFromJson( + event.providerCalendarId, + baseline, + ), + BusyProvider.microsoft => microsoftCalendarEventFromJson( + event.providerCalendarId, + baseline, + ), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => throw StateError( + 'Provider event recovery is unavailable for ${provider.storageValue}.', + ), + }; + } + + bool _isCompleteProviderEventBaseline( + BusyProvider provider, + Map baseline, + ) { + if (baseline.isEmpty || + baseline.containsKey(calendarEventSemanticBaselineKey)) { + return false; + } + final start = _jsonObjectMap(baseline['start']); + final end = _jsonObjectMap(baseline['end']); + return switch (provider) { + BusyProvider.google => + _hasNonEmptyValue(start, 'date', 'dateTime') && + _hasNonEmptyValue(end, 'date', 'dateTime'), + BusyProvider.microsoft => + _hasNonEmptyValue(start, 'dateTime') && + _hasNonEmptyValue(end, 'dateTime'), + BusyProvider.appleICloud || + BusyProvider.nextcloud || + BusyProvider.webCal => false, + }; + } + + bool _hasNonEmptyValue( + Map values, + String key, [ + String? alternativeKey, + ]) { + final value = values[key]?.toString().trim(); + if (value != null && value.isNotEmpty) return true; + final alternative = alternativeKey == null + ? null + : values[alternativeKey]?.toString().trim(); + return alternative != null && alternative.isNotEmpty; + } + Future restoreSourceAfterPatchDiscard(PendingOp op) async { if (_calendarOperationType(op) != 'calendar.patch') return; final sourceId = op.calendarSourceId; diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index faebabec..306e6348 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -495,7 +495,7 @@ class CalendarPendingOpsReplayer { )..where((row) => row.id.equals(dependent.id))).write( PendingOpsCompanion( baselineRawJson: Value( - jsonEncode({_eventSemanticBaselineKey: baseline}), + jsonEncode({calendarEventSemanticBaselineKey: baseline}), ), updatedAtUtc: Value(_nowUtc().toIso8601String()), ), @@ -1259,7 +1259,7 @@ class CalendarPendingOpsReplayer { String encodedBaseline, ) { final raw = _jsonObject(encodedBaseline); - final semantic = raw[_eventSemanticBaselineKey]; + final semantic = raw[calendarEventSemanticBaselineKey]; if (semantic is Map) { return semantic.cast(); } @@ -1887,8 +1887,6 @@ String? _localStart(CalendarEvent event) => String? _localEnd(CalendarEvent event) => event.allDay ? event.endDate : event.endDateTime; -const _eventSemanticBaselineKey = '__busymaxSemanticBaseline'; - Map _mapValue(Object? value) { if (value is Map) { return value; diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 5aa97cde..baa86b0e 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import '../../calendar_providers/cloud_calendar_client.dart'; import '../../db/app_database.dart'; import '../calendar/data/calendar_repository.dart'; import '../task_lists/data/task_lists_repository.dart'; @@ -11,12 +12,14 @@ class PendingOpResolutionService { PendingOpResolutionService({ required AppDatabase database, TaskRemoteClient? apiClient, + CloudCalendarClient? calendarClient, required String accountId, required Future Function() syncTasks, required Future Function() syncCalendar, DateTime Function()? nowUtc, }) : _database = database, _apiClient = apiClient, + _calendarClient = calendarClient, _accountId = accountId, _syncTasks = syncTasks, _syncCalendar = syncCalendar, @@ -24,6 +27,7 @@ class PendingOpResolutionService { final AppDatabase _database; final TaskRemoteClient? _apiClient; + final CloudCalendarClient? _calendarClient; final String _accountId; final Future Function() _syncTasks; final Future Function() _syncCalendar; @@ -72,7 +76,13 @@ class PendingOpResolutionService { await repository.discardPendingEventCreation(op); return false; } - await repository.restoreEventAfterMutationDiscard(op); + await repository.restoreEventAfterMutationDiscard( + op, + fetchProviderEvent: _calendarClient == null + ? null + : ({required calendarId, required eventId}) => _calendarClient + .getEvent(calendarId: calendarId, eventId: eventId), + ); return true; } diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 10c441ae..4287d6c0 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -12,6 +12,7 @@ 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/google_calendar/google_calendar_errors.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -1391,6 +1392,149 @@ void main() { }, ); + test( + 'discarding a rebased blocked edit restores the complete remote event', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8), + ); + final baseEvent = client._event( + 'provider-event', + title: 'Base title', + description: 'Planning description', + location: 'Base room', + remindersJson: const { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + ], + }, + organizerJson: const {'self': true}, + ); + await repository.upsertEvent(accountId: 'account', event: baseEvent); + final eventId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'provider-event', + ); + + var detail = await repository.loadEventDetail(eventId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(title: 'First local title'), + ); + detail = await repository.loadEventDetail(eventId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail!, + ).copyWith(location: 'Second local room'), + ); + + client + ..persistEventUpdates = true + ..remoteEvent = client._event( + 'provider-event', + title: 'Base title', + description: 'Planning description', + location: 'Remote room', + remindersJson: baseEvent.remindersJson, + organizerJson: baseEvent.organizerJson, + updatedAtServer: '2026-06-08T00:05:00.000Z', + ); + + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8, 1), + ).replayDueOps(); + + expect(applied, 1); + final blocked = await database.select(database.pendingOps).getSingle(); + expect(blocked.lastErrorCode, 'conflict'); + expect( + (jsonDecode(blocked.baselineRawJson!) as Map).containsKey( + calendarEventSemanticBaselineKey, + ), + isTrue, + ); + client.getEventError = StateError('Provider temporarily unavailable'); + final failingResolutionService = PendingOpResolutionService( + database: database, + calendarClient: client, + accountId: 'account', + syncTasks: () async {}, + syncCalendar: () async {}, + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + await expectLater( + failingResolutionService.discard(blocked.id), + throwsA(isA()), + ); + expect( + await database.pendingOpsDao.getOp(blocked.id), + isNot(equals(null)), + ); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).location, + 'Second local room', + ); + client.getEventError = null; + final getCallsBeforeDiscard = client.calls + .where((call) => call == 'getEvent:cal-1:provider-event') + .length; + var calendarSyncCalls = 0; + final resolutionService = PendingOpResolutionService( + database: database, + calendarClient: client, + accountId: 'account', + syncTasks: () async {}, + syncCalendar: () async => calendarSyncCalls += 1, + nowUtc: () => DateTime.utc(2026, 6, 8), + ); + + await resolutionService.discard(blocked.id); + + expect( + client.calls + .where((call) => call == 'getEvent:cal-1:provider-event') + .length, + getCallsBeforeDiscard + 1, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(calendarSyncCalls, 1); + final restored = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle(); + expect(restored.title, 'First local title'); + expect(restored.description, 'Planning description'); + expect(restored.location, 'Remote room'); + expect(restored.startDateTime, '2026-06-08T09:00:00.000Z'); + expect(restored.endDateTime, '2026-06-08T10:00:00.000Z'); + expect(restored.startTimeZone, 'UTC'); + expect(restored.endTimeZone, 'UTC'); + expect(restored.remindersJson, jsonEncode(baseEvent.remindersJson)); + expect(restored.organizerJson, jsonEncode(baseEvent.organizerJson)); + expect(restored.syncStatus, 'synced'); + expect(restored.isDeleted, isFalse); + + final notification = await database + .select(database.notificationSchedule) + .getSingle(); + expect(notification.sourceId, eventId); + expect(notification.title, 'First local title'); + expect( + notification.scheduledAtUtc, + DateTime.utc(2026, 6, 8, 8, 30).millisecondsSinceEpoch, + ); + }, + ); + test('event delete pending op calls provider deleteEvent', () async { final eventId = await _insertEvent( database, @@ -2345,6 +2489,7 @@ class _FakeCalendarClient Completer? createEventGate; Completer? calendarPatchGate; Object? createEventResponseError; + Object? getEventError; int get distinctCreatedEventCount => _createdEventsByIdentity.length; @@ -2438,9 +2583,16 @@ class _FakeCalendarClient title: persistEventUpdates ? mutation.title ?? current?.title ?? '' : mutation.title ?? '', + description: persistEventUpdates + ? mutation.description ?? current?.description + : mutation.description, location: persistEventUpdates ? mutation.location ?? current?.location : mutation.location, + remindersJson: persistEventUpdates + ? mutation.reminders ?? current?.remindersJson + : mutation.reminders, + organizerJson: persistEventUpdates ? current?.organizerJson : null, updatedAtServer: persistEventUpdates ? DateTime.utc( 2026, @@ -2466,6 +2618,8 @@ class _FakeCalendarClient required String eventId, }) async { calls.add('getEvent:$calendarId:$eventId'); + final error = getEventError; + if (error != null) throw error; final remote = remoteEvent; if (remote != null) return remote; for (final created in _createdEventsByIdentity.values) { @@ -2541,7 +2695,10 @@ class _FakeCalendarClient String id, { required String title, String providerCalendarId = 'cal-1', + String? description, String? location, + Object? remindersJson, + Object? organizerJson, String? etagOrChangeKey, String updatedAtServer = '2026-06-08T00:00:00.000Z', String? startTimeZone, @@ -2553,17 +2710,31 @@ class _FakeCalendarClient providerEventId: id, etagOrChangeKey: etagOrChangeKey, title: title, + description: description, location: location, startDateTime: '2026-06-08T09:00:00.000Z', - startTimeZone: startTimeZone, + startTimeZone: startTimeZone ?? 'UTC', endDateTime: '2026-06-08T10:00:00.000Z', - endTimeZone: endTimeZone, + endTimeZone: endTimeZone ?? 'UTC', + remindersJson: remindersJson, + organizerJson: organizerJson, updatedAtServer: updatedAtServer, rawJson: { 'id': id, 'summary': title, + if (description != null) 'description': description, if (location != null) 'location': location, if (etagOrChangeKey != null) 'etag': etagOrChangeKey, + 'start': { + 'dateTime': '2026-06-08T09:00:00.000Z', + 'timeZone': startTimeZone ?? 'UTC', + }, + 'end': { + 'dateTime': '2026-06-08T10:00:00.000Z', + 'timeZone': endTimeZone ?? 'UTC', + }, + if (remindersJson != null) 'reminders': remindersJson, + if (organizerJson != null) 'organizer': organizerJson, 'updated': updatedAtServer, }, ); From 6d9e36b248dffa49d6ccc6e85d36440341291b57 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 18:29:21 -0700 Subject: [PATCH 67/69] Fix dependent task replay and DAV reminders --- .../discovery/dav_discovery_repository.dart | 42 ++++- lib/src/dav/sync/dav_account_sync_engine.dart | 6 +- .../features/sync/pending_ops_replayer.dart | 9 +- .../sync/dav_account_sync_engine_test.dart | 163 ++++++++++++++++++ .../sync/pending_ops_replayer_test.dart | 79 +++++++++ 5 files changed, 295 insertions(+), 4 deletions(-) diff --git a/lib/src/dav/discovery/dav_discovery_repository.dart b/lib/src/dav/discovery/dav_discovery_repository.dart index a5d9fcf8..396c6205 100644 --- a/lib/src/dav/discovery/dav_discovery_repository.dart +++ b/lib/src/dav/discovery/dav_discovery_repository.dart @@ -18,8 +18,11 @@ final class DavDiscoveryRepository { final AppDatabase _database; final String Function() _idFactory; - Future commitSuccessfulInventory(DavDiscoveryResult result) { + /// Returns whether discovery changed a notification-enabled projection. + Future commitSuccessfulInventory(DavDiscoveryResult result) { return _database.transaction(() async { + final notificationProjectionsBefore = + await _notificationEnabledProjectionKeys(result.accountId); final service = result.service; await _database .into(_database.davAccountServices) @@ -164,9 +167,43 @@ final class DavDiscoveryRepository { updatedAtUtc: Value(service.discoveredAtUtc.toIso8601String()), ), ); + final notificationProjectionsAfter = + await _notificationEnabledProjectionKeys(result.accountId); + return !_sameProjectionKeys( + notificationProjectionsBefore, + notificationProjectionsAfter, + ); }); } + Future> _notificationEnabledProjectionKeys( + String accountId, + ) async { + final eventSources = + await (_database.select(_database.calendarSources)..where( + (row) => + row.accountId.equals(accountId) & + row.davCollectionId.isNotNull() & + row.remindersEnabled.equals(true) & + row.isDeleted.equals(false), + )) + .get(); + final taskLists = + await (_database.select(_database.taskLists)..where( + (row) => + row.accountId.equals(accountId) & + row.davCollectionId.isNotNull() & + row.remindersEnabled.equals(true) & + row.pendingDelete.equals(false) & + row.serverMissing.equals(false), + )) + .get(); + return { + for (final source in eventSources) 'event:${source.id}', + for (final list in taskLists) 'task:${list.id}', + }; + } + Future recordDiscoveryFailure(String accountId, String code) async { final existing = await (_database.select( _database.davAccountServices, @@ -295,6 +332,9 @@ final class DavDiscoveryRepository { } } +bool _sameProjectionKeys(Set left, Set right) => + left.length == right.length && left.containsAll(right); + String _projectionMetadata(DavCollectionDiscovery collection) => jsonEncode({ 'transport': 'caldav', 'hrefKey': collection.hrefKey, diff --git a/lib/src/dav/sync/dav_account_sync_engine.dart b/lib/src/dav/sync/dav_account_sync_engine.dart index 549a46bd..e220d858 100644 --- a/lib/src/dav/sync/dav_account_sync_engine.dart +++ b/lib/src/dav/sync/dav_account_sync_engine.dart @@ -174,6 +174,7 @@ final class DavAccountSyncEngine { final objectRepository = DavObjectRepository(database: _database); final discoveryRepository = DavDiscoveryRepository(database: _database); var discoveryRefreshed = false; + var notificationProjectionsChanged = false; final affected = {}; final failures = []; @@ -193,7 +194,8 @@ final class DavAccountSyncEngine { correlationId: _correlationIdFactory(), cancellationToken: cancellationToken, ); - await discoveryRepository.commitSuccessfulInventory(discovery); + notificationProjectionsChanged = await discoveryRepository + .commitSuccessfulInventory(discovery); } final collections = await _selectedCollections(); @@ -282,7 +284,7 @@ final class DavAccountSyncEngine { failures.addAll(followUpResults.failures); } - if (affected.isNotEmpty) { + if (affected.isNotEmpty || notificationProjectionsChanged) { await _rebuildNotifications?.call(_accountId, affected); } if (failures.isNotEmpty) { diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index f955ee50..d6600127 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -278,8 +278,11 @@ class PendingOpsReplayer { final acknowledgedFields = _request(completedOp).keys; for (final dependent in dependents) { final baseline = _jsonObject(dependent.baselineRawJson ?? '{}'); + final usesWholeTaskConflictBoundary = + dependent.operation == 'move_task' || + dependent.operation == 'delete_task'; // Keep the original timestamp and untouched fields so a provider edit to - // a different field is still detected by the dependent operation. + // a different field is still detected by a dependent field-level edit. for (final field in acknowledgedFields) { baseline[field] = serverTask.rawJson[field]; } @@ -288,6 +291,10 @@ class PendingOpsReplayer { )..where((row) => row.id.equals(dependent.id))).write( PendingOpsCompanion( baselineRawJson: Value(jsonEncode(baseline)), + // Whole-task checks must advance past the acknowledged local write. + baselineUpdatedUtc: usesWholeTaskConflictBoundary + ? Value(serverTask.updated?.toUtc().toIso8601String()) + : const Value.absent(), updatedAtUtc: Value(_now()), ), ); diff --git a/test/dav/sync/dav_account_sync_engine_test.dart b/test/dav/sync/dav_account_sync_engine_test.dart index 327601b9..cb1f3d79 100644 --- a/test/dav/sync/dav_account_sync_engine_test.dart +++ b/test/dav/sync/dav_account_sync_engine_test.dart @@ -8,6 +8,7 @@ import 'package:busymax/src/dav/mutation/dav_mutation_patch.dart'; import 'package:busymax/src/dav/mutation/dav_pending_operations.dart'; import 'package:busymax/src/dav/sync/dav_account_sync_engine.dart'; import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -206,6 +207,138 @@ void main() { }, ); + test( + 'removed collection rebuilds reminders without object-level changes', + () async { + const eventId = 'nextcloud-event'; + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: eventId, + accountId: 'account', + calendarSourceId: 'dav-calendar-collection', + provider: 'nextcloud', + providerCalendarId: _collectionHref, + providerEventId: 'event@example.test', + title: 'Removed event', + startDateTime: const Value('2026-08-09T09:00:00.000Z'), + startTimeZone: const Value('UTC'), + endDateTime: const Value('2026-08-09T10:00:00.000Z'), + endTimeZone: const Value('UTC'), + createdAtLocal: _now.millisecondsSinceEpoch, + updatedAtLocal: _now.millisecondsSinceEpoch, + ), + ); + await database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'dav-task-list-collection', + id: 'nextcloud-task', + davCollectionId: const Value('collection'), + title: 'Removed task', + taskAlarmsJson: const Value( + '[{"trigger":{"kind":"absolute",' + '"dateTime":"2026-08-09T08:30:00.000Z"}}]', + ), + rawJson: '{}', + createdLocalAtUtc: _now.toIso8601String(), + updatedLocalAtUtc: _now.toIso8601String(), + ), + ); + await database.batch( + (batch) => batch.insertAll(database.notificationSchedule, [ + for (final reminder in const [ + ( + id: 'event|nextcloud-event|display:30', + sourceType: 'event', + sourceId: eventId, + title: 'Removed event', + ), + ( + id: 'task|account|dav-task-list-collection|nextcloud-task', + sourceType: 'task', + sourceId: 'nextcloud-task', + title: 'Removed task', + ), + ]) + NotificationScheduleCompanion.insert( + id: reminder.id, + accountId: 'account', + sourceType: reminder.sourceType, + sourceId: reminder.sourceId, + scheduledAtUtc: DateTime.utc( + 2026, + 8, + 9, + 8, + 30, + ).millisecondsSinceEpoch, + title: reminder.title, + createdAtLocal: _now.millisecondsSinceEpoch, + updatedAtLocal: _now.millisecondsSinceEpoch, + ), + ]), + ); + var requestIndex = 0; + final client = MockClient((request) async { + final response = switch (requestIndex) { + 0 => http.Response( + '', + 200, + headers: { + 'dav': '1, 3, calendar-access, sync-collection', + 'allow': 'OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE', + }, + ), + 1 => _discoveryMultistatus(_currentPrincipalResponse), + 2 => _discoveryMultistatus(_principalPropertiesResponse), + 3 => _discoveryMultistatus(_emptyInventoryResponse), + _ => throw StateError('Unexpected discovery request.'), + }; + requestIndex += 1; + return response; + }); + var rebuildCalls = 0; + final result = await DavAccountSyncEngine( + database: database, + secretStore: secrets, + httpClient: client, + accountId: 'account', + correlationIdFactory: () => 'removed-collection', + nowUtc: () => _now, + rebuildNotifications: (accountId, affectedObjectIds) async { + rebuildCalls += 1; + expect(accountId, 'account'); + expect(affectedObjectIds, isEmpty); + await NotificationScheduleService( + database: database, + nowUtc: () => _now, + ).rebuildUpcomingNotifications(accountId); + }, + ).synchronize(full: true); + + expect(requestIndex, 4); + expect(result.discoveryRefreshed, isTrue); + expect(result.affectedObjectIds, isEmpty); + expect(rebuildCalls, 1); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isTrue, + ); + expect( + (await database.select(database.taskLists).getSingle()).serverMissing, + isTrue, + ); + expect( + await database.select(database.notificationSchedule).get(), + isEmpty, + ); + }, + ); + test('credential mismatch maps to reauthentication-required', () async { await secrets.saveCredential( 'account', @@ -415,3 +548,33 @@ SUMMARY:$summary\r END:VEVENT\r END:VCALENDAR\r '''; + +http.Response _discoveryMultistatus(String body) => http.Response( + body, + 207, + headers: {'content-type': 'application/xml; charset=utf-8'}, +); + +const _currentPrincipalResponse = ''' + + /cloud/.well-known/caldav + /cloud/remote.php/dav/principals/users/alex/ + HTTP/1.1 200 OK +'''; + +const _principalPropertiesResponse = ''' + + /cloud/remote.php/dav/principals/users/alex/ + + /cloud/remote.php/dav/calendars/alex/ + HTTP/1.1 200 OK + +'''; + +const _emptyInventoryResponse = ''' + + /cloud/remote.php/dav/calendars/alex/ + + HTTP/1.1 200 OK + +'''; diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index 6efb02cf..6aee3082 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -819,6 +819,85 @@ void main() { }, ); + for (final dependentOperation in ['delete_task', 'move_task']) { + final action = dependentOperation == 'delete_task' ? 'delete' : 'move'; + test( + 'existing task edit followed by $action does not self-conflict', + () async { + const baselineUpdatedUtc = '2026-06-04T00:00:00.000Z'; + final baselineRawJson = jsonEncode({ + 'id': 'task-1', + 'title': 'Base title', + 'updated': baselineUpdatedUtc, + }); + apiClient + ..persistTaskPatches = true + ..remoteTask = _taskDto( + 'task-1', + title: 'Base title', + updated: DateTime.parse(baselineUpdatedUtc), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'Base title', + updatedUtc: baselineUpdatedUtc, + rawJson: baselineRawJson, + ), + ); + var localEdit = 0; + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++localEdit), + ); + + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'title': 'Local title'}), + ); + if (dependentOperation == 'delete_task') { + await repository.deleteTask('list-1', 'task-1'); + } else { + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + previousSiblingTaskId: 'task-0', + ), + ); + } + + final queued = await database.pendingOpsDao.pendingOpsForReplay( + 'account', + _later, + ); + expect(queued, hasLength(2)); + final edit = queued.singleWhere( + (operation) => operation.operation == 'patch_task', + ); + final dependent = queued.singleWhere( + (operation) => operation.operation == dependentOperation, + ); + expect(dependent.dependsOnOpId, edit.id); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + expect(applied, 2); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(apiClient.calls, ['patch_task:task-1', '${action}_task:task-1']); + }, + ); + } + test( 'earlier local patch does not hide a genuine conflict on a later field', () async { From 2e5e9a05c6c4fb007296938bc431f6e5790a0b14 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 18:45:27 -0700 Subject: [PATCH 68/69] Consolidate project tooling under tool --- .github/workflows/flutter-windows.yml | 20 +++++----- README.md | 4 +- analysis_options.yaml | 2 +- docs/beta_snap_release.md | 2 +- docs/windows_development.md | 2 +- docs/windows_packaging.md | 10 ++--- pubspec.lock | 39 ------------------- pubspec.yaml | 2 - test/google_tasks/discovery_surface_test.dart | 2 +- .../windows_packaging_contract_test.dart | 2 +- .../build_install_snap_local_test.dart | 8 ++-- .../flutter_linux_workflow_test.dart | 0 .../install_linux_dev_desktop_test.dart | 6 +-- {tools => tool}/README.md | 0 {tools => tool}/build_install_snap_local.sh | 2 +- .../cache/tasks_v1_discovery.json | 0 .../fetch_tasks_discovery.dart | 2 +- {tools => tool}/install_linux_dev_desktop.sh | 2 +- .../windows/AppxManifest.xml.template | 0 {tools => tool}/windows/build_release.ps1 | 2 +- .../windows/check_prerequisites.ps1 | 0 {tools => tool}/windows/common.ps1 | 0 .../windows/create_test_certificate.ps1 | 0 .../windows/install_test_package.ps1 | 0 {tools => tool}/windows/package_store.ps1 | 0 {tools => tool}/windows/render_manifest.ps1 | 0 {tools => tool}/windows/run_native_tests.ps1 | 0 {tools => tool}/windows/run_wack.ps1 | 0 .../windows/tests/manifest_contract.Tests.ps1 | 0 .../tests/package_contents_contract.Tests.ps1 | 0 .../tests/prerequisite_contract.Tests.ps1 | 0 .../windows/tests/store_config.Tests.ps1 | 0 .../windows/tests/wack_report.Tests.ps1 | 0 {tools => tool}/windows/validate_manifest.ps1 | 0 .../windows/validate_package_contents.ps1 | 0 .../windows/validate_release_config.ps1 | 0 {tools => tool}/windows/wack_report.ps1 | 0 windows/runner/CMakeLists.txt | 2 +- 38 files changed, 34 insertions(+), 75 deletions(-) rename test/{tools => tool}/build_install_snap_local_test.dart (98%) rename test/{tools => tool}/flutter_linux_workflow_test.dart (100%) rename test/{tools => tool}/install_linux_dev_desktop_test.dart (95%) rename {tools => tool}/README.md (100%) rename {tools => tool}/build_install_snap_local.sh (99%) rename {tools => tool}/google_tasks_discovery/cache/tasks_v1_discovery.json (100%) rename {tools => tool}/google_tasks_discovery/fetch_tasks_discovery.dart (96%) rename {tools => tool}/install_linux_dev_desktop.sh (98%) rename {tools => tool}/windows/AppxManifest.xml.template (100%) rename {tools => tool}/windows/build_release.ps1 (98%) rename {tools => tool}/windows/check_prerequisites.ps1 (100%) rename {tools => tool}/windows/common.ps1 (100%) rename {tools => tool}/windows/create_test_certificate.ps1 (100%) rename {tools => tool}/windows/install_test_package.ps1 (100%) rename {tools => tool}/windows/package_store.ps1 (100%) rename {tools => tool}/windows/render_manifest.ps1 (100%) rename {tools => tool}/windows/run_native_tests.ps1 (100%) rename {tools => tool}/windows/run_wack.ps1 (100%) rename {tools => tool}/windows/tests/manifest_contract.Tests.ps1 (100%) rename {tools => tool}/windows/tests/package_contents_contract.Tests.ps1 (100%) rename {tools => tool}/windows/tests/prerequisite_contract.Tests.ps1 (100%) rename {tools => tool}/windows/tests/store_config.Tests.ps1 (100%) rename {tools => tool}/windows/tests/wack_report.Tests.ps1 (100%) rename {tools => tool}/windows/validate_manifest.ps1 (100%) rename {tools => tool}/windows/validate_package_contents.ps1 (100%) rename {tools => tool}/windows/validate_release_config.ps1 (100%) rename {tools => tool}/windows/wack_report.ps1 (100%) diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 9246e1c8..bc9ec29d 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -89,7 +89,7 @@ jobs: - name: Verify Windows SDK packaging tools shell: pwsh run: | - . ./tools/windows/common.ps1 + . ./tool/windows/common.ps1 $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' $sdk = Get-BusyMaxWindowsSdkDirectory -KitsBin $kitsBin if ($null -eq $sdk) { @@ -107,7 +107,7 @@ jobs: run: | New-Item -ItemType Directory -Force ` -Path build/windows/test-results | Out-Null - $environment = ./tools/windows/check_prerequisites.ps1 + $environment = ./tool/windows/check_prerequisites.ps1 $environment | ConvertTo-Json | Set-Content ` -LiteralPath build/windows/test-results/windows-environment.json ` -Encoding utf8NoBOM @@ -116,28 +116,28 @@ jobs: - name: Validate non-production CI identity shell: pwsh run: >- - ./tools/windows/validate_release_config.ps1 + ./tool/windows/validate_release_config.ps1 -ConfigPath build/windows/store-ci.json -Ci | Format-List - name: Run pinned Pester contract tests shell: pwsh run: >- - ./tools/windows/build_release.ps1 + ./tool/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage PesterTests - name: Restore dependencies and generate sources shell: pwsh run: >- - ./tools/windows/build_release.ps1 + ./tool/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage SourceGeneration - name: Check formatting, analysis, and platform boundaries shell: pwsh run: >- - ./tools/windows/build_release.ps1 + ./tool/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage StaticAnalysis @@ -145,7 +145,7 @@ jobs: id: flutter-tests shell: pwsh run: >- - ./tools/windows/build_release.ps1 + ./tool/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage FlutterTests @@ -206,7 +206,7 @@ jobs: id: windows-compile shell: pwsh run: >- - ./tools/windows/build_release.ps1 + ./tool/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage WindowsCompile @@ -239,7 +239,7 @@ jobs: id: native-tests shell: pwsh run: >- - ./tools/windows/build_release.ps1 + ./tool/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage NativeTests @@ -275,7 +275,7 @@ jobs: id: package shell: pwsh run: >- - ./tools/windows/build_release.ps1 + ./tool/windows/build_release.ps1 -ConfigPath build/windows/store-ci.json -Ci -Stage Package diff --git a/README.md b/README.md index c9f6bb39..0803d1c7 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Wayland windows with its desktop icon. The helper is idempotent and defaults to the Flutter debug bundle: ```bash -tools/install_linux_dev_desktop.sh +tool/install_linux_dev_desktop.sh ``` Then run BusyMax normally: @@ -128,7 +128,7 @@ flutter run -d linux -t lib/main_linux.dart \ --dart-define=MICROSOFT_OAUTH_CLIENT_ID= ``` -Use `tools/install_linux_dev_desktop.sh --uninstall` to remove the development +Use `tool/install_linux_dev_desktop.sh --uninstall` to remove the development launcher. Remove it before testing an installed Snap so the user-level launcher does not take precedence; packaged Snaps register their own launcher. diff --git a/analysis_options.yaml b/analysis_options.yaml index dd434b29..f6989f7f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,7 +3,7 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - build/** - - tools/google_tasks_discovery/cache/** + - tool/google_tasks_discovery/cache/** linter: rules: diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index affb0537..196f44c0 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -76,7 +76,7 @@ For a local scaffold smoke build instead, first quit every running BusyMax instance, including its tray process and any development build: ```bash -./tools/build_install_snap_local.sh \ +./tool/build_install_snap_local.sh \ --dart-define-from-file .snap-local/busymax-dart-defines.json ``` diff --git a/docs/windows_development.md b/docs/windows_development.md index 92a8db37..e2e1e3ea 100644 --- a/docs/windows_development.md +++ b/docs/windows_development.md @@ -24,7 +24,7 @@ Windows 11 24H2 minimum: ```powershell flutter config --enable-windows-desktop -.\tools\windows\check_prerequisites.ps1 +.\tool\windows\check_prerequisites.ps1 ``` ## Entry points diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md index e7411f34..d1377258 100644 --- a/docs/windows_packaging.md +++ b/docs/windows_packaging.md @@ -51,7 +51,7 @@ also require production HTTPS and OAuth configuration. From a 64-bit Windows 11 PowerShell prompt: ```powershell -.\tools\windows\build_release.ps1 ` +.\tool\windows\build_release.ps1 ` -ConfigPath config\windows_store.local.json ``` @@ -67,7 +67,7 @@ SHA-256. It performs no network deployment. check, so the manifest cannot claim a newer SDK than the release builder used. The manifest is rendered reproducibly from -`tools/windows/AppxManifest.xml.template`. It declares x64, minimum OS +`tool/windows/AppxManifest.xml.template`. It declares x64, minimum OS `10.0.26100.0`, only internet client and full-trust desktop capabilities, `.ics`, `webcal`, StartupTask, toast activation, resource languages derived from ARB catalogs, and BusyMax visual assets. It contains no location, microphone, @@ -112,9 +112,9 @@ by default; that element does not define an arguments attribute. For installed-package testing only: ```powershell -.\tools\windows\create_test_certificate.ps1 ` +.\tool\windows\create_test_certificate.ps1 ` -ConfigPath config\windows_store.local.json -.\tools\windows\install_test_package.ps1 ` +.\tool\windows\install_test_package.ps1 ` -ConfigPath config\windows_store.local.json ` -PackagePath build\windows\store\BusyMax--x64.msix ` -PfxPath build\windows\test-signing\busymax-test-only.pfx @@ -153,7 +153,7 @@ artifact. After installing a release-equivalent locally signed package, run: ```powershell -.\tools\windows\run_wack.ps1 ` +.\tool\windows\run_wack.ps1 ` -PackagePath build\windows\store\BusyMax-test-signed.msix ``` diff --git a/pubspec.lock b/pubspec.lock index 71815bf9..34f28676 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -414,11 +414,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -545,11 +540,6 @@ packages: description: flutter source: sdk version: "0.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" get_it: dependency: transitive description: @@ -646,11 +636,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.5" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" intl: dependency: "direct main" description: @@ -963,14 +948,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" - process: - dependency: transitive - description: - name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 - url: "https://pub.dev" - source: hosted - version: "5.0.5" pub_semver: dependency: transitive description: @@ -1128,14 +1105,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" system_theme: dependency: "direct main" description: @@ -1327,14 +1296,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" - url: "https://pub.dev" - source: hosted - version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8a679155..d415edd5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,8 +49,6 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - integration_test: - sdk: flutter image: 4.8.0 build_runner: ^2.15.0 drift_dev: ^2.33.0 diff --git a/test/google_tasks/discovery_surface_test.dart b/test/google_tasks/discovery_surface_test.dart index c4d76cfb..22150a42 100644 --- a/test/google_tasks/discovery_surface_test.dart +++ b/test/google_tasks/discovery_surface_test.dart @@ -9,7 +9,7 @@ void main() { final discovery = jsonDecode( File( - 'tools/google_tasks_discovery/cache/tasks_v1_discovery.json', + 'tool/google_tasks_discovery/cache/tasks_v1_discovery.json', ).readAsStringSync(), ) as Map; diff --git a/test/platform/windows/windows_packaging_contract_test.dart b/test/platform/windows/windows_packaging_contract_test.dart index 88be459c..26f14504 100644 --- a/test/platform/windows/windows_packaging_contract_test.dart +++ b/test/platform/windows/windows_packaging_contract_test.dart @@ -6,7 +6,7 @@ import 'package:xml/xml.dart'; void main() { final manifestSource = File( - 'tools/windows/AppxManifest.xml.template', + 'tool/windows/AppxManifest.xml.template', ).readAsStringSync(); final manifest = XmlDocument.parse(manifestSource); diff --git a/test/tools/build_install_snap_local_test.dart b/test/tool/build_install_snap_local_test.dart similarity index 98% rename from test/tools/build_install_snap_local_test.dart rename to test/tool/build_install_snap_local_test.dart index 80f2aa01..82093d34 100644 --- a/test/tools/build_install_snap_local_test.dart +++ b/test/tool/build_install_snap_local_test.dart @@ -183,7 +183,7 @@ void main() { await _expectUnsafeRootRejected( fixture, - arguments: const ['--root', 'tools/..'], + arguments: const ['--root', 'tool/..'], ); }); @@ -327,8 +327,8 @@ final class _LocalSnapFixture { final commandLog = File('${root.path}/commands.log'); _writeFile( - '${project.path}/tools/build_install_snap_local.sh', - File('tools/build_install_snap_local.sh').readAsStringSync(), + '${project.path}/tool/build_install_snap_local.sh', + File('tool/build_install_snap_local.sh').readAsStringSync(), ); _writeFile( '${project.path}/pubspec.yaml', @@ -461,7 +461,7 @@ fi return Process.run( 'bash', [ - '${project.path}/tools/build_install_snap_local.sh', + '${project.path}/tool/build_install_snap_local.sh', '--skip-tests', '--no-run', '--scaffold', diff --git a/test/tools/flutter_linux_workflow_test.dart b/test/tool/flutter_linux_workflow_test.dart similarity index 100% rename from test/tools/flutter_linux_workflow_test.dart rename to test/tool/flutter_linux_workflow_test.dart diff --git a/test/tools/install_linux_dev_desktop_test.dart b/test/tool/install_linux_dev_desktop_test.dart similarity index 95% rename from test/tools/install_linux_dev_desktop_test.dart rename to test/tool/install_linux_dev_desktop_test.dart index 2782dcf5..c0690b97 100644 --- a/test/tools/install_linux_dev_desktop_test.dart +++ b/test/tool/install_linux_dev_desktop_test.dart @@ -22,7 +22,7 @@ void main() { for (var attempt = 0; attempt < 2; attempt++) { final result = await Process.run('bash', [ - 'tools/install_linux_dev_desktop.sh', + 'tool/install_linux_dev_desktop.sh', '--executable', executable, ], environment: environment); @@ -53,7 +53,7 @@ void main() { ); final uninstall = await Process.run('bash', [ - 'tools/install_linux_dev_desktop.sh', + 'tool/install_linux_dev_desktop.sh', '--uninstall', ], environment: environment); expect(uninstall.exitCode, 0, reason: _processFailure(uninstall)); @@ -77,7 +77,7 @@ void main() { final result = await Process.run( 'bash', - ['tools/install_linux_dev_desktop.sh'], + ['tool/install_linux_dev_desktop.sh'], environment: { ...Platform.environment, 'XDG_DATA_HOME': dataHome.path, diff --git a/tools/README.md b/tool/README.md similarity index 100% rename from tools/README.md rename to tool/README.md diff --git a/tools/build_install_snap_local.sh b/tool/build_install_snap_local.sh similarity index 99% rename from tools/build_install_snap_local.sh rename to tool/build_install_snap_local.sh index 9b42d6c8..6471db0d 100755 --- a/tools/build_install_snap_local.sh +++ b/tool/build_install_snap_local.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: tools/build_install_snap_local.sh [version] [options] +Usage: tool/build_install_snap_local.sh [version] [options] Build the Flutter Linux release, replace the payload in an installed snap scaffold, pack it, install it locally, and optionally run it. diff --git a/tools/google_tasks_discovery/cache/tasks_v1_discovery.json b/tool/google_tasks_discovery/cache/tasks_v1_discovery.json similarity index 100% rename from tools/google_tasks_discovery/cache/tasks_v1_discovery.json rename to tool/google_tasks_discovery/cache/tasks_v1_discovery.json diff --git a/tools/google_tasks_discovery/fetch_tasks_discovery.dart b/tool/google_tasks_discovery/fetch_tasks_discovery.dart similarity index 96% rename from tools/google_tasks_discovery/fetch_tasks_discovery.dart rename to tool/google_tasks_discovery/fetch_tasks_discovery.dart index f0fb0120..04da3dc6 100644 --- a/tools/google_tasks_discovery/fetch_tasks_discovery.dart +++ b/tool/google_tasks_discovery/fetch_tasks_discovery.dart @@ -4,7 +4,7 @@ import 'dart:io'; const discoveryUrl = 'https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest'; const lockedRevision = '20260602'; -const cachePath = 'tools/google_tasks_discovery/cache/tasks_v1_discovery.json'; +const cachePath = 'tool/google_tasks_discovery/cache/tasks_v1_discovery.json'; const revisionDartPath = 'lib/src/google_tasks/api/tasks_discovery_revision.dart'; diff --git a/tools/install_linux_dev_desktop.sh b/tool/install_linux_dev_desktop.sh similarity index 98% rename from tools/install_linux_dev_desktop.sh rename to tool/install_linux_dev_desktop.sh index 43893a6b..e3130283 100755 --- a/tools/install_linux_dev_desktop.sh +++ b/tool/install_linux_dev_desktop.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Usage: tools/install_linux_dev_desktop.sh [options] +Usage: tool/install_linux_dev_desktop.sh [options] Register this BusyMax checkout with the current user's Linux desktop so GNOME can associate native Wayland windows with the BusyMax launcher and icon. diff --git a/tools/windows/AppxManifest.xml.template b/tool/windows/AppxManifest.xml.template similarity index 100% rename from tools/windows/AppxManifest.xml.template rename to tool/windows/AppxManifest.xml.template diff --git a/tools/windows/build_release.ps1 b/tool/windows/build_release.ps1 similarity index 98% rename from tools/windows/build_release.ps1 rename to tool/windows/build_release.ps1 index 8a9d78c1..d19be91c 100644 --- a/tools/windows/build_release.ps1 +++ b/tool/windows/build_release.ps1 @@ -23,7 +23,7 @@ function Invoke-BusyMaxPesterTests { New-Item -ItemType Directory -Force ` -Path 'build\windows\test-results' | Out-Null $pester = Invoke-Pester ` - -Path 'tools\windows\tests' -PassThru -Output Detailed + -Path 'tool\windows\tests' -PassThru -Output Detailed [pscustomobject]@{ Result = [string]$pester.Result PassedCount = $pester.PassedCount diff --git a/tools/windows/check_prerequisites.ps1 b/tool/windows/check_prerequisites.ps1 similarity index 100% rename from tools/windows/check_prerequisites.ps1 rename to tool/windows/check_prerequisites.ps1 diff --git a/tools/windows/common.ps1 b/tool/windows/common.ps1 similarity index 100% rename from tools/windows/common.ps1 rename to tool/windows/common.ps1 diff --git a/tools/windows/create_test_certificate.ps1 b/tool/windows/create_test_certificate.ps1 similarity index 100% rename from tools/windows/create_test_certificate.ps1 rename to tool/windows/create_test_certificate.ps1 diff --git a/tools/windows/install_test_package.ps1 b/tool/windows/install_test_package.ps1 similarity index 100% rename from tools/windows/install_test_package.ps1 rename to tool/windows/install_test_package.ps1 diff --git a/tools/windows/package_store.ps1 b/tool/windows/package_store.ps1 similarity index 100% rename from tools/windows/package_store.ps1 rename to tool/windows/package_store.ps1 diff --git a/tools/windows/render_manifest.ps1 b/tool/windows/render_manifest.ps1 similarity index 100% rename from tools/windows/render_manifest.ps1 rename to tool/windows/render_manifest.ps1 diff --git a/tools/windows/run_native_tests.ps1 b/tool/windows/run_native_tests.ps1 similarity index 100% rename from tools/windows/run_native_tests.ps1 rename to tool/windows/run_native_tests.ps1 diff --git a/tools/windows/run_wack.ps1 b/tool/windows/run_wack.ps1 similarity index 100% rename from tools/windows/run_wack.ps1 rename to tool/windows/run_wack.ps1 diff --git a/tools/windows/tests/manifest_contract.Tests.ps1 b/tool/windows/tests/manifest_contract.Tests.ps1 similarity index 100% rename from tools/windows/tests/manifest_contract.Tests.ps1 rename to tool/windows/tests/manifest_contract.Tests.ps1 diff --git a/tools/windows/tests/package_contents_contract.Tests.ps1 b/tool/windows/tests/package_contents_contract.Tests.ps1 similarity index 100% rename from tools/windows/tests/package_contents_contract.Tests.ps1 rename to tool/windows/tests/package_contents_contract.Tests.ps1 diff --git a/tools/windows/tests/prerequisite_contract.Tests.ps1 b/tool/windows/tests/prerequisite_contract.Tests.ps1 similarity index 100% rename from tools/windows/tests/prerequisite_contract.Tests.ps1 rename to tool/windows/tests/prerequisite_contract.Tests.ps1 diff --git a/tools/windows/tests/store_config.Tests.ps1 b/tool/windows/tests/store_config.Tests.ps1 similarity index 100% rename from tools/windows/tests/store_config.Tests.ps1 rename to tool/windows/tests/store_config.Tests.ps1 diff --git a/tools/windows/tests/wack_report.Tests.ps1 b/tool/windows/tests/wack_report.Tests.ps1 similarity index 100% rename from tools/windows/tests/wack_report.Tests.ps1 rename to tool/windows/tests/wack_report.Tests.ps1 diff --git a/tools/windows/validate_manifest.ps1 b/tool/windows/validate_manifest.ps1 similarity index 100% rename from tools/windows/validate_manifest.ps1 rename to tool/windows/validate_manifest.ps1 diff --git a/tools/windows/validate_package_contents.ps1 b/tool/windows/validate_package_contents.ps1 similarity index 100% rename from tools/windows/validate_package_contents.ps1 rename to tool/windows/validate_package_contents.ps1 diff --git a/tools/windows/validate_release_config.ps1 b/tool/windows/validate_release_config.ps1 similarity index 100% rename from tools/windows/validate_release_config.ps1 rename to tool/windows/validate_release_config.ps1 diff --git a/tools/windows/wack_report.ps1 b/tool/windows/wack_report.ps1 similarity index 100% rename from tools/windows/wack_report.ps1 rename to tool/windows/wack_report.ps1 diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 132429a4..1bdd7b80 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -44,7 +44,7 @@ target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_link_libraries(${BINARY_NAME} PRIVATE "advapi32.lib" "windowsapp.lib") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") -# Built and executed explicitly by tools/windows/run_native_tests.ps1. Keeping +# Built and executed explicitly by tool/windows/run_native_tests.ps1. Keeping # it out of the default target prevents test code from entering the package. add_executable(busymax_runner_native_test EXCLUDE_FROM_ALL "tests/single_instance_test.cpp" From 67dd561336c53bb13c4ca0e0bdb7d261d348444d Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 4 Sep 2026 19:03:52 -0700 Subject: [PATCH 69/69] Avoid NetworkManager access in confined snaps --- lib/main_linux.dart | 6 ++ .../network_connectivity_service.dart | 8 +++ .../linux_network_connectivity_monitor.dart | 19 ++++++ ...nux_network_connectivity_monitor_test.dart | 58 +++++++++++++++++++ test/tool/build_install_snap_local_test.dart | 28 +++++++++ tool/build_install_snap_local.sh | 12 +++- 6 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 lib/src/platform/linux/linux_network_connectivity_monitor.dart create mode 100644 test/platform/linux/linux_network_connectivity_monitor_test.dart diff --git a/lib/main_linux.dart b/lib/main_linux.dart index 516dd396..555a5a5c 100644 --- a/lib/main_linux.dart +++ b/lib/main_linux.dart @@ -21,6 +21,7 @@ import 'src/platform/common/desktop_services.dart'; import 'src/platform/external_calendar_open_service.dart'; import 'src/platform/gtk_font_service.dart'; import 'src/platform/linux/linux_notification_backend.dart'; +import 'src/platform/linux/linux_network_connectivity_monitor.dart'; import 'src/platform/linux/linux_secret_storage_presentation.dart'; import 'src/platform/linux_autostart_service.dart'; import 'src/platform/linux_header_bar_service.dart'; @@ -79,6 +80,11 @@ Future main(List arguments) async { initialAppSettingsProvider.overrideWithValue(initialAppSettings), initialGtkFontSettingsProvider.overrideWithValue(initialGtkFont), initialGtkThemeColorsProvider.overrideWithValue(initialGtkThemeColors), + networkConnectivityMonitorProvider.overrideWith((ref) { + final monitor = createLinuxNetworkConnectivityMonitor(); + ref.onDispose(() => unawaited(monitor.dispose())); + return monitor; + }), desktopWindowServiceProvider.overrideWithValue(const LinuxWindowService()), desktopAutostartServiceProvider.overrideWith( (ref) => LinuxAutostartService(), diff --git a/lib/src/features/connectivity/network_connectivity_service.dart b/lib/src/features/connectivity/network_connectivity_service.dart index f32dcbb2..e2fc010b 100644 --- a/lib/src/features/connectivity/network_connectivity_service.dart +++ b/lib/src/features/connectivity/network_connectivity_service.dart @@ -30,6 +30,12 @@ final class NetworkConnectivityMonitor { connectivityChanges ?? platform.onConnectivityChanged; } + /// Keeps network requests enabled without querying a platform status API. + NetworkConnectivityMonitor.withoutPlatformObservation() + : initialCheckTimeout = const Duration(seconds: 5), + _checkConnectivity = _unknownConnectivityCheck, + _connectivityChanges = const Stream>.empty(); + final Duration initialCheckTimeout; late final ConnectivityCheck _checkConnectivity; late final Stream> _connectivityChanges; @@ -186,6 +192,8 @@ final class NetworkConnectivityMonitor { } } +Future> _unknownConnectivityCheck() async => const []; + final class ConnectivityAwareHttpClient extends http.BaseClient { ConnectivityAwareHttpClient({ required http.Client inner, diff --git a/lib/src/platform/linux/linux_network_connectivity_monitor.dart b/lib/src/platform/linux/linux_network_connectivity_monitor.dart new file mode 100644 index 00000000..ddf02bc0 --- /dev/null +++ b/lib/src/platform/linux/linux_network_connectivity_monitor.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import '../../features/connectivity/network_connectivity_service.dart'; + +typedef ObservedConnectivityMonitorFactory = + NetworkConnectivityMonitor Function(); + +/// Avoids NetworkManager's system D-Bus API inside a strictly confined Snap. +NetworkConnectivityMonitor createLinuxNetworkConnectivityMonitor({ + Map? environment, + ObservedConnectivityMonitorFactory? observedMonitorFactory, +}) { + final runtimeEnvironment = environment ?? Platform.environment; + final snapPath = runtimeEnvironment['SNAP']?.trim(); + if (snapPath != null && snapPath.isNotEmpty) { + return NetworkConnectivityMonitor.withoutPlatformObservation(); + } + return (observedMonitorFactory ?? NetworkConnectivityMonitor.new)(); +} diff --git a/test/platform/linux/linux_network_connectivity_monitor_test.dart b/test/platform/linux/linux_network_connectivity_monitor_test.dart new file mode 100644 index 00000000..348519d0 --- /dev/null +++ b/test/platform/linux/linux_network_connectivity_monitor_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/platform/linux/linux_network_connectivity_monitor.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'Snap runtime does not construct the platform connectivity backend', + () async { + var platformFactoryCalls = 0; + final monitor = createLinuxNetworkConnectivityMonitor( + environment: const {'SNAP': '/snap/busymax/current'}, + observedMonitorFactory: () { + platformFactoryCalls += 1; + throw StateError('NetworkManager must not be accessed in a Snap'); + }, + ); + addTearDown(monitor.dispose); + + await monitor.initialize(); + + expect(platformFactoryCalls, 0); + expect(monitor.availability, NetworkAvailability.unknown); + expect(await monitor.canUseNetwork(), isTrue); + }, + ); + + test( + 'unpackaged Linux runtime retains platform connectivity monitoring', + () async { + final changes = StreamController>.broadcast(); + final observedMonitor = NetworkConnectivityMonitor( + checkConnectivity: () async => [ConnectivityResult.none], + connectivityChanges: changes.stream, + ); + var platformFactoryCalls = 0; + final monitor = createLinuxNetworkConnectivityMonitor( + environment: const {}, + observedMonitorFactory: () { + platformFactoryCalls += 1; + return observedMonitor; + }, + ); + addTearDown(() async { + await monitor.dispose(); + await changes.close(); + }); + + await monitor.initialize(); + + expect(platformFactoryCalls, 1); + expect(monitor, same(observedMonitor)); + expect(monitor.availability, NetworkAvailability.offline); + }, + ); +} diff --git a/test/tool/build_install_snap_local_test.dart b/test/tool/build_install_snap_local_test.dart index 82093d34..f36bb8c9 100644 --- a/test/tool/build_install_snap_local_test.dart +++ b/test/tool/build_install_snap_local_test.dart @@ -4,6 +4,34 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('build_install_snap_local.sh', () { + test('explains missing OAuth configuration for local builds', () async { + final fixture = await _LocalSnapFixture.create(); + addTearDown(fixture.dispose); + + final result = await fixture.run( + arguments: ['--root', fixture.validRoot.path], + ); + final output = '${result.stdout}\n${result.stderr}'; + + expect( + result.exitCode, + 0, + reason: _processFailure(result, fixture.commandLogContents), + ); + expect( + output, + contains('Google and Microsoft sign-in will be unavailable'), + ); + expect( + output, + contains( + './tool/build_install_snap_local.sh ' + '--dart-define-from-file .snap-local/busymax-dart-defines.json', + ), + ); + expect(output, contains('Apple and Nextcloud remain available')); + }); + test('installs without removing the existing snap or its data', () async { final fixture = await _LocalSnapFixture.create(); addTearDown(fixture.dispose); diff --git a/tool/build_install_snap_local.sh b/tool/build_install_snap_local.sh index 6471db0d..27dd206a 100755 --- a/tool/build_install_snap_local.sh +++ b/tool/build_install_snap_local.sh @@ -285,7 +285,17 @@ echo "App ID: $APP_ID" echo "Scaffold: $SNAP_SCAFFOLD" echo "Root: $SNAP_ROOT" echo "Output: $OUT" -echo "Defines: $((${#DART_DEFINE_ARGS[@]} + ${#DART_DEFINE_FILE_ARGS[@]})) build-time entries" +DEFINE_ENTRY_COUNT=$((${#DART_DEFINE_ARGS[@]} + ${#DART_DEFINE_FILE_ARGS[@]})) +echo "Defines: $DEFINE_ENTRY_COUNT build-time entries" +if [[ "$DEFINE_ENTRY_COUNT" -eq 0 ]]; then + cat >&2 <<'EOF' +Warning: No OAuth build definitions were supplied. +Google and Microsoft sign-in will be unavailable in this local build. +Apple and Nextcloud remain available. +To configure OAuth providers, rebuild with: + ./tool/build_install_snap_local.sh --dart-define-from-file .snap-local/busymax-dart-defines.json +EOF +fi if [[ "$SKIP_TESTS" != "1" ]]; then echo "== Validate source =="

VHKJ+#7#=fuyDXbb3`#E#;rDrE?knt9*fV~jM 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 literal 0 HcmV?d00001 diff --git a/windows/runner/resources/msix/Square150x150Logo.scale-400.png b/windows/runner/resources/msix/Square150x150Logo.scale-400.png new file mode 100644 index 0000000000000000000000000000000000000000..bec3d9ab0068534f0d8e55abed4e2457fd88c139 GIT binary patch literal 163667 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