diff --git a/.github/workflows/test-and-lint.yaml b/.github/workflows/test-and-lint.yaml index 50d1bfcf..ed7f70be 100644 --- a/.github/workflows/test-and-lint.yaml +++ b/.github/workflows/test-and-lint.yaml @@ -47,6 +47,7 @@ jobs: api-level: 30 arch: x86_64 profile: pixel_7_pro + enable-hw-keyboard: true script: | cd alfie_flutter && flutter test integration_test --tags=smoke diff --git a/alfie_flutter/android/app/src/main/AndroidManifest.xml b/alfie_flutter/android/app/src/main/AndroidManifest.xml index 8efc80a9..77ff4c0a 100644 --- a/alfie_flutter/android/app/src/main/AndroidManifest.xml +++ b/alfie_flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ getRouterContext(WidgetTester tester) async { + final scaffoldFinder = find.byType(Scaffold); + if (scaffoldFinder.evaluate().isNotEmpty) { + return tester.element(scaffoldFinder.first); + } else { + final routerFinder = find.byType(Router); + if (routerFinder.evaluate().isNotEmpty) { + return tester.element(routerFinder.first); + } + } + throw Exception('No router context found'); + } + + Future setupAppWithBagItems(WidgetTester tester) async { + // Pump the app widget + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const MainApp()), + ); + await tester.pumpAndSettle(); + + // Navigate to bag screen via the bottom navigation bar + final bagTab = find.text('Bag'); + expect(bagTab, findsOneWidget); + await tester.tap(bagTab); + await tester.pumpAndSettle(); + } + + group('Successful Checkout Flow', () { + testWidgets('User can proceed through checkout successfully', ( + WidgetTester tester, + ) async { + await setupAppWithBagItems(tester); + + // Continue from bag into the checkout funnel + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('CONTINUE AS GUEST')); + await tester.pumpAndSettle(); + + // Should be on contact information screen + expect(find.text('Contact Info'), findsOneWidget); + + // Fill contact information using Explicit Keys + await tester.enterText( + find.byKey(const Key('contact_first_name_field')), + 'John', + ); + await tester.enterText( + find.byKey(const Key('contact_last_name_field')), + 'Doe', + ); + await tester.enterText( + find.byKey(const Key('contact_email_field')), + 'john.doe@example.com', + ); + await tester.enterText( + find.byKey(const Key('contact_phone_field')), + '+1234567890', + ); + + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.widgetWithText(ElevatedButton, 'Continue').first, + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + // Should be on delivery information screen + expect(find.text('Delivery Information'), findsOneWidget); + + // Fill delivery address using Explicit Keys + await tester.enterText( + find.byKey(const Key('delivery_country_field')), + 'USA', + ); + await tester.enterText( + find.byKey(const Key('delivery_postal_code_field')), + '12345', + ); + await tester.enterText( + find.byKey(const Key('delivery_city_field')), + 'New York', + ); + await tester.enterText( + find.byKey(const Key('delivery_street_field')), + '123 Main St', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + // Should be on checkout summary screen + expect(find.text('Checkout'), findsOneWidget); + + // Navigate to delivery method from checkout + await tester.tap(find.text('Delivery Method')); + await tester.pumpAndSettle(); + + // Should be on delivery method screen + expect(find.text('Delivery Method'), findsOneWidget); + + await tester.tap(find.text('Standard Delivery')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Payment Method')); + await tester.pumpAndSettle(); + + // Should be on payment method screen + expect(find.text('Payment'), findsOneWidget); + + // Add new card + await tester.tap(find.text('Add new card')); + await tester.pumpAndSettle(); + + // Fill card details in modal order using Explicit Keys + await tester.enterText( + find.byKey(const Key('card_number_field')), + '4111111111111111', + ); + await tester.enterText( + find.byKey(const Key('card_name_field')), + 'John Doe', + ); + await tester.enterText( + find.byKey(const Key('card_expiry_field')), + '12/30', + ); + await tester.enterText(find.byKey(const Key('card_cvv_field')), '123'); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.widgetWithText(ElevatedButton, 'Continue').first, + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + // Should be back on payment method screen with card added + expect(find.textContaining('1111'), findsOneWidget); + + // Return to checkout summary with the saved payment method + await tester.ensureVisible(find.byIcon(AppIcons.back)); + await tester.tap(find.byIcon(AppIcons.back)); + await tester.pumpAndSettle(); + + // Should be on checkout screen + expect(find.text('Checkout'), findsOneWidget); + expect(find.textContaining('1111'), findsOneWidget); + + // Verify order summary and total + expect(find.textContaining('\$'), findsWidgets); + + // Place order + await tester.tap( + find.widgetWithText(ElevatedButton, 'Place Order').first, + ); + await tester.pumpAndSettle(); + + // Should be on order confirmation screen + expect(find.text('Thank you!'), findsOneWidget); + expect(find.text('ORDER NUMBER #1A2B3C4D'), findsOneWidget); + }, tags: ['smoke']); + + testWidgets('Order confirmation screen shows correct details', ( + WidgetTester tester, + ) async { + await setupAppWithBagItems(tester); + + // Quick navigation to order confirmation + final checkoutVM = container.read(checkoutViewModelProvider.notifier); + + // Set up complete checkout state + checkoutVM.startGuestSession(); + checkoutVM.setUserData( + const UserData( + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phoneNumber: '+1234567890', + ), + ); + checkoutVM.setDeliveryAddress( + const Address( + country: 'USA', + postalCode: '12345', + city: 'New York', + street: '123 Main St', + ), + ); + checkoutVM.setBillingAddress( + const Address( + country: 'USA', + postalCode: '12345', + city: 'New York', + street: '123 Main St', + ), + ); + checkoutVM.setDeliveryMethod(DeliveryMethod.standard); + checkoutVM.setPaymentMethod( + const PaymentCard( + type: PaymentCardType.visa, + number: '4111111111111111', + name: 'John Doe', + month: 12, + year: 2025, + cvv: 123, + ), + ); + + // Navigate to checkout + GoRouter.of(await getRouterContext(tester)).go(AppRoute.checkout.path); + await tester.pumpAndSettle(); + + // Place order + await tester.tap( + find.widgetWithText(ElevatedButton, 'Place Order').first, + ); + await tester.pumpAndSettle(); + + // Verify order confirmation + expect(find.text('Thank you!'), findsOneWidget); + expect(find.textContaining('ORDER NUMBER'), findsOneWidget); + expect(find.textContaining('email@email.com'), findsOneWidget); + }, tags: ['smoke']); + }); + + group('Error Scenarios', () { + testWidgets('Empty required fields show validation errors', ( + WidgetTester tester, + ) async { + await setupAppWithBagItems(tester); + + // Navigate to contact information + GoRouter.of( + await getRouterContext(tester), + ).go(AppRoute.contactInformation.fullPath); + await tester.pumpAndSettle(); + + // Try to continue without filling fields + final contactContinueButton = find + .widgetWithText(ElevatedButton, 'Continue') + .first; + await tester.tap(contactContinueButton); + await tester.pumpAndSettle(); + + // Should still be on contact info screen with invalid form state + expect(find.text('Contact Info'), findsOneWidget); + expect( + tester + .widget( + find.widgetWithText(ElevatedButton, 'Continue').first, + ) + .enabled, + isFalse, + ); + }); + + testWidgets('Invalid payment details show errors', ( + WidgetTester tester, + ) async { + await setupAppWithBagItems(tester); + + // Navigate to checkout and quickly get to payment + final checkoutVM = container.read(checkoutViewModelProvider.notifier); + checkoutVM.startGuestSession(); + checkoutVM.setUserData( + const UserData( + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phoneNumber: '+1234567890', + ), + ); + checkoutVM.setDeliveryAddress( + const Address( + country: 'USA', + postalCode: '12345', + city: 'New York', + street: '123 Main St', + ), + ); + checkoutVM.setBillingAddress( + const Address( + country: 'USA', + postalCode: '12345', + city: 'New York', + street: '123 Main St', + ), + ); + checkoutVM.setDeliveryMethod(DeliveryMethod.standard); + + // Navigate to payment method + GoRouter.of( + await getRouterContext(tester), + ).go(AppRoute.paymentMethod.fullPath); + await tester.pumpAndSettle(); + + // Try to add invalid card + await tester.tap(find.text('Add new card')); + await tester.pumpAndSettle(); + + // Enter invalid card number using Explicit Keys + await tester.enterText( + find.byKey(const Key('card_number_field')), + '1234', + ); + await tester.pumpAndSettle(); + await tester.ensureVisible( + find.widgetWithText(ElevatedButton, 'Continue').first, + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + // Should show validation error + expect(find.text('Card is invalid'), findsOneWidget); + }); + + testWidgets('Submit button disabled when form is invalid', ( + WidgetTester tester, + ) async { + await setupAppWithBagItems(tester); + + // Navigate to checkout + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + // Start guest session + await tester.tap(find.text('CONTINUE AS GUEST')); + await tester.pumpAndSettle(); + expect(find.text('Contact Info'), findsOneWidget); + + // Check that continue button is disabled initially + final continueButton = find + .widgetWithText(ElevatedButton, 'Continue') + .first; + expect(tester.widget(continueButton).enabled, isFalse); + + // Fill some fields but not all using Explicit Keys + await tester.enterText( + find.byKey(const Key('contact_email_field')), + 'test@example.com', + ); + await tester.pumpAndSettle(); + + // Button should still be disabled + expect(tester.widget(continueButton).enabled, isFalse); + + // Fill all required fields using Explicit Keys + await tester.enterText( + find.byKey(const Key('contact_first_name_field')), + 'John', + ); + await tester.enterText( + find.byKey(const Key('contact_last_name_field')), + 'Doe', + ); + await tester.enterText( + find.byKey(const Key('contact_phone_field')), + '+1234567890', + ); + await tester.pumpAndSettle(); + + // Button should now be enabled + expect(tester.widget(continueButton).enabled, isTrue); + }); + }); + + group('Navigation and UI State', () { + testWidgets('Proper navigation between checkout steps', ( + WidgetTester tester, + ) async { + await setupAppWithBagItems(tester); + + // Start checkout flow + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + expect(find.text('Identification'), findsOneWidget); + + // Guest session + await tester.tap(find.text('CONTINUE AS GUEST')); + await tester.pumpAndSettle(); + expect(find.text('Contact Info'), findsOneWidget); + + // Back navigation + await tester.tap(find.byIcon(AppIcons.back)); + await tester.pumpAndSettle(); + expect(find.text('Identification'), findsOneWidget); + }); + + testWidgets('UI state updates correctly during checkout', ( + WidgetTester tester, + ) async { + await setupAppWithBagItems(tester); + + // Navigate to checkout + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + // Start guest session + await tester.tap(find.text('CONTINUE AS GUEST')); + await tester.pumpAndSettle(); + + // Fill contact info using Explicit Keys + await tester.enterText( + find.byKey(const Key('contact_first_name_field')), + 'John', + ); + await tester.enterText( + find.byKey(const Key('contact_last_name_field')), + 'Doe', + ); + await tester.enterText( + find.byKey(const Key('contact_email_field')), + 'john@example.com', + ); + await tester.enterText( + find.byKey(const Key('contact_phone_field')), + '+1234567890', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + // Fill delivery info using Explicit Keys + await tester.enterText( + find.byKey(const Key('delivery_country_field')), + 'USA', + ); + await tester.enterText( + find.byKey(const Key('delivery_postal_code_field')), + '12345', + ); + await tester.enterText( + find.byKey(const Key('delivery_city_field')), + 'New York', + ); + await tester.enterText( + find.byKey(const Key('delivery_street_field')), + '123 Main St', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + // Select delivery method + await tester.tap(find.text('Delivery Method')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Standard Delivery')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue').first); + await tester.pumpAndSettle(); + + // On checkout screen, verify state is reflected + expect(find.text('Ship to'), findsOneWidget); + expect(find.textContaining('USA'), findsWidgets); + expect(find.text('Delivery Method'), findsOneWidget); + expect(find.textContaining('Standard Delivery'), findsOneWidget); + }); + }); + }); +} diff --git a/alfie_flutter/integration_test/plp_fps_results.csv b/alfie_flutter/integration_test/plp_fps_results.csv new file mode 100644 index 00000000..e69de29b diff --git a/alfie_flutter/integration_test/product_listing_performance_test.dart b/alfie_flutter/integration_test/product_listing_performance_test.dart new file mode 100644 index 00000000..87c5dde4 --- /dev/null +++ b/alfie_flutter/integration_test/product_listing_performance_test.dart @@ -0,0 +1,190 @@ +import 'dart:developer'; +import 'package:alfie_flutter/data/models/environment.dart'; +import 'package:alfie_flutter/data/services/persistent_storage_service.dart'; +import 'package:alfie_flutter/main.dart'; +import 'package:alfie_flutter/ui/core/ui/product_card/vertical_product_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:integration_test/integration_test.dart'; + +Finder _findPlpScrollView() => + _findScrollable(find.byKey(const Key('plp_scroll_view'))); +Finder _findPdpScrollView() => + _findScrollable(find.byKey(const Key('pdp_scroll_view'))); + +Finder _findScrollable(Finder customScrollView) => find + .descendant(of: customScrollView, matching: find.byType(Scrollable)) + .first; + +Finder _findPlpProductCard({bool useFirst = true}) { + final card = find.byType(VerticalProductCard).hitTestable(); + return useFirst ? card.first : card.last; +} + +Finder _findPdpWishlistButton() => find.byKey(const Key('pdp_wishlist_button')); +Finder _findPdpAddToBagButton() => + find.byKey(const Key('pdp_add_to_bag_button')); +Finder _findPdpBackButton() => find.byKey(const Key('pdp_back_button')); + +Future _scrollUntilVisible( + WidgetTester tester, + Finder target, + Finder scrollable, { + double scrollOffset = 250.0, +}) async { + await tester.scrollUntilVisible(target, scrollOffset, scrollable: scrollable); + await tester.pumpAndSettle(); + expect(target, findsOneWidget); +} + +Future _scrollAndTap( + WidgetTester tester, + Finder target, + Finder scrollable, { + double scrollOffset = 250.0, +}) async { + await _scrollUntilVisible( + tester, + target, + scrollable, + scrollOffset: scrollOffset, + ); + await tester.tap(target); + await tester.pumpAndSettle(); +} + +Future _performScrollSteps( + WidgetTester tester, + Finder scrollable, + int count, { + Offset offset = const Offset(0, -220), + Duration pause = const Duration(milliseconds: 250), +}) async { + for (var i = 0; i < count; i++) { + await tester.drag(scrollable, offset); + await tester.pump(pause); + } +} + +Future _inspectProductAndExecuteAction( + WidgetTester tester, + Finder plpScrollView, + Future Function() pdpAction, { + bool useFirstProductCard = true, +}) async { + // 1. Find and open the designated product card on the PLP + final productCard = _findPlpProductCard(useFirst: useFirstProductCard); + await _scrollAndTap(tester, productCard, plpScrollView); + + // 2. Execute the custom PDP behavior (Wishlist or Add to Bag) + await pdpAction(); + + // 3. Return to the PLP securely via back button navigation + final pdpScrollView = _findPdpScrollView(); + await _scrollAndTap( + tester, + _findPdpBackButton(), + pdpScrollView, + scrollOffset: -300, + ); +} + +Future _addProductToWishlist( + WidgetTester tester, { + bool useFirstProductCard = true, +}) async { + await _inspectProductAndExecuteAction(tester, _findPlpScrollView(), () async { + final pdpScrollView = _findPdpScrollView(); + await _scrollAndTap(tester, _findPdpWishlistButton(), pdpScrollView); + }, useFirstProductCard: useFirstProductCard); +} + +Future _addProductToBag( + WidgetTester tester, { + bool useFirstProductCard = true, +}) async { + await _inspectProductAndExecuteAction(tester, _findPlpScrollView(), () async { + final pdpScrollView = _findPdpScrollView(); + await _scrollAndTap(tester, _findPdpAddToBagButton(), pdpScrollView); + }, useFirstProductCard: useFirstProductCard); +} + +void main() { + // 1. Enable timeline collection explicitly + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + group('PLP Performance Test', () { + testWidgets('Measure average frame rate during scrolling', ( + WidgetTester tester, + ) async { + await initHiveForFlutter(); + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: [SystemUiOverlay.top], + ); + + final container = ProviderContainer(); + await dotenv.load(fileName: container.read(environmentProvider).fileName); + + final persistentStorageService = container.read( + persistentStorageServiceProvider, + ); + await persistentStorageService.init(); + + await tester.pumpWidget(const ProviderScope(child: MainApp())); + await tester.pump(); + + log( + "PAUSING FOR 6 SECONDS: Please accept the local network permission popup now...", + ); + await Future.delayed(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + + final storeTab = find.text('Store'); + expect(storeTab, findsOneWidget); + await tester.tap(storeTab); + await tester.pumpAndSettle(); + + final plpScrollView = _findPlpScrollView(); + expect(plpScrollView, findsOneWidget); + + await tester.pumpAndSettle(const Duration(seconds: 2)); + + // =========== START OF TIMELINE-RECORDED ACTIONS =========== + + // 2. Profile the actions using traceAction + await binding.traceAction(() async { + for (int i = 0; i < 5; i++) { + // 1. SCROLL + await _performScrollSteps(tester, plpScrollView, 6); + + // 2. add the first product to the wishlist, then return to the PLP. + await _addProductToWishlist(tester, useFirstProductCard: i % 2 != 0); + + // 5. Scroll a bit more and open the second product. + await _performScrollSteps(tester, plpScrollView, 4); + + await _addProductToBag(tester, useFirstProductCard: i % 2 == 0); + + // 8. Perform a final scroll pass to continue exploring the list. + await _performScrollSteps( + tester, + plpScrollView, + 8, + offset: const Offset(0, 220), + ); + } + await tester.pumpAndSettle(); + }, reportKey: 'plp_scroll_timeline'); + + log('Timeline capture finished.'); + }); + }); +} diff --git a/alfie_flutter/integration_test/snack.dart b/alfie_flutter/integration_test/snack.dart new file mode 100644 index 00000000..72499aa4 --- /dev/null +++ b/alfie_flutter/integration_test/snack.dart @@ -0,0 +1,158 @@ +import 'dart:developer'; +import 'package:alfie_flutter/data/models/environment.dart'; +import 'package:alfie_flutter/data/services/persistent_storage_service.dart'; +import 'package:alfie_flutter/main.dart'; +import 'package:alfie_flutter/ui/core/ui/product_card/vertical_product_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:integration_test/integration_test.dart'; + +// Reuse your exact element finders +Finder _findPlpScrollView() => + _findScrollable(find.byKey(const Key('plp_scroll_view'))); +Finder _findPdpScrollView() => + _findScrollable(find.byKey(const Key('pdp_scroll_view'))); +Finder _findScrollable(Finder customScrollView) => find + .descendant(of: customScrollView, matching: find.byType(Scrollable)) + .first; + +Finder _findPlpProductCard({bool useFirst = true}) { + final card = find.byType(VerticalProductCard).hitTestable(); + return useFirst ? card.first : card.last; +} + +Finder _findPdpAddToBagButton() => + find.byKey(const Key('pdp_add_to_bag_button')); +Finder _findPdpBackButton() => find.byKey(const Key('pdp_back_button')); + +Future _scrollAndTap( + WidgetTester tester, + Finder target, + Finder scrollable, { + double scrollOffset = 250.0, +}) async { + await tester.scrollUntilVisible(target, scrollOffset, scrollable: scrollable); + await tester.pumpAndSettle(); + await tester.tap(target); + await tester.pumpAndSettle(); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + group('Usability Latency - Action Feedback QAS', () { + const int totalTrials = 35; + final List recordedLatenciesMs = []; + + testWidgets('Measure real app Snackbar latency via PDP navigation loop', ( + WidgetTester tester, + ) async { + // 1. App Environment Bootstrapping + await initHiveForFlutter(); + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: [SystemUiOverlay.top], + ); + + final container = ProviderContainer(); + await dotenv.load(fileName: container.read(environmentProvider).fileName); + await container.read(persistentStorageServiceProvider).init(); + + await tester.pumpWidget(const ProviderScope(child: MainApp())); + await tester.pumpAndSettle(); + + log("PAUSING FOR 6 SECONDS: Handle initial network alerts..."); + await Future.delayed(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + + // Go to Store + final storeTab = find.text('Store'); + expect(storeTab, findsOneWidget); + await tester.tap(storeTab); + await tester.pumpAndSettle(); + + final plpScrollView = _findPlpScrollView(); + final Finder snackBarFinder = find.byType(SnackBar); + + // 2. Continuous Latency Sampling Loop + // 2. Continuous Latency Sampling Loop + for (int i = 0; i < totalTrials; i++) { + // Step A: Navigate from PLP into the PDP + final productCard = _findPlpProductCard(useFirst: (i % 2 == 0)); + await _scrollAndTap(tester, productCard, plpScrollView); + + final pdpScrollView = _findPdpScrollView(); + final Finder addToBagButton = _findPdpAddToBagButton(); + + // New Step: Scroll down the PDP until the button is fully interactable + await tester.scrollUntilVisible( + addToBagButton, + 250.0, + scrollable: pdpScrollView, + ); + await tester.pumpAndSettle(); + expect(addToBagButton, findsOneWidget); + + final stopwatch = Stopwatch(); + + // Step B: Tap the button and isolate latency collection + // (Timer starts exactly when the touch event is dispatched) + await tester.tap(addToBagButton); + stopwatch.start(); + + bool isSnackBarRendered = false; + while (stopwatch.elapsedMilliseconds < 2500) { + await tester.pump(const Duration(milliseconds: 8)); + if (tester.any(snackBarFinder)) { + stopwatch.stop(); + isSnackBarRendered = true; + break; + } + } + + if (isSnackBarRendered) { + recordedLatenciesMs.add(stopwatch.elapsedMilliseconds); + log( + 'Trial [${i + 1}/$totalTrials] Latency: ${stopwatch.elapsedMilliseconds} ms', + ); + } else { + stopwatch.stop(); + fail('Feedback SnackBar missed 2.5s rendering window at trial: $i'); + } + + // Step C: Clear the Snackbar instantly to avoid bleed-over into next trials + ScaffoldMessenger.of(tester.element(addToBagButton)).clearSnackBars(); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); + + // Step D: Safely pop back out to the PLP to refresh context for the next cycle + await _scrollAndTap( + tester, + _findPdpBackButton(), + pdpScrollView, + scrollOffset: -300, + ); + } + + // 3. Output Data Stream for your Solution Analysis Chapter + log( + '\n========================================================================', + ); + log( + ' SUCCESSFULLY CAPTURED ACTION LATENCY NFR ', + ); + log( + '========================================================================', + ); + log('SAMPLE_SIZE_N = ${recordedLatenciesMs.length}'); + log('LATENCY_DATA_MS = $recordedLatenciesMs'); + log( + '========================================================================\n', + ); + }); + }); +} diff --git a/alfie_flutter/ios/Runner.xcodeproj/project.pbxproj b/alfie_flutter/ios/Runner.xcodeproj/project.pbxproj index 520b86f1..333c2cc2 100644 --- a/alfie_flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/alfie_flutter/ios/Runner.xcodeproj/project.pbxproj @@ -390,15 +390,19 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 74L475LDQT; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.alfieFlutter; + PRODUCT_BUNDLE_IDENTIFIER = com.mindera.alfie.flutter; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -570,15 +574,19 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 74L475LDQT; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.alfieFlutter; + PRODUCT_BUNDLE_IDENTIFIER = com.mindera.alfie.flutter; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -593,15 +601,19 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 74L475LDQT; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.alfieFlutter; + PRODUCT_BUNDLE_IDENTIFIER = com.mindera.alfie.flutter; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 00000000..f9432e9e Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 00000000..1b9033c5 Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000..cac5de88 Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000..a05977e3 Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 00000000..2c2fcfbd Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 00000000..9671473e Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 00000000..a3b32705 Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 00000000..f26a080b Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 00000000..ab23acc4 Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000..87f39384 Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 00000000..a2d2d028 Binary files /dev/null and b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fab..73d3b7f6 100644 --- a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"}]} \ No newline at end of file diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada47..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41e..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d933..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b009..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe730945..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773cd..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463a..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea2..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32ae..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba0..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf12..00000000 Binary files a/alfie_flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/alfie_flutter/ios/Runner/Info.plist b/alfie_flutter/ios/Runner/Info.plist index dc37dba2..cad858f3 100644 --- a/alfie_flutter/ios/Runner/Info.plist +++ b/alfie_flutter/ios/Runner/Info.plist @@ -1,85 +1,85 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Alfie Flutter - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - alfie_flutter - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLName - com.alfie - CFBundleURLSchemes - - app - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Alfie Flutter + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + alfie_flutter + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - FlutterSceneDelegate - UISceneStoryboardFile - Main - - - + CFBundleURLName + com.alfie + CFBundleURLSchemes + + app + - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + diff --git a/alfie_flutter/ios/Runner/Runner.entitlements b/alfie_flutter/ios/Runner/Runner.entitlements index b212e2bb..0c67376e 100644 --- a/alfie_flutter/ios/Runner/Runner.entitlements +++ b/alfie_flutter/ios/Runner/Runner.entitlements @@ -1,10 +1,5 @@ - - com.apple.developer.associated-domains - - applinks:alfie.com - - + diff --git a/alfie_flutter/lib/data/models/payment_card.dart b/alfie_flutter/lib/data/models/payment_card.dart index b916f8be..3a03031c 100644 --- a/alfie_flutter/lib/data/models/payment_card.dart +++ b/alfie_flutter/lib/data/models/payment_card.dart @@ -88,7 +88,13 @@ class PaymentCard { bool operator ==(Object other) { if (identical(this, other)) return true; - return other is PaymentCard && other.type == type && other.number == number; + return other is PaymentCard && + other.type == type && + other.number == number && + other.name == name && + other.month == month && + other.year == year && + other.cvv == cvv; } @override diff --git a/alfie_flutter/lib/routing/app_route.dart b/alfie_flutter/lib/routing/app_route.dart index 2a8dd042..4d64802c 100644 --- a/alfie_flutter/lib/routing/app_route.dart +++ b/alfie_flutter/lib/routing/app_route.dart @@ -36,7 +36,7 @@ enum AppRoute { ), // Sub-pages productDetail(path: 'product/:id'), - search(path: 'search'), + search(path: 'search', children: [productDetail]), auth(path: 'auth'), signIn(path: '/signIn'), diff --git a/alfie_flutter/lib/routing/router.dart b/alfie_flutter/lib/routing/router.dart index 449208f5..d5e3b2e6 100644 --- a/alfie_flutter/lib/routing/router.dart +++ b/alfie_flutter/lib/routing/router.dart @@ -67,8 +67,8 @@ final routerProvider = Provider((ref) { registry, AppRoute.checkout.name, redirect: (context, state) { - final isLoggedIn = authStateNotifier.value is RegisteredUser; - if (!isLoggedIn) { + final hasActiveUser = authStateNotifier.value != null; + if (!hasActiveUser) { return AppRoute.identification.fullPath; } return null; diff --git a/alfie_flutter/lib/ui/bag/view_model/bag_view_model.dart b/alfie_flutter/lib/ui/bag/view_model/bag_view_model.dart index 1c52983a..292c5388 100644 --- a/alfie_flutter/lib/ui/bag/view_model/bag_view_model.dart +++ b/alfie_flutter/lib/ui/bag/view_model/bag_view_model.dart @@ -1,6 +1,7 @@ import 'package:alfie_flutter/data/models/bag_item.dart'; import 'package:alfie_flutter/data/models/product.dart'; import 'package:alfie_flutter/data/repositories/bag_repository.dart'; +import 'package:alfie_flutter/ui/product_detail/view_model/product_detail_view_model.dart'; import 'package:alfie_flutter/ui/wishlist/view_model/wishlist_view_model.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -54,7 +55,9 @@ class BagViewModel extends Notifier> { /// Migrates the specified [product] to the user's saved wishlist. void addToWishlist(Product product) { - ref.read(wishlistViewModelProvider.notifier).addProduct(product); + ref + .read(productDetailViewModelProvider(product.id).notifier) + .addToWishlist(product); } /// Removes the specified [product] from the user's saved wishlist. diff --git a/alfie_flutter/lib/ui/checkout/view/add_new_card_modal.dart b/alfie_flutter/lib/ui/checkout/view/add_new_card_modal.dart index ff0bebbe..062de2e9 100644 --- a/alfie_flutter/lib/ui/checkout/view/add_new_card_modal.dart +++ b/alfie_flutter/lib/ui/checkout/view/add_new_card_modal.dart @@ -27,113 +27,138 @@ class AddNewCardModal extends HookConsumerWidget { final formKey = useMemoized(() => GlobalKey()); final card = useState(PaymentCard.invalid); - return Padding( - padding: const EdgeInsets.all( - Spacing.small, - ).add(context.mediaQuery.padding + context.mediaQuery.viewInsets), - child: Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUnfocus, - child: SingleChildScrollView( - child: Column( - spacing: Spacing.medium, - children: [ - Header( - title: "Add new card", - leading: AppButton.tertiary( - leading: AppIcons.close, - onPressed: () => context.safePop(), + final cardNumberValid = + context.validateCardNumber(card.value.number) == null; + final nameValid = card.value.name.isNotEmpty; + final monthValid = card.value.month != 0; + final yearValid = card.value.year != 0; + final cvvValid = card.value.cvv != 0; + + final isCardValid = + cardNumberValid && nameValid && monthValid && yearValid && cvvValid; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + context.unfocus(); + formKey.currentState?.validate(); + }, + child: Padding( + padding: const EdgeInsets.all( + Spacing.small, + ).add(context.mediaQuery.padding + context.mediaQuery.viewInsets), + child: Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUnfocus, + child: SingleChildScrollView( + child: Column( + spacing: Spacing.medium, + children: [ + Header( + title: "Add new card", + leading: AppButton.tertiary( + leading: AppIcons.close, + onPressed: () => context.safePop(), + ), ), - ), - Column( - spacing: Spacing.small, - children: [ - AppInputField( - "Card Number", - keyboardType: TextInputType.number, - validator: context.validateCardNumber, - onChanged: (value) => card.value = card.value.copyWith( - number: PaymentCardUtils.getCleanedNumber(value), - type: PaymentCardUtils.getCardTypeFrmNumber(value), + Column( + spacing: Spacing.small, + children: [ + AppInputField( + "Card Number", + key: const Key('card_number_field'), + keyboardType: TextInputType.number, + validator: context.validateCardNumber, + onChanged: (value) => card.value = card.value.copyWith( + number: PaymentCardUtils.getCleanedNumber(value), + type: PaymentCardUtils.getCardTypeFrmNumber(value), + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(19), + CardNumberInputFormatter(), + ], ), - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(19), - CardNumberInputFormatter(), - ], - ), - AppInputField( - "Name on card", - keyboardType: TextInputType.text, - validator: (String? value) => - value!.isEmpty ? "This field is required" : null, - onChanged: (value) => - card.value = card.value.copyWith(name: value), - ), + AppInputField( + "Name on card", + key: const Key('card_name_field'), + keyboardType: TextInputType.text, + validator: (String? value) => + value!.isEmpty ? "This field is required" : null, + onChanged: (value) => + card.value = card.value.copyWith(name: value), + ), - Row( - spacing: Spacing.small, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: AppInputField( - "Expiry Date", - validator: context.validateDate, - keyboardType: TextInputType.datetime, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(4), - CardMonthInputFormatter(), - ], - onChanged: (value) { - final expiryDate = PaymentCardUtils.getExpiryDate( - value, - ); - if (expiryDate != null) { - card.value = card.value.copyWith( - month: expiryDate[0], - year: expiryDate[1], + Row( + spacing: Spacing.small, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: AppInputField( + "Expiry Date", + key: const Key('card_expiry_field'), + validator: context.validateDate, + keyboardType: TextInputType.datetime, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + CardMonthInputFormatter(), + ], + onChanged: (value) { + final expiryDate = PaymentCardUtils.getExpiryDate( + value, ); - } - }, + if (expiryDate != null) { + card.value = card.value.copyWith( + month: expiryDate[0], + year: expiryDate[1], + ); + } + }, + ), ), - ), - Expanded( - child: AppInputField( - "CVV", - validator: context.validateCVV, - keyboardType: TextInputType.number, - onChanged: (value) { - if (value.isEmpty) return; - card.value = card.value.copyWith( - cvv: int.parse(value), - ); - }, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(4), - ], + Expanded( + child: AppInputField( + "CVV", + key: const Key('card_cvv_field'), + validator: context.validateCVV, + keyboardType: TextInputType.number, + onChanged: (value) { + if (value.isEmpty) return; + card.value = card.value.copyWith( + cvv: int.parse(value), + ); + }, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + ), ), - ), - ], - ), - ], - ), - SizedBox( - width: double.maxFinite, - child: AppButton.primary( - label: "Continue", - onPressed: () { - ref - .read(checkoutViewModelProvider.notifier) - .setPaymentMethod(card.value); + ], + ), + ], + ), + SizedBox( + width: double.maxFinite, + child: AppButton.primary( + label: "Continue", + isDisabled: !isCardValid, + onPressed: () { + final isValid = formKey.currentState?.validate() ?? false; + if (!isValid) return; - context.safePop(); - }, + ref + .read(checkoutViewModelProvider.notifier) + .setPaymentMethod(card.value); + + context.safePop(); + }, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/alfie_flutter/lib/ui/checkout/view/address_fields.dart b/alfie_flutter/lib/ui/checkout/view/address_fields.dart index d9ef7a2c..ee702c21 100644 --- a/alfie_flutter/lib/ui/checkout/view/address_fields.dart +++ b/alfie_flutter/lib/ui/checkout/view/address_fields.dart @@ -8,11 +8,13 @@ import 'package:flutter/material.dart'; /// Designed to be embedded within larger forms (e.g., delivery and billing), /// delegating state mutations back to the parent via the [onChanged] callback. class AddressFields extends StatelessWidget { + final String keyPrefix; final Address address; final Function(Address) onChanged; const AddressFields({ super.key, + this.keyPrefix = "address_fields", required this.address, required this.onChanged, }); @@ -24,30 +26,35 @@ class AddressFields extends StatelessWidget { children: [ AppInputField( "Country", + key: Key('${keyPrefix}_country_field'), keyboardType: TextInputType.text, initialValue: address.country, onChanged: (value) => onChanged(address.copyWith(country: value)), ), AppInputField( "Postal Code", + key: Key('${keyPrefix}_postal_code_field'), keyboardType: TextInputType.text, initialValue: address.postalCode, onChanged: (value) => onChanged(address.copyWith(postalCode: value)), ), AppInputField( "City", + key: Key('${keyPrefix}_city_field'), keyboardType: TextInputType.text, initialValue: address.city, onChanged: (value) => onChanged(address.copyWith(city: value)), ), AppInputField( "Street", + key: Key('${keyPrefix}_street_field'), keyboardType: TextInputType.streetAddress, initialValue: address.street, onChanged: (value) => onChanged(address.copyWith(street: value)), ), AppInputField( "Address Line 2 (Optional)", + key: Key('${keyPrefix}_address_line_2_field'), keyboardType: TextInputType.text, initialValue: address.addressLine2 ?? "", onChanged: (value) => diff --git a/alfie_flutter/lib/ui/checkout/view/checkout_screen.dart b/alfie_flutter/lib/ui/checkout/view/checkout_screen.dart index 469870f7..f2ed9a85 100644 --- a/alfie_flutter/lib/ui/checkout/view/checkout_screen.dart +++ b/alfie_flutter/lib/ui/checkout/view/checkout_screen.dart @@ -135,7 +135,7 @@ class CheckoutScreen extends ConsumerWidget { SizedBox( width: double.infinity, child: AppButton.primary( - label: "Continue", + label: "Place Order", isDisabled: !checkoutState.canPlaceOrder, onPressed: () { ref.read(checkoutViewModelProvider.notifier).placeOrder(); diff --git a/alfie_flutter/lib/ui/checkout/view/contact_information_screen.dart b/alfie_flutter/lib/ui/checkout/view/contact_information_screen.dart index df797624..8efa4624 100644 --- a/alfie_flutter/lib/ui/checkout/view/contact_information_screen.dart +++ b/alfie_flutter/lib/ui/checkout/view/contact_information_screen.dart @@ -72,6 +72,7 @@ class ContactInformationScreen extends HookConsumerWidget { children: [ AppInputField( "First Name", + key: const Key('contact_first_name_field'), validator: context.validateName, keyboardType: TextInputType.name, initialValue: formState.value.firstName, @@ -80,6 +81,7 @@ class ContactInformationScreen extends HookConsumerWidget { ), AppInputField( "Last Name", + key: const Key('contact_last_name_field'), validator: context.validateName, keyboardType: TextInputType.name, initialValue: formState.value.lastName, @@ -88,6 +90,7 @@ class ContactInformationScreen extends HookConsumerWidget { ), AppInputField( "Email", + key: const Key('contact_email_field'), validator: context.validateEmail, keyboardType: TextInputType.emailAddress, initialValue: formState.value.email, @@ -96,6 +99,7 @@ class ContactInformationScreen extends HookConsumerWidget { ), AppInputField( "Phone Number", + key: const Key('contact_phone_field'), validator: context.validatePhoneNumber, keyboardType: TextInputType.phone, initialValue: formState.value.phoneNumber, @@ -110,6 +114,10 @@ class ContactInformationScreen extends HookConsumerWidget { ), ), bottomNavigationBar: SafeArea( + minimum: const EdgeInsets.symmetric( + horizontal: Spacing.small, + vertical: Spacing.large, + ), child: Container( width: double.maxFinite, padding: const EdgeInsets.symmetric(horizontal: Spacing.small), diff --git a/alfie_flutter/lib/ui/checkout/view/delivery_information_screen.dart b/alfie_flutter/lib/ui/checkout/view/delivery_information_screen.dart index f8c331f4..c2ca10e6 100644 --- a/alfie_flutter/lib/ui/checkout/view/delivery_information_screen.dart +++ b/alfie_flutter/lib/ui/checkout/view/delivery_information_screen.dart @@ -78,6 +78,7 @@ class DeliveryInformationScreen extends HookConsumerWidget { textAlign: TextAlign.left, ), AddressFields( + keyPrefix: 'delivery', address: deliveryAddress.value, onChanged: (newAddress) => deliveryAddress.value = newAddress, @@ -102,6 +103,7 @@ class DeliveryInformationScreen extends HookConsumerWidget { ), if (!billingCheckbox.value) AddressFields( + keyPrefix: 'billing', address: billingAddress.value, onChanged: (newAddress) => billingAddress.value = newAddress, diff --git a/alfie_flutter/lib/ui/core/ui/gallery.dart b/alfie_flutter/lib/ui/core/ui/gallery.dart index 0916f543..b70710c8 100644 --- a/alfie_flutter/lib/ui/core/ui/gallery.dart +++ b/alfie_flutter/lib/ui/core/ui/gallery.dart @@ -56,7 +56,7 @@ class Gallery extends HookWidget { final pageController = usePageController(); // Tracks user interaction to temporarily pause auto-scrolling. - final isInteracting = useState(false); + final isInteracting = useRef(false); useEffect(() { // Avoid setting up the timer if auto-scroll is disabled, diff --git a/alfie_flutter/lib/ui/core/ui/header.dart b/alfie_flutter/lib/ui/core/ui/header.dart index f7ba46f4..ac104012 100644 --- a/alfie_flutter/lib/ui/core/ui/header.dart +++ b/alfie_flutter/lib/ui/core/ui/header.dart @@ -29,7 +29,7 @@ class Header extends StatelessWidget { left: Spacing.extraExtraSmall, bottom: Spacing.extraExtraSmall, right: Spacing.extraExtraSmall, - ).add(context.mediaQuery.padding), + ).add(EdgeInsets.only(top: context.mediaQuery.padding.top)), child: Row( spacing: Spacing.extraSmall, children: [ diff --git a/alfie_flutter/lib/ui/core/ui/product_card/vertical_product_card.dart b/alfie_flutter/lib/ui/core/ui/product_card/vertical_product_card.dart index 24743fc8..38a77ab9 100644 --- a/alfie_flutter/lib/ui/core/ui/product_card/vertical_product_card.dart +++ b/alfie_flutter/lib/ui/core/ui/product_card/vertical_product_card.dart @@ -35,34 +35,44 @@ class VerticalProductCard extends ConsumerWidget { children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: Spacing.extraSmall, children: [ - AspectRatio( - aspectRatio: aspectRatio, - child: ImageFactory.network( - product.colours?.first.media?.first.firstUrl ?? "", - ), - ), Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: Spacing.extraSmall, children: [ - Text( - product.brand.name, - style: context.textTheme.labelSmall, - maxLines: 1, - ), - Text( - product.name.capitalizeAll(), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium, + AspectRatio( + aspectRatio: aspectRatio, + child: ImageFactory.network( + product.colours?.first.media?.first.firstUrl ?? "", + ), ), - Text( - product.defaultVariant.price.amount.formatted, - style: context.textTheme.bodyMediumBold, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.brand.name, + style: context.textTheme.labelSmall, + maxLines: 1, + ), + Text( + product.name.capitalizeAll(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium, + ), + Text( + product.defaultVariant.price.amount.formatted, + style: context.textTheme.bodyMediumBold, + ), + if (aditionalInfo != null) + Text( + aditionalInfo!, + style: context.textTheme.labelSmall, + ), + ], ), - if (aditionalInfo != null) - Text(aditionalInfo!, style: context.textTheme.labelSmall), ], ), @@ -72,6 +82,7 @@ class VerticalProductCard extends ConsumerWidget { Align( alignment: Alignment.topRight, child: WishlistButton( + key: ValueKey('plp_product_card_${product.id}_wishlist_button'), product: product, buttonVariant: ButtonVariant.tertiary, ), @@ -82,9 +93,7 @@ class VerticalProductCard extends ConsumerWidget { child: Padding( padding: const EdgeInsets.all(Spacing.extraSmall), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.extraSmall, - ), + padding: EdgeInsets.symmetric(horizontal: Spacing.extraSmall), color: AppColors.neutral800, child: Text( label!, diff --git a/alfie_flutter/lib/ui/home/view_model/home_state.dart b/alfie_flutter/lib/ui/home/view_model/home_state.dart index 42a374ca..1fa4e70b 100644 --- a/alfie_flutter/lib/ui/home/view_model/home_state.dart +++ b/alfie_flutter/lib/ui/home/view_model/home_state.dart @@ -35,15 +35,15 @@ class HomeState { /// Static product category filters for the horizontal navigation carousel. final List categories = const [ - "A", - "B", - "C", - "D", - "E", - "F", - "G", - "H", - "I", + "Category A", + "Category B", + "Category C", + "Category D", + "Category E", + "Category F", + "Category G", + "Category H", + "Category I", ]; /// Static promotional banners presented in the promotion gallery. diff --git a/alfie_flutter/lib/ui/product_detail/view/product_detail_screen.dart b/alfie_flutter/lib/ui/product_detail/view/product_detail_screen.dart index 6a73cd0a..25fe0813 100644 --- a/alfie_flutter/lib/ui/product_detail/view/product_detail_screen.dart +++ b/alfie_flutter/lib/ui/product_detail/view/product_detail_screen.dart @@ -39,6 +39,7 @@ class ProductDetailScreen extends ConsumerWidget { return const Center(child: Text("Not Found")); } return CustomScrollView( + key: const Key('pdp_scroll_view'), slivers: [ SliverAppBar( primary: true, @@ -48,6 +49,7 @@ class ProductDetailScreen extends ConsumerWidget { background: Header( title: product.name.capitalizeAll(), leading: IconButton( + key: const Key('pdp_back_button'), padding: const EdgeInsets.only(), icon: Icon(AppIcons.back), onPressed: () => context.safePop(), diff --git a/alfie_flutter/lib/ui/product_detail/view/product_main_info.dart b/alfie_flutter/lib/ui/product_detail/view/product_main_info.dart index b13690fb..684460f0 100644 --- a/alfie_flutter/lib/ui/product_detail/view/product_main_info.dart +++ b/alfie_flutter/lib/ui/product_detail/view/product_main_info.dart @@ -64,6 +64,7 @@ class ProductMainInfo extends ConsumerWidget { children: [ Expanded( child: AppButton.primary( + key: const Key('pdp_add_to_bag_button'), label: "Add to Bag", onPressed: () { ref @@ -84,7 +85,10 @@ class ProductMainInfo extends ConsumerWidget { }, ), ), - WishlistButton(product: product), + WishlistButton( + key: const Key('pdp_wishlist_button'), + product: product, + ), ], ), ], diff --git a/alfie_flutter/lib/ui/product_listing/view/product_listing_content.dart b/alfie_flutter/lib/ui/product_listing/view/product_listing_content.dart index a70d8fbd..3134582b 100644 --- a/alfie_flutter/lib/ui/product_listing/view/product_listing_content.dart +++ b/alfie_flutter/lib/ui/product_listing/view/product_listing_content.dart @@ -18,7 +18,7 @@ class ProductListingContent extends ConsumerWidget { /// The active grid cross-axis layout preference. final int columns; - static const ratios = {1: 0.58, 2: 0.48}; + static const ratios = {1: 0.58, 2: 0.475}; const ProductListingContent({ super.key, @@ -63,7 +63,11 @@ class ProductListingContent extends ConsumerWidget { builder: (context, value, child) { return Transform.scale(scale: value, child: child); }, - child: VerticalProductCard(product: product, label: label), + child: VerticalProductCard( + key: ValueKey('plp_product_card_$index'), + product: product, + label: label, + ), ); }, childCount: productListing.products.length), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( diff --git a/alfie_flutter/lib/ui/product_listing/view/product_listing_screen.dart b/alfie_flutter/lib/ui/product_listing/view/product_listing_screen.dart index e7eb1e6f..8a835e59 100644 --- a/alfie_flutter/lib/ui/product_listing/view/product_listing_screen.dart +++ b/alfie_flutter/lib/ui/product_listing/view/product_listing_screen.dart @@ -28,6 +28,7 @@ class ProductListingScreen extends HookConsumerWidget { ref.read(productListingViewModelProvider(id).notifier).updateCount(); }, child: CustomScrollView( + key: const Key('plp_scroll_view'), controller: controller, slivers: [ ProductListingAppBar(id: id), diff --git a/alfie_flutter/lib/utils/build_context_extensions.dart b/alfie_flutter/lib/utils/build_context_extensions.dart index 2502d468..64ca0049 100644 --- a/alfie_flutter/lib/utils/build_context_extensions.dart +++ b/alfie_flutter/lib/utils/build_context_extensions.dart @@ -11,4 +11,7 @@ extension AppContextExtension on BuildContext { /// Shorthand for [MediaQuery.of(context)]. MediaQueryData get mediaQuery => MediaQuery.of(this); + + /// Unfocuses the current focus node, dismissing the keyboard if visible. + void unfocus() => FocusScope.of(this).unfocus(); } diff --git a/alfie_flutter/lib/utils/image_utils.dart b/alfie_flutter/lib/utils/image_utils.dart index 769b123b..b8a3395d 100644 --- a/alfie_flutter/lib/utils/image_utils.dart +++ b/alfie_flutter/lib/utils/image_utils.dart @@ -1,9 +1,16 @@ import 'package:alfie_flutter/ui/core/themes/colors.dart'; import 'package:alfie_flutter/ui/core/themes/spacing.dart'; +import 'package:alfie_flutter/utils/build_context_extensions.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; /// Provides standardized factories for rendering application imagery. abstract class ImageFactory { + static const Image fallbackImage = Image( + image: AssetImage('assets/images/fallback_image.png'), + fit: BoxFit.cover, + ); + /// Loads a remote image from the specified [url] with a built-in fallback state. /// /// Utilizes [FadeInImage.assetNetwork] to transition smoothly from a local @@ -11,23 +18,27 @@ abstract class ImageFactory { /// if the [url] is malformed or inaccessible. static Widget network(String url) { if (url.isEmpty) { - return Image.asset( - 'assets/images/fallback_image.png', - fit: BoxFit.fitHeight, - ); + return fallbackImage; } return LayoutBuilder( - builder: (context, constraints) => FadeInImage.assetNetwork( - placeholder: 'assets/images/fallback_image.png', - image: url, - fit: BoxFit.cover, - imageErrorBuilder: (context, error, stackTrace) { - return Image.asset( - 'assets/images/fallback_image.png', - fit: BoxFit.cover, - ); - }, - ), + builder: (context, constraints) { + final double dpr = context.mediaQuery.devicePixelRatio; + + final int? targetWidth = + constraints.maxWidth.isFinite && constraints.maxWidth > 0 + ? (constraints.maxWidth * dpr).round() + : null; + + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + + memCacheWidth: targetWidth, + + placeholder: (context, url) => fallbackImage, + errorWidget: (context, url, error) => fallbackImage, + ); + }, ); } diff --git a/alfie_flutter/macos/Runner.xcodeproj/project.pbxproj b/alfie_flutter/macos/Runner.xcodeproj/project.pbxproj index eca814d8..57e14eb9 100644 --- a/alfie_flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/alfie_flutter/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -78,6 +79,7 @@ 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -92,6 +94,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -150,6 +153,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -193,6 +197,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 33CC10EC2044A3C60003C045 /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -216,6 +223,9 @@ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -692,6 +702,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/alfie_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/alfie_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 7722e880..aa88ab09 100644 --- a/alfie_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/alfie_flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + main() => integrationDriver( + timeout: const Duration(minutes: 5), + // This tells Flutter to automatically unpack and write the timeline summary to a local file + responseDataCallback: (data) async { + if (data != null) { + final timeline = driver.Timeline.fromJson( + data['plp_scroll_timeline'] as Map, + ); + + // Convert the Timeline into a TimelineSummary that's easier to + // read and understand. + final summary = driver.TimelineSummary.summarize(timeline); + + // Then, write the entire timeline to disk in a json format. + // This file can be opened in the Chrome browser's tracing tools + // found by navigating to chrome://tracing. + // Optionally, save the summary to disk by setting includeSummary + // to true + await summary.writeTimelineToFile( + 'plp_scroll_timeline', + pretty: true, + includeSummary: true, + ); + log('Timeline data successfully received from device.'); + } + }, +);