Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/providers/collab/active_page_live_sync_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,8 @@ class ActivePageLiveSyncNotifier extends Notifier<ActivePageLiveSyncState> {
);
}

for (final text in ref.read(textProvider)) {
for (final text
in ref.read(textProvider.notifier).snapshotForPersistence()) {
final payload = Map<String, dynamic>.from(text.toJson())
..putIfAbsent('elementType', () => _CollabElementKind.text.name);
envelopes.add(
Expand Down
4 changes: 3 additions & 1 deletion lib/providers/strategy_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import 'package:icarus/providers/user_preferences_provider.dart';
import 'package:icarus/providers/strategy_page.dart';
import 'package:icarus/providers/strategy_settings_provider.dart';
import 'package:icarus/providers/text_provider.dart';
import 'package:icarus/providers/text_draft_provider.dart';
import 'package:icarus/providers/transition_provider.dart';
import 'package:icarus/providers/utility_provider.dart';
import 'package:hive_ce/hive.dart';
Expand Down Expand Up @@ -184,8 +185,9 @@ class StrategyProvider extends Notifier<StrategyState> {
return true;
}

final hasTextDrafts = ref.read(textDraftProvider).isNotEmpty;
final saveState = ref.read(strategySaveStateProvider);
if (!saveState.isDirty) {
if (!saveState.isDirty && !hasTextDrafts) {
return true;
}

Expand Down
23 changes: 22 additions & 1 deletion lib/services/unsaved_strategy_guard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart';
import 'package:icarus/providers/collab/strategy_op_queue_provider.dart';
import 'package:icarus/providers/strategy_save_state_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/providers/text_draft_provider.dart';
import 'package:icarus/services/app_error_reporter.dart';
import 'package:icarus/strategy/strategy_page_models.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
Expand Down Expand Up @@ -141,6 +142,24 @@ Future<bool> _guardCloudStrategyExit({
required WidgetRef ref,
required Future<void> Function() onContinue,
}) async {
final openingStrategy = ref.read(strategyProvider);
if (ref.read(textDraftProvider).isNotEmpty &&
openingStrategy.strategyId != null) {
try {
await ref
.read(strategyProvider.notifier)
.forceSaveNow(openingStrategy.strategyId!);
} catch (error, stackTrace) {
AppErrorReporter.reportError(
'Failed to sync the active text edit before leaving.',
error: error,
stackTrace: stackTrace,
source: 'cloud_media.exit_guard',
);
return false;
}
}

while (true) {
final strategyState = ref.read(strategyProvider);
final saveState = ref.read(strategySaveStateProvider);
Expand Down Expand Up @@ -279,7 +298,9 @@ Future<bool> guardUnsavedStrategyExit({
);
}

if (strategyState.strategyName == null || !saveState.isDirty) {
final hasTextDrafts = ref.read(textDraftProvider).isNotEmpty;
if (strategyState.strategyName == null ||
(!saveState.isDirty && !hasTextDrafts)) {
await onContinue();
return true;
}
Expand Down
24 changes: 23 additions & 1 deletion lib/widgets/cloud_sync_status_chip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import 'package:icarus/providers/collab/strategy_conflict_provider.dart';
import 'package:icarus/providers/collab/strategy_op_queue_provider.dart';
import 'package:icarus/providers/strategy_provider.dart';
import 'package:icarus/providers/strategy_save_state_provider.dart';
import 'package:icarus/providers/text_draft_provider.dart';
import 'package:icarus/strategy/strategy_page_models.dart';
import 'package:shadcn_ui/shadcn_ui.dart';

const _chipSwitchDuration = Duration(milliseconds: 150);
const _conflictToastGap = Duration(seconds: 5);

enum _SyncStatus { synced, syncing, offline, attention }
enum _SyncStatus { synced, editing, syncing, offline, attention }

/// Persistent cloud sync indicator for the strategy editor top strip.
///
Expand Down Expand Up @@ -103,6 +104,9 @@ class _CloudSyncStatusChipState extends ConsumerState<CloudSyncStatusChip> {

final saveState = ref.watch(strategySaveStateProvider);
final opQueueState = ref.watch(strategyOpQueueProvider);
final hasTextDrafts = ref.watch(
textDraftProvider.select((drafts) => drafts.isNotEmpty),
);
final isConnected = ref.watch(convexConnectionProvider).valueOrNull ?? true;

final _SyncStatus status;
Expand All @@ -112,6 +116,8 @@ class _CloudSyncStatusChipState extends ConsumerState<CloudSyncStatusChip> {
status = _SyncStatus.attention;
} else if (!isConnected) {
status = _SyncStatus.offline;
} else if (hasTextDrafts) {
status = _SyncStatus.editing;
} else if (saveState.isSaving ||
saveState.hasPendingCloudSync ||
saveState.hasPendingMediaSync ||
Expand Down Expand Up @@ -190,6 +196,7 @@ class _CloudSyncStatusChipState extends ConsumerState<CloudSyncStatusChip> {
case _SyncStatus.attention:
return Settings.tacticalVioletTheme.destructive.withValues(alpha: 0.14);
case _SyncStatus.offline:
case _SyncStatus.editing:
case _SyncStatus.syncing:
case _SyncStatus.synced:
return Settings.tacticalVioletTheme.muted.withValues(alpha: 0.4);
Expand All @@ -201,6 +208,7 @@ class _CloudSyncStatusChipState extends ConsumerState<CloudSyncStatusChip> {
case _SyncStatus.attention:
return Settings.tacticalVioletTheme.destructive;
case _SyncStatus.offline:
case _SyncStatus.editing:
case _SyncStatus.syncing:
case _SyncStatus.synced:
return Settings.tacticalVioletTheme.mutedForeground;
Expand All @@ -217,6 +225,13 @@ class _CloudSyncStatusChipState extends ConsumerState<CloudSyncStatusChip> {
size: 13,
color: color,
);
case _SyncStatus.editing:
return Icon(
Icons.edit_outlined,
key: const ValueKey('editing'),
size: 13,
color: color,
);
case _SyncStatus.syncing:
return SizedBox(
key: const ValueKey('syncing'),
Expand Down Expand Up @@ -248,6 +263,8 @@ class _CloudSyncStatusChipState extends ConsumerState<CloudSyncStatusChip> {
switch (status) {
case _SyncStatus.synced:
return 'Synced';
case _SyncStatus.editing:
return 'Editing…';
case _SyncStatus.syncing:
return 'Syncing…';
case _SyncStatus.offline:
Expand Down Expand Up @@ -324,6 +341,8 @@ class _SyncStatusPopover extends StatelessWidget {
switch (status) {
case _SyncStatus.synced:
return 'All changes synced';
case _SyncStatus.editing:
return 'Edit not synced yet';
case _SyncStatus.syncing:
return 'Syncing changes';
case _SyncStatus.offline:
Expand All @@ -337,6 +356,9 @@ class _SyncStatusPopover extends StatelessWidget {
switch (status) {
case _SyncStatus.synced:
return 'Your strategy is safely stored in the cloud.';
case _SyncStatus.editing:
return 'Finish editing or switch pages to send this change to the '
'cloud.';
case _SyncStatus.syncing:
return 'Your edits are being sent to the cloud. You can keep '
'working — this happens in the background.';
Expand Down
50 changes: 35 additions & 15 deletions lib/widgets/draggable_widgets/text/text_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,14 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> {
late final FocusNode _focusNode;
late final TextDraftProvider _draftNotifier;
late final ProviderSubscription<Map<String, String>> _draftSubscription;
bool _syncingController = false;

@override
void initState() {
super.initState();
_draftNotifier = ref.read(textDraftProvider.notifier);
_controller = TextEditingController(text: _effectiveText());
_controller.addListener(_onControllerChanged);
_focusNode = FocusNode()..addListener(_onFocusChange);
_draftSubscription = ref.listenManual<Map<String, String>>(
textDraftProvider,
Expand Down Expand Up @@ -115,7 +117,9 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> {
_focusNode
..removeListener(_onFocusChange)
..dispose();
_controller.dispose();
_controller
..removeListener(_onControllerChanged)
..dispose();
super.dispose();
}

Expand All @@ -128,6 +132,17 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> {
_draftNotifier.commitDraft(widget.id);
}

void _onControllerChanged() {
if (_syncingController) return;

final nextText = _controller.text;
final currentText = _draftNotifier.draftFor(widget.id) ?? widget.text;
if (nextText == currentText) return;

_draftNotifier.setDraft(widget.id, nextText);
if (mounted) setState(() {});
}

void _syncControllerWithExternalState() {
if (!_controller.value.isComposingRangeValid) {
_controller.clearComposing();
Expand All @@ -141,12 +156,17 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> {
final extentOffset =
selection.extentOffset.clamp(0, nextText.length).toInt();

_controller.value = TextEditingValue(
text: nextText,
selection: selection.isValid
? TextSelection(baseOffset: baseOffset, extentOffset: extentOffset)
: TextSelection.collapsed(offset: nextText.length),
);
_syncingController = true;
try {
_controller.value = TextEditingValue(
text: nextText,
selection: selection.isValid
? TextSelection(baseOffset: baseOffset, extentOffset: extentOffset)
: TextSelection.collapsed(offset: nextText.length),
);
} finally {
_syncingController = false;
}
}

void _updateMeasuredSize() {
Expand Down Expand Up @@ -183,10 +203,6 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> {
controller: _controller,
focusNode: _focusNode,
fontSize: widget.fontSize,
onChanged: (value) {
_draftNotifier.setDraft(widget.id, value);
setState(() {});
},
onTapOutside: (_) {
_focusNode.unfocus();
},
Expand Down Expand Up @@ -272,7 +288,6 @@ class _SharedTextField extends StatelessWidget {
this.readOnly = false,
this.enableInteractiveSelection = true,
this.showCursor = true,
this.onChanged,
this.onTapOutside,
});

Expand All @@ -282,13 +297,12 @@ class _SharedTextField extends StatelessWidget {
final bool readOnly;
final bool enableInteractiveSelection;
final bool showCursor;
final ValueChanged<String>? onChanged;
final TapRegionCallback? onTapOutside;

@override
Widget build(BuildContext context) {
final coordinateSystem = CoordinateSystem.instance;
return MediaQuery(
final textField = MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),
child: TextField(
focusNode: focusNode,
Expand All @@ -308,10 +322,16 @@ class _SharedTextField extends StatelessWidget {
scrollPadding: EdgeInsets.zero,
textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline,
onChanged: onChanged,
onTapOutside: onTapOutside,
),
);

return MergeSemantics(
child: Semantics(
label: 'Placed text',
child: textField,
),
);
}
}

Expand Down
11 changes: 7 additions & 4 deletions test/strategy_page_session_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -954,19 +954,22 @@ void main() {
selectFirstPageIfNeeded: true,
);
container.read(textProvider.notifier).fromHive([
PlacedText(id: 'local-edit', position: const Offset(5, 5))
..text = 'unsent',
PlacedText(id: 'text-page-1', position: const Offset(5, 5))..text = 'one',
]);
container
.read(textDraftProvider.notifier)
.setDraft('text-page-1', 'unsent draft');

await session.setActivePage('page-2').timeout(const Duration(seconds: 2));
expect(session.activePageId, 'page-2');
expect(remote.selectedPageIds, contains('page-2'));
expect(container.read(textProvider).single.text, 'two');
expect(container.read(textDraftProvider), isEmpty);
expect(queue.flushNowCount, 1);
expect(
container.read(strategyOpQueueProvider).pending.any((pending) =>
pending.op.pagePublicId == 'page-1' ||
pending.op.entityPublicId == 'page-1'),
pending.op.entityPublicId == 'text-page-1' &&
pending.op.payload.toString().contains('unsent draft')),
isTrue,
);
});
Expand Down
63 changes: 63 additions & 0 deletions test/text_widget_resilience_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
Expand Down Expand Up @@ -174,6 +175,68 @@ void main() {
expect(container.read(textProvider).single.text, 'edited');
});

testWidgets('semantics text changes enter the draft and commit pipeline',
(tester) async {
final semanticsHandle = tester.ensureSemantics();
final container = createContainer();
container.read(textProvider.notifier).fromHive([
PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'before',
]);

await tester.pumpWidget(buildTextHarness(container));

await tester.tap(find.byType(TextField));
await tester.pump();
final placedText = find.semantics.byLabel('Placed text');
expect(placedText, findsOneWidget);
tester.semantics.performAction(
placedText,
SemanticsAction.setText,
args: 'edited through semantics',
);
await tester.pump();

expect(
container.read(textDraftProvider),
{'text-1': 'edited through semantics'},
);
expect(container.read(textProvider).single.text, 'before');

final field = tester.widget<TextField>(find.byType(TextField));
field.focusNode!.unfocus();
await tester.pump();

expect(container.read(textDraftProvider), isEmpty);
expect(
container.read(textProvider).single.text,
'edited through semantics',
);
semanticsHandle.dispose();
});

testWidgets('controller text changes enter the draft pipeline',
(tester) async {
final container = createContainer();
container.read(textProvider.notifier).fromHive([
PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'before',
]);

await tester.pumpWidget(buildTextHarness(container));

final field = tester.widget<TextField>(find.byType(TextField));
field.controller!.value = const TextEditingValue(
text: 'edited through controller',
selection: TextSelection.collapsed(offset: 25),
);
await tester.pump();

expect(
container.read(textDraftProvider),
{'text-1': 'edited through controller'},
);
expect(container.read(textProvider).single.text, 'before');
});

testWidgets(
'drag start commits the draft before the drag lifecycle swaps children',
(tester) async {
Expand Down
Loading
Loading