diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcdde444..e887d3c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,12 @@ jobs: - name: Static Analysis run: flutter analyze + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + - name: Setup Clang uses: OneSignal/sdk-shared/.github/actions/setup-clang@main diff --git a/analysis_options.yaml b/analysis_options.yaml index e4ac80b9..f02a9360 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -5,3 +5,6 @@ analyzer: - example_pod/** - example_spm/** - examples/** + - build/** + - android/** + - ios/** diff --git a/examples/demo/analysis_options.yaml b/examples/demo/analysis_options.yaml index 0d290213..0d87f43e 100644 --- a/examples/demo/analysis_options.yaml +++ b/examples/demo/analysis_options.yaml @@ -7,6 +7,11 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** include: package:flutter_lints/flutter.yaml linter: diff --git a/examples/demo/lib/main.dart b/examples/demo/lib/main.dart index 8fdfefa2..38bffa95 100644 --- a/examples/demo/lib/main.dart +++ b/examples/demo/lib/main.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import 'screens/home_screen.dart'; import 'services/onesignal_api_service.dart'; +import 'services/log_service.dart'; import 'services/preferences_service.dart'; import 'services/tooltip_helper.dart'; import 'theme.dart'; @@ -48,35 +49,36 @@ Future main() async { // Register IAM listeners OneSignal.InAppMessages.addWillDisplayListener((event) { - debugPrint('[OneSignal] IAM willDisplay: ${event.message.messageId}'); + LogService.show('IAM willDisplay: ${event.message.messageId}'); }); OneSignal.InAppMessages.addDidDisplayListener((event) { - debugPrint('[OneSignal] IAM didDisplay: ${event.message.messageId}'); + LogService.show('IAM didDisplay: ${event.message.messageId}'); }); OneSignal.InAppMessages.addWillDismissListener((event) { - debugPrint('[OneSignal] IAM willDismiss: ${event.message.messageId}'); + LogService.show('IAM willDismiss: ${event.message.messageId}'); }); OneSignal.InAppMessages.addDidDismissListener((event) { - debugPrint('[OneSignal] IAM didDismiss: ${event.message.messageId}'); + LogService.show('IAM didDismiss: ${event.message.messageId}'); }); OneSignal.InAppMessages.addClickListener((event) { - debugPrint('[OneSignal] IAM click: ${event.message.messageId}'); + LogService.show('IAM click: ${event.message.messageId}'); }); // Register notification listeners OneSignal.Notifications.addClickListener((event) { - debugPrint( - '[OneSignal] Notification click: ${event.notification.title ?? ''}', + LogService.show( + 'Notification click: ${event.notification.notificationId} ' + '${event.notification.title ?? ''}', ); // Uncomment to see the full event object. // debugPrint('[OneSignal] click event: ${event.jsonRepresentation()}'); }); - + OneSignal.Notifications.addForegroundWillDisplayListener((event) { - debugPrint( - '[OneSignal] Notification foregroundWillDisplay: ' - '${event.notification.title ?? ''}', + LogService.show( + 'Notification foregroundWillDisplay: ' + '${event.notification.notificationId} ${event.notification.title ?? ''}', ); // Uncomment to see the full event object. diff --git a/examples/demo/lib/screens/home_screen.dart b/examples/demo/lib/screens/home_screen.dart index 20e8894d..fac1ed40 100644 --- a/examples/demo/lib/screens/home_screen.dart +++ b/examples/demo/lib/screens/home_screen.dart @@ -7,6 +7,7 @@ import '../theme.dart'; import '../services/tooltip_helper.dart'; import '../viewmodels/app_viewmodel.dart'; import '../widgets/dialogs.dart'; +import '../widgets/log_view.dart'; import '../widgets/sections/aliases_section.dart'; import '../widgets/sections/app_section.dart'; import '../widgets/sections/user_section.dart'; @@ -77,68 +78,88 @@ class _HomeScreenState extends State { ], ), ), - body: 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()), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.osPrimary, - foregroundColor: Colors.white, - ), - child: const Text('NEXT SCREEN'), + body: Column( + children: [ + const LogView(), + Expanded( + 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(), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.osPrimary, + foregroundColor: Colors.white, + ), + child: const Text('NEXT SCREEN'), + ), + ), + const SizedBox(height: 16), + ], ), ), - const SizedBox(height: 16), - ], - ), + ), + ], ), ); } diff --git a/examples/demo/lib/services/log_service.dart b/examples/demo/lib/services/log_service.dart new file mode 100644 index 00000000..256686dd --- /dev/null +++ b/examples/demo/lib/services/log_service.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +class LogEntry { + const LogEntry(this.time, this.message); + + final String time; + final String message; +} + +class LogService { + static const _maxEntries = 100; + + /// Log view entries are kept in memory only, so each launch starts empty. + static final ValueNotifier> entries = ValueNotifier(const []); + + static void info(String message) => _write(message); + + static void show(String message) { + _write(message); + _addToView(message); + } + + static void error(String message) { + _write('ERROR: $message'); + _addToView(message); + } + + static void _write(String message) => debugPrint('[OneSignal] $message'); + + static void _addToView(String message) { + final next = [ + ...entries.value, + LogEntry(_formatTime(DateTime.now()), message), + ]; + entries.value = + next.length > _maxEntries + ? next.sublist(next.length - _maxEntries) + : next; + } + + static void clear() => entries.value = const []; + + static String _formatTime(DateTime date) => [ + date.hour, + date.minute, + date.second, + ].map((part) => part.toString().padLeft(2, '0')).join(':'); + + LogService._(); +} diff --git a/examples/demo/lib/theme.dart b/examples/demo/lib/theme.dart index 60d10b72..5ff97b8e 100644 --- a/examples/demo/lib/theme.dart +++ b/examples/demo/lib/theme.dart @@ -20,12 +20,8 @@ class AppColors { static const osDivider = Color(0xFFE8EAED); static const osWarningBackground = Color(0xFFFFF8E1); static const osBackdrop = Color(0x8A000000); - static const osLogBackground = Color(0xFF1A1B1E); - static const osLogDebug = Color(0xFF82AAFF); - static const osLogInfo = Color(0xFFC3E88D); - static const osLogWarn = Color(0xFFFFCB6B); - static const osLogError = Color(0xFFFF5370); - static const osLogTimestamp = Color(0xFF676E7B); + static const osLogBackground = Color(0xFF1E1E1E); + static const osLogText = Color(0xFFD4D4D4); AppColors._(); } diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart index e2d69e96..588bef0f 100644 --- a/examples/demo/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo/lib/viewmodels/app_viewmodel.dart @@ -3,6 +3,7 @@ import 'package:onesignal_flutter/onesignal_flutter.dart'; import '../models/in_app_message_type.dart'; import '../models/notification_type.dart'; +import '../services/log_service.dart'; import '../services/onesignal_api_service.dart'; import '../services/preferences_service.dart'; @@ -168,8 +169,8 @@ class AppViewModel extends ChangeNotifier { return t.length > 8 ? '${t.substring(0, 8)}…' : t; } - debugPrint( - '[OneSignal] Push subscription changed: ' + LogService.show( + 'Push subscription changed: ' 'id=${state.previous.id ?? 'null'} → ${state.current.id ?? 'null'}, ' 'optedIn=${state.previous.optedIn} → ${state.current.optedIn}, ' 'token=${fmtToken(state.previous.token)} → ${fmtToken(state.current.token)}', @@ -179,14 +180,14 @@ class AppViewModel extends ChangeNotifier { OneSignal.Notifications.addPermissionObserver((permission) { _hasNotificationPermission = permission; - debugPrint('[OneSignal] Permission changed: $permission'); + LogService.show('Permission changed: $permission'); notifyListeners(); }); OneSignal.User.addObserver((state) { final nextOnesignalId = state.current.onesignalId; - debugPrint( - '[OneSignal] User changed: onesignalId=${nextOnesignalId ?? 'null'}, ' + LogService.show( + 'User changed: onesignalId=${nextOnesignalId ?? 'null'}, ' 'externalId=${state.current.externalId ?? 'null'}', ); @@ -251,11 +252,11 @@ class AppViewModel extends ChangeNotifier { try { await OneSignal.login(externalUserId); await _prefs.setExternalUserId(externalUserId); - debugPrint('Logged in as: $externalUserId'); + LogService.info('Logged in as: $externalUserId'); // The User observer runs fetchUserDataFromApi once the new onesignalId // is assigned; that call clears _isLoading in its finally. } catch (e) { - debugPrint('Login error: $e'); + LogService.error('Login error: $e'); _isLoading = false; notifyListeners(); } @@ -273,9 +274,9 @@ class AppViewModel extends ChangeNotifier { try { await OneSignal.logout(); await _prefs.setExternalUserId(null); - debugPrint('Logged out'); + LogService.info('Logged out'); } catch (e) { - debugPrint('Logout error: $e'); + LogService.error('Logout error: $e'); } } @@ -307,7 +308,7 @@ class AppViewModel extends ChangeNotifier { } _pushEnabled = enabled; notifyListeners(); - debugPrint('Push ${enabled ? "enabled" : "disabled"}'); + LogService.info('Push ${enabled ? "enabled" : "disabled"}'); } Future promptPush() => OneSignal.Notifications.requestPermission(true); @@ -316,21 +317,21 @@ class AppViewModel extends ChangeNotifier { Future sendNotification(NotificationType type) async { final subscriptionId = OneSignal.User.pushSubscription.id; if (subscriptionId == null) { - debugPrint('No subscription ID for notification'); + LogService.info('No subscription ID for notification'); return; } final success = await _apiService.sendNotification(type, subscriptionId); if (success) { - debugPrint('Notification sent: ${type.name}'); + LogService.info('Notification sent: ${type.name}'); } else { - debugPrint('Failed to send notification'); + LogService.info('Failed to send notification'); } } Future sendCustomNotification(String title, String body) async { final subscriptionId = OneSignal.User.pushSubscription.id; if (subscriptionId == null) { - debugPrint('No subscription ID for custom notification'); + LogService.info('No subscription ID for custom notification'); return; } final success = await _apiService.sendCustomNotification( @@ -339,15 +340,15 @@ class AppViewModel extends ChangeNotifier { subscriptionId, ); if (success) { - debugPrint('Custom notification sent'); + LogService.info('Custom notification sent'); } else { - debugPrint('Failed to send notification'); + LogService.info('Failed to send notification'); } } void clearAllNotifications() { OneSignal.Notifications.clearAll(); - debugPrint('All notifications cleared'); + LogService.info('All notifications cleared'); } // IAM @@ -362,7 +363,7 @@ class AppViewModel extends ChangeNotifier { OneSignal.InAppMessages.addTrigger('iam_type', type.triggerValue); _triggersList = _mergePairs(_triggersList, {'iam_type': type.triggerValue}); notifyListeners(); - debugPrint('Sent In-App Message: ${type.label}'); + LogService.info('Sent In-App Message: ${type.label}'); } // Aliases @@ -370,14 +371,14 @@ class AppViewModel extends ChangeNotifier { OneSignal.User.addAlias(label, id); _aliasesList = _mergePairs(_aliasesList, {label: id}); notifyListeners(); - debugPrint('Alias added: $label'); + LogService.info('Alias added: $label'); } void addAliases(Map aliases) { OneSignal.User.addAliases(aliases); _aliasesList = _mergePairs(_aliasesList, aliases); notifyListeners(); - debugPrint('${aliases.length} alias(es) added'); + LogService.info('${aliases.length} alias(es) added'); } // Emails @@ -385,14 +386,14 @@ class AppViewModel extends ChangeNotifier { OneSignal.User.addEmail(email); _emailsList = _mergeUnique(_emailsList, [email]); notifyListeners(); - debugPrint('Email added: $email'); + LogService.info('Email added: $email'); } void removeEmail(String email) { OneSignal.User.removeEmail(email); _emailsList = List.from(_emailsList)..remove(email); notifyListeners(); - debugPrint('Email removed: $email'); + LogService.info('Email removed: $email'); } // SMS @@ -400,14 +401,14 @@ class AppViewModel extends ChangeNotifier { OneSignal.User.addSms(smsNumber); _smsNumbersList = _mergeUnique(_smsNumbersList, [smsNumber]); notifyListeners(); - debugPrint('SMS added: $smsNumber'); + LogService.info('SMS added: $smsNumber'); } void removeSms(String smsNumber) { OneSignal.User.removeSms(smsNumber); _smsNumbersList = List.from(_smsNumbersList)..remove(smsNumber); notifyListeners(); - debugPrint('SMS removed: $smsNumber'); + LogService.info('SMS removed: $smsNumber'); } // Tags @@ -415,28 +416,28 @@ class AppViewModel extends ChangeNotifier { OneSignal.User.addTagWithKey(key, value); _tagsList = _mergePairs(_tagsList, {key: value}); notifyListeners(); - debugPrint('Tag added: $key'); + LogService.info('Tag added: $key'); } void addTags(Map tags) { OneSignal.User.addTags(tags); _tagsList = _mergePairs(_tagsList, tags); notifyListeners(); - debugPrint('${tags.length} tag(s) added'); + LogService.info('${tags.length} tag(s) added'); } void removeTag(String key) { OneSignal.User.removeTag(key); _tagsList = List.from(_tagsList)..removeWhere((e) => e.key == key); notifyListeners(); - debugPrint('Tag removed: $key'); + LogService.info('Tag removed: $key'); } void removeSelectedTags(List keys) { OneSignal.User.removeTags(keys); _tagsList = List.from(_tagsList)..removeWhere((e) => keys.contains(e.key)); notifyListeners(); - debugPrint('${keys.length} tag(s) removed'); + LogService.info('${keys.length} tag(s) removed'); } // Triggers (in-memory only) @@ -444,21 +445,21 @@ class AppViewModel extends ChangeNotifier { OneSignal.InAppMessages.addTrigger(key, value); _triggersList = _mergePairs(_triggersList, {key: value}); notifyListeners(); - debugPrint('Trigger added: $key'); + LogService.info('Trigger added: $key'); } void addTriggers(Map triggers) { OneSignal.InAppMessages.addTriggers(triggers); _triggersList = _mergePairs(_triggersList, triggers); notifyListeners(); - debugPrint('${triggers.length} trigger(s) added'); + LogService.info('${triggers.length} trigger(s) added'); } void removeTrigger(String key) { OneSignal.InAppMessages.removeTrigger(key); _triggersList = List.from(_triggersList)..removeWhere((e) => e.key == key); notifyListeners(); - debugPrint('Trigger removed: $key'); + LogService.info('Trigger removed: $key'); } void removeSelectedTriggers(List keys) { @@ -466,36 +467,36 @@ class AppViewModel extends ChangeNotifier { _triggersList = List.from(_triggersList) ..removeWhere((e) => keys.contains(e.key)); notifyListeners(); - debugPrint('${keys.length} trigger(s) removed'); + LogService.info('${keys.length} trigger(s) removed'); } void clearAllTriggers() { OneSignal.InAppMessages.clearTriggers(); _triggersList = []; notifyListeners(); - debugPrint('All triggers cleared'); + LogService.info('All triggers cleared'); } // Outcomes void sendOutcome(String name) { OneSignal.Session.addOutcome(name); - debugPrint('Outcome sent: $name'); + LogService.info('Outcome sent: $name'); } void sendUniqueOutcome(String name) { OneSignal.Session.addUniqueOutcome(name); - debugPrint('Unique outcome sent: $name'); + LogService.info('Unique outcome sent: $name'); } void sendOutcomeWithValue(String name, double value) { OneSignal.Session.addOutcomeWithValue(name, value); - debugPrint('Outcome sent: $name = $value'); + LogService.info('Outcome sent: $name = $value'); } // Custom Events void trackEvent(String name, Map? properties) { OneSignal.User.trackEvent(name, properties); - debugPrint('Event tracked: $name'); + LogService.info('Event tracked: $name'); } // Live Activities @@ -519,7 +520,7 @@ class AppViewModel extends ChangeNotifier { content, ); notifyListeners(); - debugPrint('Started Live Activity: $_activityId'); + LogService.info('Started Live Activity: $_activityId'); } Future updateLiveActivity() async { @@ -537,9 +538,9 @@ class AppViewModel extends ChangeNotifier { _isLaUpdating = false; if (success) { _statusIndex = nextIndex; - debugPrint('Updated Live Activity: $_activityId'); + LogService.info('Updated Live Activity: $_activityId'); } else { - debugPrint('Failed to update Live Activity'); + LogService.info('Failed to update Live Activity'); } notifyListeners(); } @@ -548,9 +549,9 @@ class AppViewModel extends ChangeNotifier { final success = await _apiService.endLiveActivity(_activityId); if (success) { _statusIndex = 0; - debugPrint('Ended Live Activity: $_activityId'); + LogService.info('Ended Live Activity: $_activityId'); } else { - debugPrint('Failed to end Live Activity'); + LogService.info('Failed to end Live Activity'); } notifyListeners(); } @@ -561,7 +562,7 @@ class AppViewModel extends ChangeNotifier { OneSignal.Location.setShared(shared); await _prefs.setLocationShared(shared); notifyListeners(); - debugPrint('Location sharing ${shared ? "enabled" : "disabled"}'); + LogService.info('Location sharing ${shared ? "enabled" : "disabled"}'); } void promptLocation() { diff --git a/examples/demo/lib/widgets/log_view.dart b/examples/demo/lib/widgets/log_view.dart new file mode 100644 index 00000000..3891a192 --- /dev/null +++ b/examples/demo/lib/widgets/log_view.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; + +import '../services/log_service.dart'; +import '../theme.dart'; + +const _monospace = 'monospace'; +const _entryFontSize = 8.0; + +class LogView extends StatefulWidget { + const LogView({super.key}); + + @override + State createState() => _LogViewState(); +} + +class _LogViewState extends State { + final _scrollController = ScrollController(); + bool _collapsed = false; + + @override + void initState() { + super.initState(); + LogService.entries.addListener(_onEntriesChanged); + } + + @override + void dispose() { + LogService.entries.removeListener(_onEntriesChanged); + _scrollController.dispose(); + super.dispose(); + } + + // Keep following new entries only while the reader is already at the end. + void _onEntriesChanged() { + final atEnd = + !_scrollController.hasClients || + _scrollController.position.extentAfter < _entryFontSize * 2; + if (atEnd) _scrollToEnd(); + } + + void _scrollToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + void _toggle() { + setState(() => _collapsed = !_collapsed); + if (!_collapsed) _scrollToEnd(); + } + + @override + Widget build(BuildContext context) { + return Semantics( + identifier: 'log_view', + container: true, + child: ColoredBox( + color: AppColors.osLogBackground, + child: ValueListenableBuilder>( + valueListenable: LogService.entries, + builder: + (context, logs, _) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(logs.length), + if (!_collapsed) _buildBody(logs), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(int count) { + return SizedBox( + height: 24, + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 9), + child: Row( + children: [ + Expanded( + child: Text( + 'LOGS ($count)', + style: const TextStyle( + color: AppColors.osGrey500, + fontFamily: _monospace, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ), + _HeaderButton( + identifier: 'log_clear_button', + label: 'Clear logs', + icon: Icons.delete, + size: 14, + onTap: LogService.clear, + ), + _HeaderButton( + identifier: 'log_toggle_button', + label: _collapsed ? 'Expand logs' : 'Collapse logs', + icon: _collapsed ? Icons.expand_more : Icons.expand_less, + size: 18, + onTap: _toggle, + ), + ], + ), + ), + ); + } + + Widget _buildBody(List logs) { + const entryStyle = TextStyle( + color: AppColors.osLogText, + fontFamily: _monospace, + fontSize: _entryFontSize, + height: 1.4, + ); + const mutedStyle = TextStyle(color: AppColors.osGrey600); + + return SizedBox( + height: 100, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (logs.isEmpty) + Text('No logs yet', style: entryStyle.merge(mutedStyle)), + for (final (index, entry) in logs.indexed) + Semantics( + identifier: 'log_entry_$index', + container: true, + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: entry.time, style: mutedStyle), + TextSpan(text: ' ${entry.message}'), + ], + ), + style: entryStyle, + ), + ), + ], + ), + ), + ); + } +} + +class _HeaderButton extends StatelessWidget { + const _HeaderButton({ + required this.identifier, + required this.label, + required this.icon, + required this.size, + required this.onTap, + }); + + final String identifier; + final String label; + final IconData icon; + final double size; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + identifier: identifier, + container: true, + button: true, + label: label, + excludeSemantics: true, + onTap: onTap, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + // The header is only 24 tall, so widen the target instead. + child: SizedBox( + width: 32, + height: double.infinity, + child: Icon(icon, size: size, color: AppColors.osGrey500), + ), + ), + ); + } +}