Repository: wiredashio/wiredash-sdk

Issue:
Feedback was sent twice
User also painted on the screenshot identically, so it's impossible that he sent it twice manually.

Perhaps Wiredash retries sending it if it didn't work, which could lead to it being sent twice?

Only happened once though and no big deal.



Fix patch:
diff --git a/lib/src/common/device_info/device_info.dart b/lib/src/common/device_info/device_info.dart
index f48040f5..a16ddaa5 100644
--- a/lib/src/common/device_info/device_info.dart
+++ b/lib/src/common/device_info/device_info.dart
@@ -1,7 +1,8 @@
-import 'dart:ui' show Brightness, WindowPadding;
+import 'dart:ui' show Brightness;
 
 import 'package:flutter/foundation.dart';
 import 'package:flutter/widgets.dart';
+import 'package:wiredash/src/feedback/data/pending_feedback_item.dart';
 
 export 'dart:ui' show Brightness;
 
@@ -25,7 +26,7 @@ class FlutterDeviceInfo {
   /// Area not covered with system UI
   ///
   /// https://api.flutter.dev/flutter/dart-ui/FlutterView/padding.html
-  final WindowPadding padding;
+  final WiredashWindowPadding padding;
 
   /// The dimensions of the rectangle into which the scene rendered in this
   /// view will be drawn on the screen, in physical pixels.
@@ -71,13 +72,13 @@ class FlutterDeviceInfo {
   final double textScaleFactor;
 
   /// https://api.flutter.dev/flutter/dart-ui/FlutterView/viewInsets.html
-  final WindowPadding viewInsets;
+  final WiredashWindowPadding viewInsets;
 
   /// Area where Android does not intercept i.e. for the back button gesture
   /// (swipe from the side of the screen)
   ///
   /// https://api.flutter.dev/flutter/dart-ui/FlutterView/systemGestureInsets.html
-  final WindowPadding gestureInsets;
+  final WiredashWindowPadding gestureInsets;
 
   /// When in web, the full user agent String of the browser
   ///
@@ -105,7 +106,7 @@ class FlutterDeviceInfo {
     String? deviceId,
     String? platformLocale,
     List<String>? platformSupportedLocales,
-    WindowPadding? padding,
+    WiredashWindowPadding? padding,
     Size? physicalSize,
     Rect? physicalGeometry,
     double? pixelRatio,
@@ -113,10 +114,10 @@ class FlutterDeviceInfo {
     String? platformOSVersion,
     String? platformVersion,
     double? textScaleFactor,
-    WindowPadding? viewInsets,
+    WiredashWindowPadding? viewInsets,
     String? userAgent,
     Brightness? platformBrightness,
-    WindowPadding? gestureInsets,
+    WiredashWindowPadding? gestureInsets,
   }) {
     return FlutterDeviceInfo(
       platformLocale: platformLocale ?? this.platformLocale,
diff --git a/lib/src/common/device_info/device_info_generator.dart b/lib/src/common/device_info/device_info_generator.dart
index 2f1ad517..59b12ca2 100644
--- a/lib/src/common/device_info/device_info_generator.dart
+++ b/lib/src/common/device_info/device_info_generator.dart
@@ -7,6 +7,7 @@ import 'package:wiredash/src/common/device_info/device_info.dart';
 import 'package:wiredash/src/common/device_info/device_info_generator_stub.dart'
     if (dart.library.html) 'package:wiredash/src/common/device_info/dart_html_device_info_generator.dart'
     if (dart.library.io) 'package:wiredash/src/common/device_info/dart_io_device_info_generator.dart';
+import 'package:wiredash/src/feedback/data/pending_feedback_item.dart';
 
 abstract class DeviceInfoGenerator {
   /// Loads a [DeviceInfoGenerator] based on the environment by calling the
@@ -35,14 +36,15 @@ abstract class DeviceInfoGenerator {
       platformLocale: windowLocale().toLanguageTag(),
       platformSupportedLocales:
           windowLocales().map((it) => it.toLanguageTag()).toList(),
-      padding: window.padding,
+      padding: WiredashWindowPadding.fromWindowPadding(window.padding),
       physicalSize: window.physicalSize,
       physicalGeometry: window.physicalGeometry,
       pixelRatio: window.devicePixelRatio,
       textScaleFactor: window.textScaleFactor,
-      viewInsets: window.viewInsets,
+      viewInsets: WiredashWindowPadding.fromWindowPadding(window.viewInsets),
       platformBrightness: window.platformBrightness,
-      gestureInsets: window.systemGestureInsets,
+      gestureInsets:
+          WiredashWindowPadding.fromWindowPadding(window.systemGestureInsets),
     );
   }
 
diff --git a/lib/src/feedback/data/pending_feedback_item.dart b/lib/src/feedback/data/pending_feedback_item.dart
index 6bab4b0b..5849458f 100644
--- a/lib/src/feedback/data/pending_feedback_item.dart
+++ b/lib/src/feedback/data/pending_feedback_item.dart
@@ -300,7 +300,8 @@ extension _SerializeDeviceInfo on FlutterDeviceInfo {
   }
 }
 
-/// [WindowPadding] doesn't offer a public constructor
+/// [WindowPadding] doesn't offer a public constructor and doesn't implement
+/// ==() and hashCode
 class WiredashWindowPadding implements WindowPadding {
   const WiredashWindowPadding({
     required this.left,
@@ -318,6 +319,15 @@ class WiredashWindowPadding implements WindowPadding {
     );
   }
 
+  factory WiredashWindowPadding.fromWindowPadding(WindowPadding padding) {
+    return WiredashWindowPadding(
+      left: padding.left,
+      top: padding.top,
+      right: padding.right,
+      bottom: padding.bottom,
+    );
+  }
+
   /// The distance from the left edge to the first unpadded pixel, in physical
   /// pixels.
   @override
diff --git a/lib/src/feedback/data/pending_feedback_item_storage.dart b/lib/src/feedback/data/pending_feedback_item_storage.dart
index ed355d64..33e916ac 100644
--- a/lib/src/feedback/data/pending_feedback_item_storage.dart
+++ b/lib/src/feedback/data/pending_feedback_item_storage.dart
@@ -146,10 +146,13 @@ class PendingFeedbackItemStorage {
       list.remove(removed);
       list.add(item);
 
-      final oldDiskAttachments = removed.feedbackItem.attachments
-          .where((element) => element.file.isOnDisk);
+      final List<PersistedAttachment> oldDiskAttachments = removed
+          .feedbackItem.attachments
+          .where((element) => element.file.isOnDisk)
+          .toList();
       final newDiskAttachments = item.feedbackItem.attachments
-          .where((element) => element.file.isOnDisk);
+          .where((element) => element.file.isOnDisk)
+          .toList();
       final uploaded = oldDiskAttachments
           .where((element) => !newDiskAttachments.contains(element))
           .toList();
diff --git a/lib/src/feedback/data/retrying_feedback_submitter.dart b/lib/src/feedback/data/retrying_feedback_submitter.dart
index 2850b2b6..1c020eb5 100644
--- a/lib/src/feedback/data/retrying_feedback_submitter.dart
+++ b/lib/src/feedback/data/retrying_feedback_submitter.dart
@@ -137,13 +137,13 @@ class RetryingFeedbackSubmitter implements FeedbackSubmitter {
           PersistedAttachment oldAttachment,
           PersistedAttachment? update,
         ) async {
-          final atts = item.feedbackItem.attachments.toList()
+          final atts = copy.feedbackItem.attachments.toList()
             ..remove(oldAttachment);
           if (update != null) {
             atts.add(update);
           }
-          copy = item.copyWith(
-            feedbackItem: item.feedbackItem.copyWith(attachments: atts),
+          copy = copy.copyWith(
+            feedbackItem: copy.feedbackItem.copyWith(attachments: atts),
           );
 
           await _pendingFeedbackItemStorage.updatePendingItem(copy);
@@ -191,7 +191,10 @@ class RetryingFeedbackSubmitter implements FeedbackSubmitter {
         await _api.sendFeedback(copy.feedbackItem);
 
         // ignore: avoid_print
-        print('Feedback submitted ✌️ ${item.feedbackItem.message}');
+        print(
+          'Feedback with ${copy.feedbackItem.attachments.length} screenshots submitted ✌️\n'
+          'message: ${copy.feedbackItem.message}',
+        );
         await _pendingFeedbackItemStorage.clearPendingItem(item.id);
         break;
       } on UnauthenticatedWiredashApiException catch (e, stack) {
diff --git a/lib/src/feedback/ui/steps/step_3_screenshot_overview.dart b/lib/src/feedback/ui/steps/step_3_screenshot_overview.dart
index 27b26884..104112d2 100644
--- a/lib/src/feedback/ui/steps/step_3_screenshot_overview.dart
+++ b/lib/src/feedback/ui/steps/step_3_screenshot_overview.dart
@@ -123,7 +123,7 @@ class Step3WithGallery extends StatelessWidget {
                   for (final att in context.feedbackModel.attachments)
                     Padding(
                       padding: const EdgeInsets.only(right: 16),
-                      child: _Attachment(attachment: att),
+                      child: AttachmentPreview(attachment: att),
                     ),
                   if (context.feedbackModel.attachments.length < 3)
                     const _NewAttachment(),
@@ -154,8 +154,8 @@ class Step3WithGallery extends StatelessWidget {
   }
 }
 
-class _Attachment extends StatelessWidget {
-  const _Attachment({
+class AttachmentPreview extends StatelessWidget {
+  const AttachmentPreview({
     Key? key,
     required this.attachment,
   }) : super(key: key);


Test patch:
diff --git a/test/feedback/data/pending_feedback_item_storage_test.dart b/test/feedback/data/pending_feedback_item_storage_test.dart
index a3cffe44..2ea833ed 100644
--- a/test/feedback/data/pending_feedback_item_storage_test.dart
+++ b/test/feedback/data/pending_feedback_item_storage_test.dart
@@ -282,6 +282,53 @@ void main() {
         isTrue,
       );
     });
+
+    test('updateItem removes file from disk', () async {
+      final first = createFeedback(
+        attachments: [
+          PersistedAttachment.screenshot(
+            file: FileDataEventuallyOnDisk.inMemory(kTransparentImage),
+            deviceInfo: testDeviceInfo,
+          ),
+        ],
+      );
+      final firstPending = await storage.addPendingItem(first);
+      expect(firstPending.id, '1');
+      expect(filesOnDisk(), ['0.png']);
+
+      final second = createFeedback(
+        attachments: [
+          PersistedAttachment.screenshot(
+            file: FileDataEventuallyOnDisk.inMemory(kTransparentImage),
+            deviceInfo: testDeviceInfo,
+          ),
+        ],
+      );
+      final secondPending = await storage.addPendingItem(second);
+      expect(secondPending.id, '3');
+
+      expect(filesOnDisk(), ['0.png', '2.png']);
+      expect(
+        await storage.retrieveAllPendingItems(),
+        [firstPending, secondPending],
+      );
+
+      // replace attachment with after upload
+      final update = firstPending.copyWith(
+        feedbackItem: firstPending.feedbackItem.copyWith(
+          attachments: [
+            PersistedAttachment.screenshot(
+              file: FileDataEventuallyOnDisk.uploaded(AttachmentId('1')),
+              deviceInfo: firstPending.feedbackItem.deviceInfo,
+            )
+          ],
+        ),
+      );
+      await storage.updatePendingItem(update);
+
+      expect(await storage.retrieveAllPendingItems(), [secondPending, update]);
+      expect(filesOnDisk(), ['2.png']);
+    });
   });
 }
 
diff --git a/test/feedback_flow_test.dart b/test/feedback_flow_test.dart
index 8fe1e051..a979c533 100644
--- a/test/feedback_flow_test.dart
+++ b/test/feedback_flow_test.dart
@@ -3,6 +3,7 @@
 import 'package:flutter_test/flutter_test.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 import 'package:wiredash/src/feedback/data/persisted_feedback_item.dart';
+import 'package:wiredash/src/feedback/ui/steps/step_3_screenshot_overview.dart';
 import 'package:wiredash/wiredash.dart';
 
 import 'util/mock_api.dart';
@@ -58,6 +59,34 @@ void main() {
       expect(submittedFeedback.attachments, hasLength(1));
     });
 
+    testWidgets('Send feedback with multiple screenshots', (tester) async {
+      final robot = await WiredashTestRobot.launchApp(tester);
+      final mockApi = MockWiredashApi();
+      robot.mockWiredashApi(mockApi);
+
+      await robot.openWiredash();
+      await robot.enterFeedbackMessage('test message');
+      await robot.goToNextStep();
+      await robot.enterScreenshotMode();
+      await robot.takeScreenshot();
+      await robot.confirmDrawing();
+      await robot.enterScreenshotMode();
+      await robot.takeScreenshot();
+      await robot.confirmDrawing();
+      expect(find.byType(AttachmentPreview), findsNWidgets(2));
+      await robot.goToNextStep();
+      await robot.submitFeedback();
+      await tester.waitUntil(
+        find.text('Thanks for your feedback!'),
+        findsOneWidget,
+      );
+      final latestCall = mockApi.sendFeedbackInvocations.latest;
+      final submittedFeedback = latestCall[0] as PersistedFeedbackItem?;
+      expect(submittedFeedback, isNotNull);
+      expect(submittedFeedback!.message, 'test message');
+      expect(submittedFeedback.attachments, hasLength(2));
+    });
+
     testWidgets('Send feedback with labels', (tester) async {
       final robot = await WiredashTestRobot.launchApp(
         tester,
diff --git a/test/util/mock_api.dart b/test/util/mock_api.dart
index bea1f2b4..e989b228 100644
--- a/test/util/mock_api.dart
+++ b/test/util/mock_api.dart
@@ -24,6 +24,8 @@ class MockWiredashApi implements WiredashApi {
     String? filename,
     MediaType? contentType,
   }) async {
-    return AttachmentId(Random().nextDouble().toString());
+    return AttachmentId(
+      Random().nextDouble().toString().replaceFirst('0.', ''),
+    );
   }
 }
diff --git a/test/util/robot.dart b/test/util/robot.dart
index eec027f9..e8479103 100644
--- a/test/util/robot.dart
+++ b/test/util/robot.dart
@@ -206,8 +206,15 @@ class WiredashTestRobot {
 
   Future<void> enterScreenshotMode() async {
     expect(find.byType(Step3ScreenshotOverview), findsOneWidget);
-    expect(find.byType(Step3NotAttachments), findsOneWidget);
-    await tester.tap(find.text('Add screenshot'));
+    final noAttachemntsResult =
+        find.byType(Step3NotAttachments).evaluate().toList();
+    if (noAttachemntsResult.isNotEmpty) {
+      expect(find.byType(Step3NotAttachments), findsOneWidget);
+      await tester.tap(find.text('Add screenshot'));
+    } else {
+      expect(find.byType(Step3WithGallery), findsOneWidget);
+      await tester.tap(find.byIcon(Wirecons.plus), warnIfMissed: false);
+    }
 
     await tester.waitUntil(find.byType(ScreenshotBar), findsOneWidget);
     await tester.waitUntil(find.byIcon(Wirecons.camera), findsOneWidget);