diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..e530e239 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,124 @@ +name: E2E Tests + +on: + push: + branches: + - rel/** + workflow_dispatch: + inputs: + platform: + description: 'Platform to test' + required: true + default: 'android' + type: choice + options: + - android + - ios + - both + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-android: + if: >- + github.event_name == 'push' || + github.event.inputs.platform == 'android' || + github.event.inputs.platform == 'both' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Flutter + uses: ./.github/actions/setup-flutter + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Create demo .env + working-directory: examples/demo + run: | + echo "ONESIGNAL_APP_ID=${{ vars.APPIUM_ONESIGNAL_APP_ID }}" > .env + echo "ONESIGNAL_API_KEY=${{ secrets.APPIUM_ONESIGNAL_API_KEY }}" >> .env + echo "E2E_MODE=true" >> .env + + - name: Build release APK + working-directory: examples/demo + run: flutter build apk --release + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: demo-apk + path: examples/demo/build/app/outputs/flutter-apk/app-release.apk + retention-days: 1 + + build-ios: + if: >- + github.event_name == 'push' || + github.event.inputs.platform == 'ios' || + github.event.inputs.platform == 'both' + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Flutter + uses: ./.github/actions/setup-flutter + + - name: Create demo .env + working-directory: examples/demo + run: | + echo "ONESIGNAL_APP_ID=${{ vars.APPIUM_ONESIGNAL_APP_ID }}" > .env + echo "ONESIGNAL_API_KEY=${{ secrets.APPIUM_ONESIGNAL_API_KEY }}" >> .env + echo "E2E_MODE=true" >> .env + + - name: Install CocoaPods dependencies + working-directory: examples/demo/ios + run: pod install + + - name: Build iOS app + working-directory: examples/demo + run: | + flutter build ios --release --no-codesign + cd build/ios/iphoneos + mkdir Payload + cp -r Runner.app Payload/ + zip -r Runner.ipa Payload + + - name: Upload IPA + uses: actions/upload-artifact@v4 + with: + name: demo-ipa + path: examples/demo/build/ios/iphoneos/Runner.ipa + retention-days: 1 + + e2e-android: + needs: build-android + uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main + secrets: inherit + with: + platform: android + app-artifact: demo-apk + app-filename: app-release.apk + sdk-type: flutter + build-name: flutter-android-${{ github.ref_name }}-${{ github.run_number }} + + e2e-ios: + needs: build-ios + uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main + secrets: inherit + with: + platform: ios + app-artifact: demo-ipa + app-filename: Runner.ipa + sdk-type: flutter + build-name: flutter-ios-${{ github.ref_name }}-${{ github.run_number }} diff --git a/examples/build.md b/examples/build.md index a2ef2884..d81214b7 100644 --- a/examples/build.md +++ b/examples/build.md @@ -119,9 +119,9 @@ OneSignal.User.addObserver(...) - Use `await Future.delayed(const Duration(milliseconds: 100))` after setting state for render delay ### SnackBar Messages -- `AppViewModel` exposes a `snackBarMessage` stream or `ValueNotifier` -- `HomeScreen` shows via `ScaffoldMessenger.of(context).showSnackBar()` -- Clear previous SnackBar with `ScaffoldMessenger.of(context).clearSnackBars()` +- `AppSnackBar` extension on `BuildContext` defined in `theme.dart` +- Call `context.showSnackBar(message)` directly from widget callbacks +- Automatically hides the current SnackBar before showing the new one ### Send In-App Message Icons - TOP BANNER: `Icons.vertical_align_top` @@ -135,17 +135,6 @@ OneSignal.User.addObserver(...) - `TextEditingController`s are properly disposed in `StatefulWidget`s - JSON parsing via `jsonDecode` returns `Map` for Track Event -### Accessibility (Appium) -- Use `Semantics` widget with `label` property: - ```dart - Semantics(label: 'log_entry_${index}_message', child: Text(entry.message)) - ``` - -### Log Manager -- Singleton with `ChangeNotifier` for reactive UI updates -- `LogManager().d(tag, message)`, `.i()`, `.w()`, `.e()` -- Also prints via `debugPrint` for development - --- ## File Structure @@ -162,8 +151,7 @@ examples/demo/ │ ├── services/ │ │ ├── onesignal_api_service.dart │ │ ├── preferences_service.dart -│ │ ├── tooltip_helper.dart -│ │ └── log_manager.dart +│ │ └── tooltip_helper.dart │ ├── repositories/ │ │ └── onesignal_repository.dart │ ├── viewmodels/ @@ -175,9 +163,9 @@ examples/demo/ │ ├── section_card.dart │ ├── toggle_row.dart │ ├── action_button.dart +│ ├── app_text_field.dart │ ├── list_widgets.dart │ ├── loading_overlay.dart -│ ├── log_view.dart │ ├── dialogs.dart │ └── sections/ │ ├── app_section.dart @@ -192,7 +180,8 @@ examples/demo/ │ ├── tags_section.dart │ ├── outcomes_section.dart │ ├── triggers_section.dart -│ ├── track_event_section.dart +│ ├── custom_events_section.dart +│ ├── live_activities_section.dart │ └── location_section.dart ├── android/ ├── ios/ diff --git a/examples/demo/.env.example b/examples/demo/.env.example index 674a938f..eb491bd5 100644 --- a/examples/demo/.env.example +++ b/examples/demo/.env.example @@ -1 +1,2 @@ -ONESIGNAL_API_KEY=your_rest_api_key +ONESIGNAL_APP_ID=your-onesignal-app-id # default app id: 77e32082-ea27-42e3-a898-c72e141824ef if empty +ONESIGNAL_API_KEY=your-onesignal-api-key diff --git a/examples/demo/README.md b/examples/demo/README.md index 670272f7..4645eab7 100644 --- a/examples/demo/README.md +++ b/examples/demo/README.md @@ -46,7 +46,20 @@ flutter run -d ## Configuration -The app uses a default OneSignal App ID for testing. To use your own, update the `oneSignalAppId` constant in `lib/main.dart`. +Copy the example environment file and fill in your values: + +```bash +cp .env.example .env +``` + +Set your OneSignal credentials in `.env`: + +``` +ONESIGNAL_APP_ID=your-onesignal-app-id +ONESIGNAL_API_KEY=your-onesignal-api-key +``` + +If no `.env` is provided, the app falls back to a built-in default App ID. ## Build Guide diff --git a/examples/demo/lib/main.dart b/examples/demo/lib/main.dart index 1e5544ec..d32e57fb 100644 --- a/examples/demo/lib/main.dart +++ b/examples/demo/lib/main.dart @@ -6,30 +6,28 @@ import 'package:provider/provider.dart'; import 'repositories/onesignal_repository.dart'; import 'screens/home_screen.dart'; -import 'services/log_manager.dart'; import 'services/onesignal_api_service.dart'; import 'services/preferences_service.dart'; import 'services/tooltip_helper.dart'; import 'theme.dart'; import 'viewmodels/app_viewmodel.dart'; -const String oneSignalAppId = '77e32082-ea27-42e3-a898-c72e141824ef'; +const String _defaultAppId = '77e32082-ea27-42e3-a898-c72e141824ef'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Load environment variables try { await dotenv.load(fileName: '.env'); } catch (_) { - LogManager().w('App', '.env file not found, continuing without API key'); + debugPrint('.env file not found, using defaults'); } - // Initialize preferences final prefs = PreferencesService(); await prefs.init(); - final appId = prefs.appId ?? oneSignalAppId; + final envAppId = dotenv.env['ONESIGNAL_APP_ID']; + final appId = (envAppId != null && envAppId.isNotEmpty) ? envAppId : _defaultAppId; // Initialize OneSignal SDK OneSignal.Debug.setLogLevel(OSLogLevel.verbose); @@ -50,29 +48,28 @@ Future main() async { // Register IAM listeners OneSignal.InAppMessages.addWillDisplayListener((event) { - LogManager().i('IAM', 'Will display: ${event.message.messageId}'); + debugPrint('IAM will display: ${event.message.messageId}'); }); OneSignal.InAppMessages.addDidDisplayListener((event) { - LogManager().i('IAM', 'Did display: ${event.message.messageId}'); + debugPrint('IAM did display: ${event.message.messageId}'); }); OneSignal.InAppMessages.addWillDismissListener((event) { - LogManager().i('IAM', 'Will dismiss: ${event.message.messageId}'); + debugPrint('IAM will dismiss: ${event.message.messageId}'); }); OneSignal.InAppMessages.addDidDismissListener((event) { - LogManager().i('IAM', 'Did dismiss: ${event.message.messageId}'); + debugPrint('IAM did dismiss: ${event.message.messageId}'); }); OneSignal.InAppMessages.addClickListener((event) { - LogManager().i('IAM', 'Clicked: ${event.result.actionId}'); + debugPrint('IAM clicked: ${event.result.actionId}'); }); // Register notification listeners OneSignal.Notifications.addClickListener((event) { - LogManager().i('Notification', 'Clicked: ${event.notification.title}'); + debugPrint('Notification clicked: ${event.notification.title}'); }); OneSignal.Notifications.addForegroundWillDisplayListener((event) { - LogManager().i( - 'Notification', - 'Foreground will display: ${event.notification.title}', + debugPrint( + 'Notification foreground will display: ${event.notification.title}', ); event.notification.display(); }); @@ -82,7 +79,7 @@ Future main() async { try { apiKey = dotenv.env['ONESIGNAL_API_KEY'] ?? ''; } catch (_) { - LogManager().w('App', 'API key not found, continuing without it'); + debugPrint('API key not found, continuing without it'); } final apiService = OneSignalApiService() ..setAppId(appId) @@ -92,7 +89,7 @@ Future main() async { // Fetch tooltips in background TooltipHelper().init(); - LogManager().i('App', 'OneSignal initialized with app ID: $appId'); + debugPrint('OneSignal initialized with app ID: $appId'); runApp( ChangeNotifierProvider( diff --git a/examples/demo/lib/repositories/onesignal_repository.dart b/examples/demo/lib/repositories/onesignal_repository.dart index 75ca90da..be9397f9 100644 --- a/examples/demo/lib/repositories/onesignal_repository.dart +++ b/examples/demo/lib/repositories/onesignal_repository.dart @@ -1,8 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:onesignal_flutter/onesignal_flutter.dart'; import '../models/notification_type.dart'; import '../models/user_data.dart'; -import '../services/log_manager.dart'; import '../services/onesignal_api_service.dart'; class OneSignalRepository { @@ -12,66 +12,54 @@ class OneSignalRepository { // User operations Future loginUser(String externalUserId) async { - LogManager().i('SDK', 'Login user: $externalUserId'); await OneSignal.login(externalUserId); } Future logoutUser() async { - LogManager().i('SDK', 'Logout user'); await OneSignal.logout(); } // Alias operations void addAlias(String label, String id) { - LogManager().i('SDK', 'Add alias: $label = $id'); OneSignal.User.addAlias(label, id); } void addAliases(Map aliases) { - LogManager().i('SDK', 'Add aliases: $aliases'); OneSignal.User.addAliases(aliases); } // Email operations void addEmail(String email) { - LogManager().i('SDK', 'Add email: $email'); OneSignal.User.addEmail(email); } void removeEmail(String email) { - LogManager().i('SDK', 'Remove email: $email'); OneSignal.User.removeEmail(email); } // SMS operations void addSms(String smsNumber) { - LogManager().i('SDK', 'Add SMS: $smsNumber'); OneSignal.User.addSms(smsNumber); } void removeSms(String smsNumber) { - LogManager().i('SDK', 'Remove SMS: $smsNumber'); OneSignal.User.removeSms(smsNumber); } // Tag operations void addTag(String key, String value) { - LogManager().i('SDK', 'Add tag: $key = $value'); OneSignal.User.addTagWithKey(key, value); } void addTags(Map tags) { - LogManager().i('SDK', 'Add tags: $tags'); OneSignal.User.addTags(tags); } void removeTag(String key) { - LogManager().i('SDK', 'Remove tag: $key'); OneSignal.User.removeTag(key); } void removeTags(List keys) { - LogManager().i('SDK', 'Remove tags: $keys'); OneSignal.User.removeTags(keys); } @@ -81,49 +69,40 @@ class OneSignalRepository { // Trigger operations void addTrigger(String key, String value) { - LogManager().i('SDK', 'Add trigger: $key = $value'); OneSignal.InAppMessages.addTrigger(key, value); } void addTriggers(Map triggers) { - LogManager().i('SDK', 'Add triggers: $triggers'); OneSignal.InAppMessages.addTriggers(triggers); } void removeTrigger(String key) { - LogManager().i('SDK', 'Remove trigger: $key'); OneSignal.InAppMessages.removeTrigger(key); } void removeTriggers(List keys) { - LogManager().i('SDK', 'Remove triggers: $keys'); OneSignal.InAppMessages.removeTriggers(keys); } void clearTriggers() { - LogManager().i('SDK', 'Clear all triggers'); OneSignal.InAppMessages.clearTriggers(); } // Outcome operations void sendOutcome(String name) { - LogManager().i('SDK', 'Send outcome: $name'); OneSignal.Session.addOutcome(name); } void sendUniqueOutcome(String name) { - LogManager().i('SDK', 'Send unique outcome: $name'); OneSignal.Session.addUniqueOutcome(name); } void sendOutcomeWithValue(String name, double value) { - LogManager().i('SDK', 'Send outcome with value: $name = $value'); OneSignal.Session.addOutcomeWithValue(name, value); } // Track event void trackEvent(String name, Map? properties) { - LogManager().i('SDK', 'Track event: $name, properties: $properties'); OneSignal.User.trackEvent(name, properties); } @@ -133,12 +112,10 @@ class OneSignalRepository { bool? isPushOptedIn() => OneSignal.User.pushSubscription.optedIn; void optInPush() { - LogManager().i('SDK', 'Opt in push'); OneSignal.User.pushSubscription.optIn(); } void optOutPush() { - LogManager().i('SDK', 'Opt out push'); OneSignal.User.pushSubscription.optOut(); } @@ -146,18 +123,17 @@ class OneSignalRepository { bool hasPermission() => OneSignal.Notifications.permission; Future requestPermission(bool fallbackToSettings) async { - LogManager().i('SDK', 'Request permission (fallback: $fallbackToSettings)'); + debugPrint('Request permission (fallback: $fallbackToSettings)'); return await OneSignal.Notifications.requestPermission(fallbackToSettings); } void clearAllNotifications() { - LogManager().i('SDK', 'Clear all notifications'); OneSignal.Notifications.clearAll(); } // In-app messages void setInAppMessagesPaused(bool paused) { - LogManager().i('SDK', 'Set IAM paused: $paused'); + debugPrint('Set IAM paused: $paused'); OneSignal.InAppMessages.paused(paused); } @@ -167,7 +143,6 @@ class OneSignalRepository { // Location void setLocationShared(bool shared) { - LogManager().i('SDK', 'Set location shared: $shared'); OneSignal.Location.setShared(shared); } @@ -176,7 +151,7 @@ class OneSignalRepository { } void requestLocationPermission() { - LogManager().i('SDK', 'Request location permission'); + debugPrint('Request location permission'); OneSignal.Location.requestPermission(); } @@ -188,12 +163,11 @@ class OneSignalRepository { Map attributes, Map content, ) async { - LogManager().i('SDK', 'Start default live activity: $activityId'); await OneSignal.LiveActivities.startDefault(activityId, attributes, content); } Future exitLiveActivity(String activityId) async { - LogManager().i('SDK', 'Exit live activity: $activityId'); + // ignore: deprecated_member_use await OneSignal.LiveActivities.exitLiveActivity(activityId); } @@ -210,12 +184,12 @@ class OneSignalRepository { // Privacy consent void setConsentRequired(bool required) { - LogManager().i('SDK', 'Set consent required: $required'); + debugPrint('Set consent required: $required'); OneSignal.consentRequired(required); } void setConsentGiven(bool granted) { - LogManager().i('SDK', 'Set consent given: $granted'); + debugPrint('Set consent given: $granted'); OneSignal.consentGiven(granted); } @@ -232,7 +206,7 @@ class OneSignalRepository { Future sendNotification(NotificationType type) async { final subscriptionId = getPushSubscriptionId(); if (subscriptionId == null) { - LogManager().w('SDK', 'No subscription ID for notification'); + debugPrint('No subscription ID for notification'); return false; } return _apiService.sendNotification(type, subscriptionId); @@ -241,7 +215,7 @@ class OneSignalRepository { Future sendCustomNotification(String title, String body) async { final subscriptionId = getPushSubscriptionId(); if (subscriptionId == null) { - LogManager().w('SDK', 'No subscription ID for custom notification'); + debugPrint('No subscription ID for custom notification'); return false; } return _apiService.sendCustomNotification(title, body, subscriptionId); diff --git a/examples/demo/lib/screens/home_screen.dart b/examples/demo/lib/screens/home_screen.dart index e161de2b..0486a11a 100644 --- a/examples/demo/lib/screens/home_screen.dart +++ b/examples/demo/lib/screens/home_screen.dart @@ -8,7 +8,6 @@ import '../services/tooltip_helper.dart'; import '../viewmodels/app_viewmodel.dart'; import '../widgets/dialogs.dart'; import '../widgets/loading_overlay.dart'; -import '../widgets/log_view.dart'; import '../widgets/sections/aliases_section.dart'; import '../widgets/sections/app_section.dart'; import '../widgets/sections/user_section.dart'; @@ -22,7 +21,7 @@ import '../widgets/sections/send_iam_section.dart'; import '../widgets/sections/send_push_section.dart'; import '../widgets/sections/sms_section.dart'; import '../widgets/sections/tags_section.dart'; -import '../widgets/sections/track_event_section.dart'; +import '../widgets/sections/custom_events_section.dart'; import '../widgets/sections/triggers_section.dart'; import 'secondary_screen.dart'; @@ -57,17 +56,6 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { final vm = context.watch(); - // Listen for snackbar messages - WidgetsBinding.instance.addPostFrameCallback((_) { - final message = vm.snackBarMessage; - if (message != null) { - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar(SnackBar(content: Text(message))); - vm.clearSnackBar(); - } - }); - return Scaffold( appBar: AppBar( centerTitle: true, @@ -94,85 +82,79 @@ class _HomeScreenState extends State { ), body: LoadingOverlay( isLoading: vm.isLoading, - child: Column( - children: [ - const LogView(), - Expanded( - child: ListView( - padding: const EdgeInsets.only(bottom: 24), - children: [ - const AppSection(), - const UserSection(), - PushSection( - onInfoTap: () => _showTooltipDialog(context, 'push'), - ), - SendPushSection( - onInfoTap: () => - _showTooltipDialog(context, 'sendPushNotification'), - ), - InAppSection( - onInfoTap: () => - _showTooltipDialog(context, 'inAppMessaging'), - ), - SendIamSection( - onInfoTap: () => - _showTooltipDialog(context, 'sendInAppMessage'), - ), - AliasesSection( - onInfoTap: () => _showTooltipDialog(context, 'aliases'), - ), - EmailsSection( - onInfoTap: () => _showTooltipDialog(context, 'emails'), - ), - SmsSection( - onInfoTap: () => _showTooltipDialog(context, 'sms'), - ), - TagsSection( - onInfoTap: () => _showTooltipDialog(context, 'tags'), - ), - OutcomesSection( - onInfoTap: () => - _showTooltipDialog(context, 'outcomes'), - ), - TriggersSection( - onInfoTap: () => _showTooltipDialog(context, 'triggers'), - ), - TrackEventSection( - onInfoTap: () => - _showTooltipDialog(context, 'trackEvent'), - ), - LocationSection( - onInfoTap: () => _showTooltipDialog(context, 'location'), - ), - if (defaultTargetPlatform == TargetPlatform.iOS) - LiveActivitiesSection( - onInfoTap: () => - _showTooltipDialog(context, 'liveActivities'), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const SecondaryScreen(), - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.osPrimary, - foregroundColor: Colors.white, + child: Semantics( + identifier: 'main_scroll_view', + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + const AppSection(), + const UserSection(), + PushSection( + onInfoTap: () => _showTooltipDialog(context, 'push'), + ), + SendPushSection( + onInfoTap: () => + _showTooltipDialog(context, 'sendPushNotification'), + ), + InAppSection( + onInfoTap: () => + _showTooltipDialog(context, 'inAppMessaging'), + ), + SendIamSection( + onInfoTap: () => + _showTooltipDialog(context, 'sendInAppMessage'), + ), + AliasesSection( + onInfoTap: () => _showTooltipDialog(context, 'aliases'), + ), + EmailsSection( + onInfoTap: () => _showTooltipDialog(context, 'emails'), + ), + SmsSection( + onInfoTap: () => _showTooltipDialog(context, 'sms'), + ), + TagsSection( + onInfoTap: () => _showTooltipDialog(context, 'tags'), + ), + OutcomesSection( + onInfoTap: () => _showTooltipDialog(context, 'outcomes'), + ), + TriggersSection( + onInfoTap: () => _showTooltipDialog(context, 'triggers'), + ), + CustomEventsSection( + onInfoTap: () => _showTooltipDialog(context, 'customEvents'), + ), + LocationSection( + onInfoTap: () => _showTooltipDialog(context, 'location'), + ), + if (defaultTargetPlatform == TargetPlatform.iOS) + LiveActivitiesSection( + onInfoTap: () => + _showTooltipDialog(context, 'liveActivities'), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SecondaryScreen(), ), - child: const Text('NEXT ACTIVITY'), - ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.osPrimary, + foregroundColor: Colors.white, ), - const SizedBox(height: 16), - ], + child: const Text('NEXT SCREEN'), + ), ), - ), - ], + const SizedBox(height: 16), + ], + ), ), ), ); diff --git a/examples/demo/lib/screens/secondary_screen.dart b/examples/demo/lib/screens/secondary_screen.dart index 9701cec9..d6232e1f 100644 --- a/examples/demo/lib/screens/secondary_screen.dart +++ b/examples/demo/lib/screens/secondary_screen.dart @@ -7,12 +7,12 @@ class SecondaryScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Secondary Activity'), + title: const Text('Secondary Screen'), centerTitle: true, ), body: Center( child: Text( - 'Secondary Activity', + 'Secondary Screen', style: Theme.of(context).textTheme.headlineMedium, ), ), diff --git a/examples/demo/lib/services/log_manager.dart b/examples/demo/lib/services/log_manager.dart deleted file mode 100644 index 3e66c7c8..00000000 --- a/examples/demo/lib/services/log_manager.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/foundation.dart'; - -enum LogLevel { debug, info, warn, error } - -class LogEntry { - final DateTime timestamp; - final LogLevel level; - final String tag; - final String message; - - const LogEntry({ - required this.timestamp, - required this.level, - required this.tag, - required this.message, - }); - - String get levelLabel { - switch (level) { - case LogLevel.debug: - return 'D'; - case LogLevel.info: - return 'I'; - case LogLevel.warn: - return 'W'; - case LogLevel.error: - return 'E'; - } - } - - String get formattedTime { - final h = timestamp.hour.toString().padLeft(2, '0'); - final m = timestamp.minute.toString().padLeft(2, '0'); - final s = timestamp.second.toString().padLeft(2, '0'); - return '$h:$m:$s'; - } -} - -class LogManager extends ChangeNotifier { - static final LogManager _instance = LogManager._internal(); - factory LogManager() => _instance; - LogManager._internal(); - - final List _logs = []; - - List get logs => List.unmodifiable(_logs); - - void _log(LogLevel level, String tag, String message) { - final entry = LogEntry( - timestamp: DateTime.now(), - level: level, - tag: tag, - message: message, - ); - _logs.add(entry); - debugPrint('[${entry.levelLabel}] $tag: $message'); - notifyListeners(); - } - - void d(String tag, String message) => _log(LogLevel.debug, tag, message); - void i(String tag, String message) => _log(LogLevel.info, tag, message); - void w(String tag, String message) => _log(LogLevel.warn, tag, message); - void e(String tag, String message) => _log(LogLevel.error, tag, message); - - void clear() { - _logs.clear(); - notifyListeners(); - } -} diff --git a/examples/demo/lib/services/onesignal_api_service.dart b/examples/demo/lib/services/onesignal_api_service.dart index bfc8887f..2c742759 100644 --- a/examples/demo/lib/services/onesignal_api_service.dart +++ b/examples/demo/lib/services/onesignal_api_service.dart @@ -1,10 +1,10 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../models/notification_type.dart'; import '../models/user_data.dart'; -import '../services/log_manager.dart'; class OneSignalApiService { String _appId = ''; @@ -49,13 +49,10 @@ class OneSignalApiService { body: jsonEncode(body), ); - LogManager().i( - 'API', - 'Send notification response: ${response.statusCode}', - ); + debugPrint('Send notification response: ${response.statusCode}'); return response.statusCode == 200; } catch (e) { - LogManager().e('API', 'Send notification error: $e'); + debugPrint('Send notification error: $e'); return false; } } @@ -82,13 +79,10 @@ class OneSignalApiService { body: jsonEncode(payload), ); - LogManager().i( - 'API', - 'Send custom notification response: ${response.statusCode}', - ); + debugPrint('Send custom notification response: ${response.statusCode}'); return response.statusCode == 200; } catch (e) { - LogManager().e('API', 'Send custom notification error: $e'); + debugPrint('Send custom notification error: $e'); return false; } } @@ -114,13 +108,12 @@ class OneSignalApiService { }), ); - LogManager().i( - 'API', + debugPrint( 'Update live activity response: ${response.statusCode} ${response.body}', ); return response.statusCode >= 200 && response.statusCode < 300; } catch (e) { - LogManager().e('API', 'Update live activity error: $e'); + debugPrint('Update live activity error: $e'); return false; } } @@ -146,13 +139,12 @@ class OneSignalApiService { }), ); - LogManager().i( - 'API', + debugPrint( 'End live activity response: ${response.statusCode} ${response.body}', ); return response.statusCode >= 200 && response.statusCode < 300; } catch (e) { - LogManager().e('API', 'End live activity error: $e'); + debugPrint('End live activity error: $e'); return false; } } @@ -170,10 +162,10 @@ class OneSignalApiService { final json = jsonDecode(response.body) as Map; return UserData.fromJson(json); } - LogManager().w('API', 'Fetch user returned ${response.statusCode}'); + debugPrint('Fetch user returned ${response.statusCode}'); return null; } catch (e) { - LogManager().e('API', 'Fetch user error: $e'); + debugPrint('Fetch user error: $e'); return null; } } diff --git a/examples/demo/lib/services/preferences_service.dart b/examples/demo/lib/services/preferences_service.dart index 9d4a282b..7fe89283 100644 --- a/examples/demo/lib/services/preferences_service.dart +++ b/examples/demo/lib/services/preferences_service.dart @@ -1,7 +1,6 @@ import 'package:shared_preferences/shared_preferences.dart'; class PreferencesService { - static const _keyAppId = 'app_id'; static const _keyConsentRequired = 'consent_required'; static const _keyPrivacyConsent = 'privacy_consent'; static const _keyExternalUserId = 'external_user_id'; @@ -14,10 +13,6 @@ class PreferencesService { _prefs = await SharedPreferences.getInstance(); } - // App ID - String? get appId => _prefs.getString(_keyAppId); - Future setAppId(String value) => _prefs.setString(_keyAppId, value); - // Consent required bool get consentRequired => _prefs.getBool(_keyConsentRequired) ?? false; Future setConsentRequired(bool value) => diff --git a/examples/demo/lib/services/tooltip_helper.dart b/examples/demo/lib/services/tooltip_helper.dart index ab05a96f..664f8121 100644 --- a/examples/demo/lib/services/tooltip_helper.dart +++ b/examples/demo/lib/services/tooltip_helper.dart @@ -1,9 +1,8 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -import 'log_manager.dart'; - class TooltipData { final String title; final String description; @@ -62,10 +61,10 @@ class TooltipHelper { ), ); }); - LogManager().i('Tooltip', 'Loaded ${_tooltips.length} tooltips'); + debugPrint('Loaded ${_tooltips.length} tooltips'); } } catch (e) { - LogManager().w('Tooltip', 'Failed to load tooltips: $e'); + debugPrint('Failed to load tooltips: $e'); } _initialized = true; diff --git a/examples/demo/lib/theme.dart b/examples/demo/lib/theme.dart index 859c0788..ae0de6cb 100644 --- a/examples/demo/lib/theme.dart +++ b/examples/demo/lib/theme.dart @@ -40,7 +40,9 @@ class AppTheme { appBarTheme: const AppBarTheme( backgroundColor: AppColors.osPrimary, foregroundColor: Colors.white, - elevation: 0, + elevation: 2, + scrolledUnderElevation: 2, + shadowColor: Colors.black, ), cardTheme: CardThemeData( color: AppColors.osCardBackground, @@ -105,3 +107,16 @@ class AppTheme { AppTheme._(); } + +extension AppSnackBar on BuildContext { + void showSnackBar(String message) { + ScaffoldMessenger.of(this) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(message), + dismissDirection: DismissDirection.horizontal, + ), + ); + } +} diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart index 57f3cfe0..c3ce1c9e 100644 --- a/examples/demo/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo/lib/viewmodels/app_viewmodel.dart @@ -4,7 +4,6 @@ import 'package:onesignal_flutter/onesignal_flutter.dart'; import '../models/in_app_message_type.dart'; import '../models/notification_type.dart'; import '../repositories/onesignal_repository.dart'; -import '../services/log_manager.dart'; import '../services/preferences_service.dart'; class AppViewModel extends ChangeNotifier { @@ -35,19 +34,6 @@ class AppViewModel extends ChangeNotifier { bool _isLoading = false; bool get isLoading => _isLoading; - // SnackBar message - String? _snackBarMessage; - String? get snackBarMessage => _snackBarMessage; - void clearSnackBar() { - _snackBarMessage = null; - } - - void _showSnackBar(String message) { - _snackBarMessage = message; - LogManager().i('App', message); - notifyListeners(); - } - // App state String _appId = ''; String get appId => _appId; @@ -154,7 +140,7 @@ class AppViewModel extends ChangeNotifier { } catch (e) { _isLoading = false; notifyListeners(); - LogManager().e('App', 'Error fetching initial user data: $e'); + debugPrint('Error fetching initial user data: $e'); } } @@ -163,19 +149,20 @@ class AppViewModel extends ChangeNotifier { OneSignal.User.pushSubscription.addObserver((state) { _pushSubscriptionId = state.current.id; _pushEnabled = state.current.optedIn; - LogManager().i('Observer', - 'Push subscription changed: id=${state.current.id}, optedIn=${state.current.optedIn}'); + debugPrint( + 'Push subscription changed: id=${state.current.id}, optedIn=${state.current.optedIn}', + ); notifyListeners(); }); OneSignal.Notifications.addPermissionObserver((permission) { _hasNotificationPermission = permission; - LogManager().i('Observer', 'Permission changed: $permission'); + debugPrint('Permission changed: $permission'); notifyListeners(); }); OneSignal.User.addObserver((state) { - LogManager().i('Observer', 'User state changed'); + debugPrint('User state changed'); fetchUserDataFromApi(); }); } @@ -201,7 +188,7 @@ class AppViewModel extends ChangeNotifier { notifyListeners(); } } catch (e) { - LogManager().e('App', 'Error fetching user data: $e'); + debugPrint('Error fetching user data: $e'); } } @@ -223,12 +210,11 @@ class AppViewModel extends ChangeNotifier { _isLoading = false; notifyListeners(); - _showSnackBar('Logged in as: $externalUserId'); + debugPrint('Logged in as: $externalUserId'); } catch (e) { _isLoading = false; notifyListeners(); - LogManager().e('App', 'Login error: $e'); - _showSnackBar('Login failed'); + debugPrint('Login error: $e'); } } @@ -248,11 +234,11 @@ class AppViewModel extends ChangeNotifier { _isLoading = false; notifyListeners(); - _showSnackBar('Logged out'); + debugPrint('Logged out'); } catch (e) { _isLoading = false; notifyListeners(); - LogManager().e('App', 'Logout error: $e'); + debugPrint('Logout error: $e'); } } @@ -284,7 +270,7 @@ class AppViewModel extends ChangeNotifier { } _pushEnabled = enabled; notifyListeners(); - _showSnackBar('Push ${enabled ? "enabled" : "disabled"}'); + debugPrint('Push ${enabled ? "enabled" : "disabled"}'); } Future promptPush() async { @@ -296,20 +282,25 @@ class AppViewModel extends ChangeNotifier { // Notifications Future sendNotification(NotificationType type) async { final success = await _repository.sendNotification(type); - _showSnackBar(success - ? 'Notification sent: ${type.name}' - : 'Failed to send notification'); + if (success) { + debugPrint('Notification sent: ${type.name}'); + } else { + debugPrint('Failed to send notification'); + } } Future sendCustomNotification(String title, String body) async { final success = await _repository.sendCustomNotification(title, body); - _showSnackBar( - success ? 'Custom notification sent' : 'Failed to send notification'); + if (success) { + debugPrint('Custom notification sent'); + } else { + debugPrint('Failed to send notification'); + } } void clearAllNotifications() { _repository.clearAllNotifications(); - _showSnackBar('All notifications cleared'); + debugPrint('All notifications cleared'); } // IAM @@ -326,7 +317,7 @@ class AppViewModel extends ChangeNotifier { ..removeWhere((e) => e.key == 'iam_type') ..add(MapEntry('iam_type', type.triggerValue)); notifyListeners(); - _showSnackBar('Sent In-App Message: ${type.label}'); + debugPrint('Sent In-App Message: ${type.label}'); } // Aliases @@ -334,14 +325,14 @@ class AppViewModel extends ChangeNotifier { _repository.addAlias(label, id); _aliasesList = List.from(_aliasesList)..add(MapEntry(label, id)); notifyListeners(); - _showSnackBar('Alias added: $label'); + debugPrint('Alias added: $label'); } void addAliases(Map aliases) { _repository.addAliases(aliases); _aliasesList = List.from(_aliasesList)..addAll(aliases.entries); notifyListeners(); - _showSnackBar('${aliases.length} alias(es) added'); + debugPrint('${aliases.length} alias(es) added'); } // Emails @@ -349,14 +340,14 @@ class AppViewModel extends ChangeNotifier { _repository.addEmail(email); _emailsList = List.from(_emailsList)..add(email); notifyListeners(); - _showSnackBar('Email added: $email'); + debugPrint('Email added: $email'); } void removeEmail(String email) { _repository.removeEmail(email); _emailsList = List.from(_emailsList)..remove(email); notifyListeners(); - _showSnackBar('Email removed: $email'); + debugPrint('Email removed: $email'); } // SMS @@ -364,14 +355,14 @@ class AppViewModel extends ChangeNotifier { _repository.addSms(smsNumber); _smsNumbersList = List.from(_smsNumbersList)..add(smsNumber); notifyListeners(); - _showSnackBar('SMS added: $smsNumber'); + debugPrint('SMS added: $smsNumber'); } void removeSms(String smsNumber) { _repository.removeSms(smsNumber); _smsNumbersList = List.from(_smsNumbersList)..remove(smsNumber); notifyListeners(); - _showSnackBar('SMS removed: $smsNumber'); + debugPrint('SMS removed: $smsNumber'); } // Tags @@ -379,28 +370,28 @@ class AppViewModel extends ChangeNotifier { _repository.addTag(key, value); _tagsList = List.from(_tagsList)..add(MapEntry(key, value)); notifyListeners(); - _showSnackBar('Tag added: $key'); + debugPrint('Tag added: $key'); } void addTags(Map tags) { _repository.addTags(tags); _tagsList = List.from(_tagsList)..addAll(tags.entries); notifyListeners(); - _showSnackBar('${tags.length} tag(s) added'); + debugPrint('${tags.length} tag(s) added'); } void removeTag(String key) { _repository.removeTag(key); _tagsList = List.from(_tagsList)..removeWhere((e) => e.key == key); notifyListeners(); - _showSnackBar('Tag removed: $key'); + debugPrint('Tag removed: $key'); } void removeSelectedTags(List keys) { _repository.removeTags(keys); _tagsList = List.from(_tagsList)..removeWhere((e) => keys.contains(e.key)); notifyListeners(); - _showSnackBar('${keys.length} tag(s) removed'); + debugPrint('${keys.length} tag(s) removed'); } // Triggers (in-memory only) @@ -408,21 +399,21 @@ class AppViewModel extends ChangeNotifier { _repository.addTrigger(key, value); _triggersList = List.from(_triggersList)..add(MapEntry(key, value)); notifyListeners(); - _showSnackBar('Trigger added: $key'); + debugPrint('Trigger added: $key'); } void addTriggers(Map triggers) { _repository.addTriggers(triggers); _triggersList = List.from(_triggersList)..addAll(triggers.entries); notifyListeners(); - _showSnackBar('${triggers.length} trigger(s) added'); + debugPrint('${triggers.length} trigger(s) added'); } void removeTrigger(String key) { _repository.removeTrigger(key); _triggersList = List.from(_triggersList)..removeWhere((e) => e.key == key); notifyListeners(); - _showSnackBar('Trigger removed: $key'); + debugPrint('Trigger removed: $key'); } void removeSelectedTriggers(List keys) { @@ -430,36 +421,36 @@ class AppViewModel extends ChangeNotifier { _triggersList = List.from(_triggersList) ..removeWhere((e) => keys.contains(e.key)); notifyListeners(); - _showSnackBar('${keys.length} trigger(s) removed'); + debugPrint('${keys.length} trigger(s) removed'); } void clearAllTriggers() { _repository.clearTriggers(); _triggersList = []; notifyListeners(); - _showSnackBar('All triggers cleared'); + debugPrint('All triggers cleared'); } // Outcomes void sendOutcome(String name) { _repository.sendOutcome(name); - _showSnackBar('Outcome sent: $name'); + debugPrint('Outcome sent: $name'); } void sendUniqueOutcome(String name) { _repository.sendUniqueOutcome(name); - _showSnackBar('Unique outcome sent: $name'); + debugPrint('Unique outcome sent: $name'); } void sendOutcomeWithValue(String name, double value) { _repository.sendOutcomeWithValue(name, value); - _showSnackBar('Outcome sent: $name = $value'); + debugPrint('Outcome sent: $name = $value'); } - // Track Event + // Custom Events void trackEvent(String name, Map? properties) { _repository.trackEvent(name, properties); - _showSnackBar('Event tracked: $name'); + debugPrint('Event tracked: $name'); } // Live Activities @@ -479,7 +470,7 @@ class AppViewModel extends ChangeNotifier { _statusIndex = 0; await _repository.startDefaultLiveActivity(_activityId, attributes, content); notifyListeners(); - _showSnackBar('Started Live Activity: $_activityId'); + debugPrint('Started Live Activity: $_activityId'); } Future updateLiveActivity() async { @@ -489,30 +480,31 @@ class AppViewModel extends ChangeNotifier { final nextIndex = (_statusIndex + 1) % _orderStatuses.length; final content = Map.from(_orderStatuses[nextIndex]); final eventUpdates = {'data': content}; - final success = await _repository.updateLiveActivity(_activityId, eventUpdates); + final success = + await _repository.updateLiveActivity(_activityId, eventUpdates); _isLaUpdating = false; if (success) { _statusIndex = nextIndex; - _showSnackBar('Updated Live Activity: $_activityId'); + debugPrint('Updated Live Activity: $_activityId'); } else { - _showSnackBar('Failed to update Live Activity'); + debugPrint('Failed to update Live Activity'); } notifyListeners(); } Future exitLiveActivity() async { await _repository.exitLiveActivity(_activityId); - _showSnackBar('Exited Live Activity: $_activityId'); + debugPrint('Exited Live Activity: $_activityId'); } Future endLiveActivity() async { final success = await _repository.endLiveActivity(_activityId); if (success) { _statusIndex = 0; - _showSnackBar('Ended Live Activity: $_activityId'); + debugPrint('Ended Live Activity: $_activityId'); } else { - _showSnackBar('Failed to end Live Activity'); + debugPrint('Failed to end Live Activity'); } notifyListeners(); } @@ -523,13 +515,17 @@ class AppViewModel extends ChangeNotifier { _repository.setLocationShared(shared); await _prefs.setLocationShared(shared); notifyListeners(); - _showSnackBar('Location sharing ${shared ? "enabled" : "disabled"}'); + debugPrint('Location sharing ${shared ? "enabled" : "disabled"}'); } void promptLocation() { _repository.requestLocationPermission(); } + Future checkLocationShared() async { + return await _repository.isLocationShared(); + } + // Dismiss loading (called from user state change observer) void dismissLoading() { if (_isLoading) { diff --git a/examples/demo/lib/widgets/action_button.dart b/examples/demo/lib/widgets/action_button.dart index 0e657960..9222f81c 100644 --- a/examples/demo/lib/widgets/action_button.dart +++ b/examples/demo/lib/widgets/action_button.dart @@ -6,17 +6,19 @@ class PrimaryButton extends StatelessWidget { final String label; final VoidCallback? onPressed; final IconData? icon; + final String? semanticsLabel; const PrimaryButton({ super.key, required this.label, this.onPressed, this.icon, + this.semanticsLabel, }); @override Widget build(BuildContext context) { - return SizedBox( + Widget button = SizedBox( width: double.infinity, child: ElevatedButton( onPressed: onPressed, @@ -36,22 +38,28 @@ class PrimaryButton extends StatelessWidget { ), ), ); + if (semanticsLabel != null) { + button = Semantics(identifier: semanticsLabel, container: true, child: button); + } + return button; } } class DestructiveButton extends StatelessWidget { final String label; final VoidCallback? onPressed; + final String? semanticsLabel; const DestructiveButton({ super.key, required this.label, this.onPressed, + this.semanticsLabel, }); @override Widget build(BuildContext context) { - return SizedBox( + Widget button = SizedBox( width: double.infinity, child: OutlinedButton( onPressed: onPressed, @@ -62,5 +70,9 @@ class DestructiveButton extends StatelessWidget { child: Text(label), ), ); + if (semanticsLabel != null) { + button = Semantics(identifier: semanticsLabel, container: true, child: button); + } + return button; } } diff --git a/examples/demo/lib/widgets/dialogs.dart b/examples/demo/lib/widgets/dialogs.dart index 9f6e3655..bcf065b6 100644 --- a/examples/demo/lib/widgets/dialogs.dart +++ b/examples/demo/lib/widgets/dialogs.dart @@ -42,7 +42,8 @@ class _SingleInputDialogState extends State { content: SizedBox( width: double.maxFinite, child: Semantics( - label: '${widget.fieldLabel}_input', + identifier: '${widget.fieldLabel}_input', + container: true, child: AppTextField( controller: _controller, decoration: InputDecoration(labelText: widget.fieldLabel), @@ -72,12 +73,18 @@ class PairInputDialog extends StatefulWidget { final String title; final String keyLabel; final String valueLabel; + final String? keySemanticsLabel; + final String? valueSemanticsLabel; + final String? confirmSemanticsLabel; const PairInputDialog({ super.key, required this.title, this.keyLabel = 'Key', this.valueLabel = 'Value', + this.keySemanticsLabel, + this.valueSemanticsLabel, + this.confirmSemanticsLabel, }); @override @@ -109,7 +116,8 @@ class _PairInputDialogState extends State { children: [ Expanded( child: Semantics( - label: '${widget.keyLabel}_input', + identifier: widget.keySemanticsLabel ?? '${widget.keyLabel}_input', + container: true, child: AppTextField( controller: _keyController, decoration: InputDecoration(labelText: widget.keyLabel), @@ -120,7 +128,8 @@ class _PairInputDialogState extends State { const SizedBox(width: 12), Expanded( child: Semantics( - label: '${widget.valueLabel}_input', + identifier: widget.valueSemanticsLabel ?? '${widget.valueLabel}_input', + container: true, child: AppTextField( controller: _valueController, decoration: InputDecoration(labelText: widget.valueLabel), @@ -136,14 +145,18 @@ class _PairInputDialogState extends State { onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), - TextButton( - onPressed: _isValid - ? () => Navigator.pop( - context, - MapEntry(_keyController.text, _valueController.text), - ) - : null, - child: const Text('Add'), + Semantics( + identifier: widget.confirmSemanticsLabel ?? 'confirm_button', + container: true, + child: TextButton( + onPressed: _isValid + ? () => Navigator.pop( + context, + MapEntry(_keyController.text, _valueController.text), + ) + : null, + child: const Text('Add'), + ), ), ], ); @@ -232,21 +245,29 @@ class _MultiPairInputDialogState extends State { Row( children: [ Expanded( - child: AppTextField( - controller: _keyControllers[i], - decoration: InputDecoration( - labelText: widget.keyLabel, - isDense: true, + child: Semantics( + identifier: '${widget.keyLabel}_input_$i', + container: true, + child: AppTextField( + controller: _keyControllers[i], + decoration: InputDecoration( + labelText: widget.keyLabel, + isDense: true, + ), ), ), ), const SizedBox(width: 8), Expanded( - child: AppTextField( - controller: _valueControllers[i], - decoration: InputDecoration( - labelText: widget.valueLabel, - isDense: true, + child: Semantics( + identifier: '${widget.valueLabel}_input_$i', + container: true, + child: AppTextField( + controller: _valueControllers[i], + decoration: InputDecoration( + labelText: widget.valueLabel, + isDense: true, + ), ), ), ), @@ -320,20 +341,24 @@ class _MultiSelectRemoveDialogState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: widget.items.map((item) { - return CheckboxListTile( - title: Text(item.key), - value: _selected.contains(item.key), - controlAffinity: ListTileControlAffinity.leading, - onChanged: (checked) { - setState(() { - if (checked == true) { - _selected.add(item.key); - } else { - _selected.remove(item.key); - } - }); - }, - contentPadding: EdgeInsets.zero, + return Semantics( + identifier: 'remove_checkbox_${item.key}', + container: true, + child: CheckboxListTile( + title: Text(item.key), + value: _selected.contains(item.key), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (checked) { + setState(() { + if (checked == true) { + _selected.add(item.key); + } else { + _selected.remove(item.key); + } + }); + }, + contentPadding: EdgeInsets.zero, + ), ); }).toList(), ), @@ -380,7 +405,8 @@ class _LoginDialogState extends State { content: SizedBox( width: double.maxFinite, child: Semantics( - label: 'external_user_id_input', + identifier: 'login_user_id_input', + container: true, child: AppTextField( controller: _controller, decoration: const InputDecoration(labelText: 'External User Id'), @@ -393,11 +419,15 @@ class _LoginDialogState extends State { onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), - TextButton( - onPressed: _controller.text.isEmpty - ? null - : () => Navigator.pop(context, _controller.text), - child: const Text('Login'), + Semantics( + identifier: 'login_confirm_button', + container: true, + child: TextButton( + onPressed: _controller.text.isEmpty + ? null + : () => Navigator.pop(context, _controller.text), + child: const Text('Login'), + ), ), ], ); @@ -469,19 +499,27 @@ class _OutcomeDialogState extends State { ), ), const SizedBox(height: 8), - AppTextField( - controller: _nameController, - decoration: const InputDecoration(labelText: 'Outcome Name'), - onChanged: (_) => setState(() {}), + Semantics( + identifier: 'outcome_name_input', + container: true, + child: AppTextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Outcome Name'), + onChanged: (_) => setState(() {}), + ), ), if (_type == OutcomeType.withValue) ...[ const SizedBox(height: 12), - AppTextField( - controller: _valueController, - decoration: const InputDecoration(labelText: 'Value'), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - onChanged: (_) => setState(() {}), + Semantics( + identifier: 'outcome_value_input', + container: true, + child: AppTextField( + controller: _valueController, + decoration: const InputDecoration(labelText: 'Value'), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => setState(() {}), + ), ), ], ], @@ -493,19 +531,23 @@ class _OutcomeDialogState extends State { onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), - TextButton( - onPressed: _isValid - ? () { - Navigator.pop(context, { - 'type': _type, - 'name': _nameController.text, - 'value': _type == OutcomeType.withValue - ? double.parse(_valueController.text) - : null, - }); - } - : null, - child: const Text('Send'), + Semantics( + identifier: 'outcome_send_button', + container: true, + child: TextButton( + onPressed: _isValid + ? () { + Navigator.pop(context, { + 'type': _type, + 'name': _nameController.text, + 'value': _type == OutcomeType.withValue + ? double.parse(_valueController.text) + : null, + }); + } + : null, + child: const Text('Send'), + ), ), ], ); @@ -557,28 +599,36 @@ class _TrackEventDialogState extends State { Widget build(BuildContext context) { return AlertDialog( insetPadding: const EdgeInsets.symmetric(horizontal: 16), - title: const Text('Track Event'), + title: const Text('Custom Event'), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - AppTextField( - controller: _nameController, - decoration: const InputDecoration(labelText: 'Event Name'), - onChanged: (_) => setState(() {}), + Semantics( + identifier: 'event_name_input', + container: true, + child: AppTextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Event Name'), + onChanged: (_) => setState(() {}), + ), ), const SizedBox(height: 12), - AppTextField( - controller: _propsController, - decoration: InputDecoration( - labelText: 'Properties (optional, JSON)', - hintText: '{"key": "value"}', - errorText: _jsonError, + Semantics( + identifier: 'event_properties_input', + container: true, + child: AppTextField( + controller: _propsController, + decoration: InputDecoration( + labelText: 'Properties (optional, JSON)', + hintText: '{"key": "value"}', + errorText: _jsonError, + ), + maxLines: 3, + onChanged: _validateJson, ), - maxLines: 3, - onChanged: _validateJson, ), ], ), @@ -589,21 +639,25 @@ class _TrackEventDialogState extends State { onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), - TextButton( - onPressed: _isValid - ? () { - Map? props; - if (_propsController.text.isNotEmpty) { - props = jsonDecode(_propsController.text) - as Map; + Semantics( + identifier: 'event_track_button', + container: true, + child: TextButton( + onPressed: _isValid + ? () { + Map? props; + if (_propsController.text.isNotEmpty) { + props = jsonDecode(_propsController.text) + as Map; + } + Navigator.pop(context, { + 'name': _nameController.text, + 'properties': props, + }); } - Navigator.pop(context, { - 'name': _nameController.text, - 'properties': props, - }); - } - : null, - child: const Text('Track'), + : null, + child: const Text('Track'), + ), ), ], ); @@ -686,7 +740,11 @@ class TooltipDialog extends StatelessWidget { Widget build(BuildContext context) { return AlertDialog( insetPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text(tooltip.title), + title: Semantics( + identifier: 'tooltip_title', + container: true, + child: Text(tooltip.title), + ), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( @@ -694,7 +752,11 @@ class TooltipDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tooltip.description), + Semantics( + identifier: 'tooltip_description', + container: true, + child: Text(tooltip.description), + ), if (tooltip.options != null && tooltip.options!.isNotEmpty) ...[ const SizedBox(height: 16), ...tooltip.options!.map((option) => Padding( diff --git a/examples/demo/lib/widgets/list_widgets.dart b/examples/demo/lib/widgets/list_widgets.dart index 7b9fbeca..17fba5fe 100644 --- a/examples/demo/lib/widgets/list_widgets.dart +++ b/examples/demo/lib/widgets/list_widgets.dart @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; import '../theme.dart'; class PairItem extends StatelessWidget { + final String sectionKey; final String keyText; final String valueText; final VoidCallback? onDelete; const PairItem({ super.key, + required this.sectionKey, required this.keyText, required this.valueText, this.onDelete, @@ -24,23 +26,35 @@ class PairItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - keyText, - style: Theme.of(context).textTheme.bodyMedium, + Semantics( + identifier: '${sectionKey}_pair_key_$keyText', + container: true, + child: Text( + keyText, + style: Theme.of(context).textTheme.bodyMedium, + ), ), - Text( - valueText, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.osGrey600, - ), + Semantics( + identifier: '${sectionKey}_pair_value_$keyText', + container: true, + child: Text( + valueText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.osGrey600, + ), + ), ), ], ), ), if (onDelete != null) - GestureDetector( - onTap: onDelete, - child: Icon(Icons.close, size: 18, color: AppColors.osPrimary), + Semantics( + identifier: '${sectionKey}_remove_$keyText', + container: true, + child: GestureDetector( + onTap: onDelete, + child: Icon(Icons.close, size: 18, color: AppColors.osPrimary), + ), ), ], ), @@ -49,11 +63,13 @@ class PairItem extends StatelessWidget { } class SingleItem extends StatelessWidget { + final String sectionKey; final String text; final VoidCallback? onDelete; const SingleItem({ super.key, + required this.sectionKey, required this.text, this.onDelete, }); @@ -66,9 +82,13 @@ class SingleItem extends StatelessWidget { children: [ Expanded(child: Text(text, style: Theme.of(context).textTheme.bodyMedium)), if (onDelete != null) - GestureDetector( - onTap: onDelete, - child: Icon(Icons.close, size: 18, color: AppColors.osPrimary), + Semantics( + identifier: '${sectionKey}_remove_$text', + container: true, + child: GestureDetector( + onTap: onDelete, + child: Icon(Icons.close, size: 18, color: AppColors.osPrimary), + ), ), ], ), @@ -98,12 +118,14 @@ class EmptyState extends StatelessWidget { } class PairList extends StatelessWidget { + final String sectionKey; final List> items; final String emptyText; final void Function(String key)? onDelete; const PairList({ super.key, + required this.sectionKey, required this.items, required this.emptyText, this.onDelete, @@ -118,6 +140,7 @@ class PairList extends StatelessWidget { for (var i = 0; i < items.length; i++) ...[ PairItem( key: ValueKey('${items[i].key}_${items[i].value}'), + sectionKey: sectionKey, keyText: items[i].key, valueText: items[i].value, onDelete: @@ -131,6 +154,7 @@ class PairList extends StatelessWidget { } class CollapsibleList extends StatefulWidget { + final String sectionKey; final List items; final String emptyText; final void Function(String item) onDelete; @@ -138,6 +162,7 @@ class CollapsibleList extends StatefulWidget { const CollapsibleList({ super.key, + required this.sectionKey, required this.items, required this.emptyText, required this.onDelete, @@ -165,6 +190,7 @@ class _CollapsibleListState extends State { for (var i = 0; i < visibleItems.length; i++) ...[ SingleItem( key: ValueKey(visibleItems[i]), + sectionKey: widget.sectionKey, text: visibleItems[i], onDelete: () => widget.onDelete(visibleItems[i]), ), diff --git a/examples/demo/lib/widgets/log_view.dart b/examples/demo/lib/widgets/log_view.dart deleted file mode 100644 index 1fb43cfe..00000000 --- a/examples/demo/lib/widgets/log_view.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../services/log_manager.dart'; -import '../theme.dart'; - -class LogView extends StatefulWidget { - const LogView({super.key}); - - @override - State createState() => _LogViewState(); -} - -class _LogViewState extends State { - bool _expanded = true; - final ScrollController _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - LogManager().addListener(_onLogChanged); - } - - @override - void dispose() { - LogManager().removeListener(_onLogChanged); - _scrollController.dispose(); - super.dispose(); - } - - void _onLogChanged() { - setState(() {}); - } - - Color _levelColor(LogLevel level) { - switch (level) { - case LogLevel.debug: - return AppColors.osLogDebug; - case LogLevel.info: - return AppColors.osLogInfo; - case LogLevel.warn: - return AppColors.osLogWarn; - case LogLevel.error: - return AppColors.osLogError; - } - } - - @override - Widget build(BuildContext context) { - final logs = LogManager().logs; - final textTheme = Theme.of(context).textTheme; - final logEntryStyle = textTheme.labelSmall?.copyWith( - fontFamily: 'monospace', - ); - - const logBackground = AppColors.osLogBackground; - - return Semantics( - label: 'log_view_container', - child: Card( - margin: EdgeInsets.zero, - color: logBackground, - shape: const RoundedRectangleBorder(), - child: Column( - children: [ - Semantics( - label: 'log_view_header', - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - borderRadius: BorderRadius.zero, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Row( - children: [ - Text( - 'LOGS', - style: textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(width: 8), - Semantics( - label: 'log_view_count', - child: Text( - '(${logs.length})', - style: textTheme.labelSmall?.copyWith( - color: AppColors.osGrey500, - ), - ), - ), - const Spacer(), - if (logs.isNotEmpty) - Semantics( - label: 'log_view_clear_button', - child: GestureDetector( - onTap: () => LogManager().clear(), - child: const Icon( - Icons.delete, - size: 18, - color: AppColors.osGrey500, - ), - ), - ), - const SizedBox(width: 8), - Icon( - _expanded - ? Icons.expand_less - : Icons.expand_more, - size: 18, - color: AppColors.osGrey500, - ), - ], - ), - ), - ), - ), - if (_expanded) - Semantics( - label: 'log_view_list', - child: SizedBox( - height: 100, - child: logs.isEmpty - ? Semantics( - label: 'log_view_empty', - child: Center( - child: Text( - 'No logs yet', - style: textTheme.bodyMedium?.copyWith( - color: AppColors.osGrey500, - ), - ), - ), - ) - : ListView.builder( - controller: _scrollController, - itemCount: logs.length, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemBuilder: (context, index) { - final entry = logs[logs.length - 1 - index]; - return Semantics( - label: 'log_entry_$index', - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 1, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Semantics( - label: 'log_entry_${index}_timestamp', - child: Text( - entry.formattedTime, - style: logEntryStyle?.copyWith( - color: AppColors.osLogTimestamp, - ), - ), - ), - const SizedBox(width: 4), - Semantics( - label: 'log_entry_${index}_level', - child: Text( - entry.levelLabel, - style: logEntryStyle?.copyWith( - fontWeight: FontWeight.bold, - color: _levelColor(entry.level), - ), - ), - ), - const SizedBox(width: 4), - Expanded( - child: Semantics( - label: 'log_entry_${index}_message', - child: Text( - '${entry.tag}: ${entry.message}', - style: logEntryStyle?.copyWith( - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ); - }, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/examples/demo/lib/widgets/section_card.dart b/examples/demo/lib/widgets/section_card.dart index 8c5b3c6d..5e261f87 100644 --- a/examples/demo/lib/widgets/section_card.dart +++ b/examples/demo/lib/widgets/section_card.dart @@ -5,44 +5,63 @@ import '../theme.dart'; class SectionCard extends StatelessWidget { final String title; final VoidCallback? onInfoTap; + final String? sectionKey; final Widget child; const SectionCard({ super.key, required this.title, this.onInfoTap, + this.sectionKey, required this.child, }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( + return Semantics( + identifier: sectionKey != null ? '${sectionKey}_section' : null, + container: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Section header (outside card, ALL CAPS like reference) Padding( - padding: EdgeInsets.only(bottom: AppSpacing.gap), + padding: EdgeInsets.only(bottom: onInfoTap != null ? 0 : AppSpacing.gap), child: Row( children: [ Expanded( child: Text( title.toUpperCase(), style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, - color: AppColors.osGrey700, - letterSpacing: 0.5, - ), + fontWeight: FontWeight.bold, + color: AppColors.osGrey700, + letterSpacing: 0.5, + ), ), ), if (onInfoTap != null) - GestureDetector( - onTap: onInfoTap, - child: Icon( - Icons.info_outline, - size: 18, - color: AppColors.osGrey500, + Transform.translate( + offset: const Offset(16, 0), + child: Semantics( + identifier: sectionKey != null + ? '${sectionKey}_info_icon' + : null, + container: true, + child: IconButton( + onPressed: onInfoTap, + icon: Icon( + Icons.info_outline, + size: 18, + color: AppColors.osGrey500, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), ), ), ], @@ -52,6 +71,7 @@ class SectionCard extends StatelessWidget { child, ], ), + ), ); } } diff --git a/examples/demo/lib/widgets/sections/aliases_section.dart b/examples/demo/lib/widgets/sections/aliases_section.dart index b17c6b87..48cba224 100644 --- a/examples/demo/lib/widgets/sections/aliases_section.dart +++ b/examples/demo/lib/widgets/sections/aliases_section.dart @@ -19,6 +19,7 @@ class AliasesSection extends StatelessWidget { return SectionCard( title: 'Aliases', + sectionKey: 'aliases', onInfoTap: onInfoTap, child: Column( children: [ @@ -27,6 +28,7 @@ class AliasesSection extends StatelessWidget { child: Padding( padding: AppSpacing.cardPadding, child: PairList( + sectionKey: 'aliases', items: vm.aliasesList, emptyText: 'No aliases added', ), @@ -34,7 +36,7 @@ class AliasesSection extends StatelessWidget { ), AppSpacing.gapBox, PrimaryButton( - label: 'ADD', + label: 'ADD ALIAS', onPressed: () async { final result = await showDialog>( context: context, @@ -42,6 +44,9 @@ class AliasesSection extends StatelessWidget { title: 'Add Alias', keyLabel: 'Label', valueLabel: 'ID', + keySemanticsLabel: 'alias_label_input', + valueSemanticsLabel: 'alias_id_input', + confirmSemanticsLabel: 'alias_confirm_button', ), ); if (result != null) { @@ -51,7 +56,7 @@ class AliasesSection extends StatelessWidget { ), AppSpacing.gapBox, PrimaryButton( - label: 'ADD MULTIPLE', + label: 'ADD MULTIPLE ALIASES', onPressed: () async { final result = await showDialog>( context: context, diff --git a/examples/demo/lib/widgets/sections/app_section.dart b/examples/demo/lib/widgets/sections/app_section.dart index 7aa07eb8..06cc5657 100644 --- a/examples/demo/lib/widgets/sections/app_section.dart +++ b/examples/demo/lib/widgets/sections/app_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -7,6 +8,8 @@ import '../../viewmodels/app_viewmodel.dart'; import '../section_card.dart'; import '../toggle_row.dart'; +final bool _isE2E = dotenv.env['E2E_MODE'] == 'true'; + class AppSection extends StatelessWidget { final VoidCallback? onInfoTap; @@ -18,6 +21,7 @@ class AppSection extends StatelessWidget { return SectionCard( title: 'App', + sectionKey: 'app', onInfoTap: onInfoTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -32,12 +36,16 @@ class AppSection extends StatelessWidget { Text('App ID', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(width: 12), Expanded( - child: SelectableText( - vm.appId, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - textAlign: TextAlign.end, + child: Semantics( + identifier: 'app_id_value', + container: true, + child: SelectableText( + _isE2E ? '••••••••-••••-••••-••••-••••••••••••' : vm.appId, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + textAlign: TextAlign.end, + ), ), ), ], diff --git a/examples/demo/lib/widgets/sections/track_event_section.dart b/examples/demo/lib/widgets/sections/custom_events_section.dart similarity index 68% rename from examples/demo/lib/widgets/sections/track_event_section.dart rename to examples/demo/lib/widgets/sections/custom_events_section.dart index 0b53962e..ae3e21b5 100644 --- a/examples/demo/lib/widgets/sections/track_event_section.dart +++ b/examples/demo/lib/widgets/sections/custom_events_section.dart @@ -1,22 +1,24 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../theme.dart'; import '../../viewmodels/app_viewmodel.dart'; import '../action_button.dart'; import '../dialogs.dart'; import '../section_card.dart'; -class TrackEventSection extends StatelessWidget { +class CustomEventsSection extends StatelessWidget { final VoidCallback? onInfoTap; - const TrackEventSection({super.key, this.onInfoTap}); + const CustomEventsSection({super.key, this.onInfoTap}); @override Widget build(BuildContext context) { final vm = context.read(); return SectionCard( - title: 'Track Event', + title: 'Custom Events', + sectionKey: 'custom_events', onInfoTap: onInfoTap, child: PrimaryButton( label: 'TRACK EVENT', @@ -26,10 +28,14 @@ class TrackEventSection extends StatelessWidget { builder: (_) => const TrackEventDialog(), ); if (result != null) { + final name = result['name'] as String; vm.trackEvent( - result['name'] as String, + name, result['properties'] as Map?, ); + if (context.mounted) { + context.showSnackBar('Event tracked: $name'); + } } }, ), diff --git a/examples/demo/lib/widgets/sections/emails_section.dart b/examples/demo/lib/widgets/sections/emails_section.dart index 95236ae0..2b424cb3 100644 --- a/examples/demo/lib/widgets/sections/emails_section.dart +++ b/examples/demo/lib/widgets/sections/emails_section.dart @@ -19,6 +19,7 @@ class EmailsSection extends StatelessWidget { return SectionCard( title: 'Emails', + sectionKey: 'emails', onInfoTap: onInfoTap, child: Column( children: [ @@ -27,6 +28,7 @@ class EmailsSection extends StatelessWidget { child: Padding( padding: AppSpacing.cardPadding, child: CollapsibleList( + sectionKey: 'emails', items: vm.emailsList, emptyText: 'No emails added', onDelete: vm.removeEmail, diff --git a/examples/demo/lib/widgets/sections/in_app_section.dart b/examples/demo/lib/widgets/sections/in_app_section.dart index acb2ba0a..c8abfed3 100644 --- a/examples/demo/lib/widgets/sections/in_app_section.dart +++ b/examples/demo/lib/widgets/sections/in_app_section.dart @@ -17,6 +17,7 @@ class InAppSection extends StatelessWidget { return SectionCard( title: 'In-App Messaging', + sectionKey: 'iam', onInfoTap: onInfoTap, child: Card( margin: EdgeInsets.zero, @@ -25,6 +26,7 @@ class InAppSection extends StatelessWidget { child: ToggleRow( label: 'Pause In-App Messages', description: 'Toggle in-app message display', + semanticsLabel: 'pause_iam_toggle', value: vm.iamPaused, onChanged: vm.setIamPaused, ), diff --git a/examples/demo/lib/widgets/sections/live_activities_section.dart b/examples/demo/lib/widgets/sections/live_activities_section.dart index 6e5729e4..32f2cb66 100644 --- a/examples/demo/lib/widgets/sections/live_activities_section.dart +++ b/examples/demo/lib/widgets/sections/live_activities_section.dart @@ -42,6 +42,7 @@ class _LiveActivitiesSectionState extends State { return SectionCard( title: 'Live Activities', + sectionKey: 'live_activities', onInfoTap: widget.onInfoTap, child: Column( children: [ @@ -82,18 +83,11 @@ class _LiveActivitiesSectionState extends State { : () => vm.updateLiveActivity(), ), AppSpacing.gapBox, - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: activityEmpty || !vm.hasApiKey - ? null - : () => vm.endLiveActivity(), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.osPrimary, - side: const BorderSide(color: AppColors.osPrimary), - ), - child: const Text('END LIVE ACTIVITY'), - ), + DestructiveButton( + label: 'END LIVE ACTIVITY', + onPressed: activityEmpty || !vm.hasApiKey + ? null + : () => vm.endLiveActivity(), ), ], ), diff --git a/examples/demo/lib/widgets/sections/location_section.dart b/examples/demo/lib/widgets/sections/location_section.dart index bdb5d304..b5918e2b 100644 --- a/examples/demo/lib/widgets/sections/location_section.dart +++ b/examples/demo/lib/widgets/sections/location_section.dart @@ -18,6 +18,7 @@ class LocationSection extends StatelessWidget { return SectionCard( title: 'Location', + sectionKey: 'location', onInfoTap: onInfoTap, child: Column( children: [ @@ -38,6 +39,16 @@ class LocationSection extends StatelessWidget { label: 'PROMPT LOCATION', onPressed: vm.promptLocation, ), + AppSpacing.gapBox, + PrimaryButton( + label: 'CHECK LOCATION SHARED', + onPressed: () async { + final shared = await vm.checkLocationShared(); + if (context.mounted) { + context.showSnackBar('Location shared: $shared'); + } + }, + ), ], ), ); diff --git a/examples/demo/lib/widgets/sections/outcomes_section.dart b/examples/demo/lib/widgets/sections/outcomes_section.dart index bfa08ac3..17e2a7f3 100644 --- a/examples/demo/lib/widgets/sections/outcomes_section.dart +++ b/examples/demo/lib/widgets/sections/outcomes_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../theme.dart'; import '../../viewmodels/app_viewmodel.dart'; import '../action_button.dart'; import '../dialogs.dart'; @@ -16,7 +17,8 @@ class OutcomesSection extends StatelessWidget { final vm = context.read(); return SectionCard( - title: 'Outcome Events', + title: 'Outcomes', + sectionKey: 'outcomes', onInfoTap: onInfoTap, child: PrimaryButton( label: 'SEND OUTCOME', @@ -28,13 +30,21 @@ class OutcomesSection extends StatelessWidget { if (result != null) { final type = result['type'] as OutcomeType; final name = result['name'] as String; + String snackbarMessage; switch (type) { case OutcomeType.normal: vm.sendOutcome(name); + snackbarMessage = 'Outcome sent: $name'; case OutcomeType.unique: vm.sendUniqueOutcome(name); + snackbarMessage = 'Unique outcome sent: $name'; case OutcomeType.withValue: - vm.sendOutcomeWithValue(name, result['value'] as double); + final value = result['value'] as double; + vm.sendOutcomeWithValue(name, value); + snackbarMessage = 'Outcome sent: $name = $value'; + } + if (context.mounted) { + context.showSnackBar(snackbarMessage); } } }, diff --git a/examples/demo/lib/widgets/sections/push_section.dart b/examples/demo/lib/widgets/sections/push_section.dart index 6d9fe108..331f85b0 100644 --- a/examples/demo/lib/widgets/sections/push_section.dart +++ b/examples/demo/lib/widgets/sections/push_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:provider/provider.dart'; import '../../theme.dart'; @@ -7,6 +8,8 @@ import '../action_button.dart'; import '../section_card.dart'; import '../toggle_row.dart'; +final bool _isE2E = dotenv.env['E2E_MODE'] == 'true'; + class PushSection extends StatelessWidget { final VoidCallback? onInfoTap; @@ -18,6 +21,7 @@ class PushSection extends StatelessWidget { return SectionCard( title: 'Push', + sectionKey: 'push', onInfoTap: onInfoTap, child: Column( children: [ @@ -35,12 +39,16 @@ class PushSection extends StatelessWidget { ), const SizedBox(width: 12), Expanded( - child: SelectableText( - vm.pushSubscriptionId ?? 'N/A', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - textAlign: TextAlign.end, + child: Semantics( + identifier: 'push_id_value', + container: true, + child: SelectableText( + _isE2E ? '••••••••-••••-••••-••••-••••••••••••' : (vm.pushSubscriptionId ?? 'N/A'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + textAlign: TextAlign.end, + ), ), ), ], @@ -49,6 +57,7 @@ class PushSection extends StatelessWidget { ToggleRow( label: 'Enabled', value: vm.pushEnabled, + semanticsLabel: 'push_enabled_toggle', onChanged: vm.hasNotificationPermission ? vm.togglePush : null, diff --git a/examples/demo/lib/widgets/sections/send_iam_section.dart b/examples/demo/lib/widgets/sections/send_iam_section.dart index 2473a673..9e67a9ea 100644 --- a/examples/demo/lib/widgets/sections/send_iam_section.dart +++ b/examples/demo/lib/widgets/sections/send_iam_section.dart @@ -17,6 +17,7 @@ class SendIamSection extends StatelessWidget { return SectionCard( title: 'Send In-App Message', + sectionKey: 'send_iam', onInfoTap: onInfoTap, child: Column( spacing: AppSpacing.gap, diff --git a/examples/demo/lib/widgets/sections/send_push_section.dart b/examples/demo/lib/widgets/sections/send_push_section.dart index ef1b327c..3ea7a965 100644 --- a/examples/demo/lib/widgets/sections/send_push_section.dart +++ b/examples/demo/lib/widgets/sections/send_push_section.dart @@ -19,26 +19,31 @@ class SendPushSection extends StatelessWidget { return SectionCard( title: 'Send Push Notification', + sectionKey: 'send_push', onInfoTap: onInfoTap, child: Column( children: [ PrimaryButton( label: 'SIMPLE', + semanticsLabel: 'send_simple_button', onPressed: () => vm.sendNotification(NotificationType.simple), ), AppSpacing.gapBox, PrimaryButton( label: 'WITH IMAGE', + semanticsLabel: 'send_image_button', onPressed: () => vm.sendNotification(NotificationType.withImage), ), AppSpacing.gapBox, PrimaryButton( label: 'WITH SOUND', + semanticsLabel: 'send_sound_button', onPressed: () => vm.sendNotification(NotificationType.withSound), ), AppSpacing.gapBox, PrimaryButton( label: 'CUSTOM', + semanticsLabel: 'send_custom_button', onPressed: () async { final result = await showDialog>( context: context, @@ -52,6 +57,7 @@ class SendPushSection extends StatelessWidget { AppSpacing.gapBox, DestructiveButton( label: 'CLEAR ALL', + semanticsLabel: 'clear_all_button', onPressed: vm.clearAllNotifications, ), ], diff --git a/examples/demo/lib/widgets/sections/sms_section.dart b/examples/demo/lib/widgets/sections/sms_section.dart index 1a0d690e..b6040f53 100644 --- a/examples/demo/lib/widgets/sections/sms_section.dart +++ b/examples/demo/lib/widgets/sections/sms_section.dart @@ -19,6 +19,7 @@ class SmsSection extends StatelessWidget { return SectionCard( title: 'SMS', + sectionKey: 'sms', onInfoTap: onInfoTap, child: Column( children: [ @@ -27,6 +28,7 @@ class SmsSection extends StatelessWidget { child: Padding( padding: AppSpacing.cardPadding, child: CollapsibleList( + sectionKey: 'sms', items: vm.smsNumbersList, emptyText: 'No SMS added', onDelete: vm.removeSms, diff --git a/examples/demo/lib/widgets/sections/tags_section.dart b/examples/demo/lib/widgets/sections/tags_section.dart index ec2c7a19..9332ade1 100644 --- a/examples/demo/lib/widgets/sections/tags_section.dart +++ b/examples/demo/lib/widgets/sections/tags_section.dart @@ -19,6 +19,7 @@ class TagsSection extends StatelessWidget { return SectionCard( title: 'Tags', + sectionKey: 'tags', onInfoTap: onInfoTap, child: Column( children: [ @@ -27,6 +28,7 @@ class TagsSection extends StatelessWidget { child: Padding( padding: AppSpacing.cardPadding, child: PairList( + sectionKey: 'tags', items: vm.tagsList, emptyText: 'No tags added', onDelete: vm.removeTag, @@ -35,11 +37,17 @@ class TagsSection extends StatelessWidget { ), AppSpacing.gapBox, PrimaryButton( - label: 'ADD', + label: 'ADD TAG', + semanticsLabel: 'add_tag_button', onPressed: () async { final result = await showDialog>( context: context, - builder: (_) => const PairInputDialog(title: 'Add Tag'), + builder: (_) => const PairInputDialog( + title: 'Add Tag', + keySemanticsLabel: 'tag_key_input', + valueSemanticsLabel: 'tag_value_input', + confirmSemanticsLabel: 'tag_confirm_button', + ), ); if (result != null) { vm.addTag(result.key, result.value); @@ -48,7 +56,7 @@ class TagsSection extends StatelessWidget { ), AppSpacing.gapBox, PrimaryButton( - label: 'ADD MULTIPLE', + label: 'ADD MULTIPLE TAGS', onPressed: () async { final result = await showDialog>( context: context, @@ -63,7 +71,7 @@ class TagsSection extends StatelessWidget { if (vm.tagsList.isNotEmpty) ...[ AppSpacing.gapBox, DestructiveButton( - label: 'REMOVE SELECTED', + label: 'REMOVE TAGS', onPressed: () async { final result = await showDialog>( context: context, diff --git a/examples/demo/lib/widgets/sections/triggers_section.dart b/examples/demo/lib/widgets/sections/triggers_section.dart index 0ce8c5aa..2eee5fb2 100644 --- a/examples/demo/lib/widgets/sections/triggers_section.dart +++ b/examples/demo/lib/widgets/sections/triggers_section.dart @@ -19,6 +19,7 @@ class TriggersSection extends StatelessWidget { return SectionCard( title: 'Triggers', + sectionKey: 'triggers', onInfoTap: onInfoTap, child: Column( children: [ @@ -27,6 +28,7 @@ class TriggersSection extends StatelessWidget { child: Padding( padding: AppSpacing.cardPadding, child: PairList( + sectionKey: 'triggers', items: vm.triggersList, emptyText: 'No triggers added', onDelete: vm.removeTrigger, @@ -35,11 +37,16 @@ class TriggersSection extends StatelessWidget { ), AppSpacing.gapBox, PrimaryButton( - label: 'ADD', + label: 'ADD TRIGGER', onPressed: () async { final result = await showDialog>( context: context, - builder: (_) => const PairInputDialog(title: 'Add Trigger'), + builder: (_) => const PairInputDialog( + title: 'Add Trigger', + keySemanticsLabel: 'trigger_key_input', + valueSemanticsLabel: 'trigger_value_input', + confirmSemanticsLabel: 'trigger_confirm_button', + ), ); if (result != null) { vm.addTrigger(result.key, result.value); @@ -48,7 +55,7 @@ class TriggersSection extends StatelessWidget { ), AppSpacing.gapBox, PrimaryButton( - label: 'ADD MULTIPLE', + label: 'ADD MULTIPLE TRIGGERS', onPressed: () async { final result = await showDialog>( context: context, @@ -64,7 +71,7 @@ class TriggersSection extends StatelessWidget { if (vm.triggersList.isNotEmpty) ...[ AppSpacing.gapBox, DestructiveButton( - label: 'REMOVE SELECTED', + label: 'REMOVE TRIGGERS', onPressed: () async { final result = await showDialog>( context: context, @@ -80,7 +87,7 @@ class TriggersSection extends StatelessWidget { ), AppSpacing.gapBox, DestructiveButton( - label: 'CLEAR ALL', + label: 'CLEAR ALL TRIGGERS', onPressed: vm.clearAllTriggers, ), ], diff --git a/examples/demo/lib/widgets/sections/user_section.dart b/examples/demo/lib/widgets/sections/user_section.dart index 7ef3d0f6..b9331413 100644 --- a/examples/demo/lib/widgets/sections/user_section.dart +++ b/examples/demo/lib/widgets/sections/user_section.dart @@ -18,6 +18,7 @@ class UserSection extends StatelessWidget { return SectionCard( title: 'User', + sectionKey: 'user', onInfoTap: onInfoTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -32,14 +33,18 @@ class UserSection extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Status', style: Theme.of(context).textTheme.bodyMedium), - Text( - vm.isLoggedIn ? 'Logged In' : 'Anonymous', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: vm.isLoggedIn - ? AppColors.osSuccess - : AppColors.osGrey600, - ), + Semantics( + identifier: 'user_status_value', + container: true, + child: Text( + vm.isLoggedIn ? 'Logged In' : 'Anonymous', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: vm.isLoggedIn + ? AppColors.osSuccess + : AppColors.osGrey600, + ), + ), ), ], ), @@ -51,11 +56,15 @@ class UserSection extends StatelessWidget { 'External ID', style: Theme.of(context).textTheme.bodyMedium, ), - SelectableText( - vm.isLoggedIn ? (vm.externalUserId ?? '') : '–', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), + Semantics( + identifier: 'user_external_id_value', + container: true, + child: SelectableText( + vm.isLoggedIn ? (vm.externalUserId ?? '') : '–', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), ), ], ), @@ -66,13 +75,17 @@ class UserSection extends StatelessWidget { AppSpacing.gapBox, PrimaryButton( label: vm.isLoggedIn ? 'SWITCH USER' : 'LOGIN USER', + semanticsLabel: 'login_user_button', onPressed: () async { final result = await showDialog( context: context, builder: (_) => const LoginDialog(), ); if (result != null && context.mounted) { - vm.loginUser(result); + await vm.loginUser(result); + if (context.mounted) { + context.showSnackBar('Logged in as $result'); + } } }, ), @@ -80,7 +93,13 @@ class UserSection extends StatelessWidget { AppSpacing.gapBox, DestructiveButton( label: 'LOGOUT USER', - onPressed: vm.logoutUser, + semanticsLabel: 'logout_user_button', + onPressed: () async { + await vm.logoutUser(); + if (context.mounted) { + context.showSnackBar('User logged out'); + } + }, ), ], ], diff --git a/examples/demo/lib/widgets/toggle_row.dart b/examples/demo/lib/widgets/toggle_row.dart index 39ad378c..c12e30c0 100644 --- a/examples/demo/lib/widgets/toggle_row.dart +++ b/examples/demo/lib/widgets/toggle_row.dart @@ -7,6 +7,7 @@ class ToggleRow extends StatelessWidget { final String? description; final bool value; final ValueChanged? onChanged; + final String? semanticsLabel; const ToggleRow({ super.key, @@ -14,33 +15,31 @@ class ToggleRow extends StatelessWidget { this.description, required this.value, this.onChanged, + this.semanticsLabel, }); @override Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: Theme.of(context).textTheme.bodyMedium), - if (description != null) - Text( - description!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: AppColors.osGrey600, - ), - ), - ], - ), - ), - Switch( - value: value, - onChanged: onChanged, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ], + Widget tile = SwitchListTile( + title: Text(label, style: Theme.of(context).textTheme.bodyMedium), + subtitle: description != null + ? Text( + description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.osGrey600, + ), + ) + : null, + value: value, + onChanged: onChanged != null ? (v) => onChanged!(v) : null, + contentPadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, ); + if (semanticsLabel != null) { + tile = Semantics( + identifier: semanticsLabel, container: true, child: tile); + } + return tile; } } diff --git a/examples/demo_spm/.env.example b/examples/demo_spm/.env.example new file mode 100644 index 00000000..f26801b6 --- /dev/null +++ b/examples/demo_spm/.env.example @@ -0,0 +1,2 @@ +ONESIGNAL_APP_ID=your-onesignal-app-id +ONESIGNAL_API_KEY=your-onesignal-api-key