From 64903bf1f38b93638e0b46bc6b6a97bc05de2539 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 10 Jan 2026 13:43:31 +0800 Subject: [PATCH] :chart_with_upwards_trend: Tracking data's analytics service --- lib/pods/config.dart | 4 + lib/pods/userinfo.dart | 14 +- lib/screens/chat/room.dart | 11 + lib/services/analytics_service.dart | 519 +++++++++++++++++++++++++++ lib/services/app_intents/ios.dart | 21 ++ lib/utils/share_utils.dart | 5 + lib/widgets/post/compose_shared.dart | 12 + lib/widgets/post/post_item.dart | 9 + 8 files changed, 585 insertions(+), 10 deletions(-) create mode 100644 lib/services/analytics_service.dart diff --git a/lib/pods/config.dart b/lib/pods/config.dart index dc256e46..f540bea5 100644 --- a/lib/pods/config.dart +++ b/lib/pods/config.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:island/services/analytics_service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; @@ -256,8 +257,11 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { void setThemeMode(String value) { final prefs = ref.read(sharedPreferencesProvider); + final oldValue = state.themeMode; prefs.setString(kAppThemeMode, value); state = state.copyWith(themeMode: value); + + AnalyticsService().logThemeChanged(oldValue ?? 'system', value); } void setAppTransparentBackground(double value) { diff --git a/lib/pods/userinfo.dart b/lib/pods/userinfo.dart index 519b56dd..64bf9009 100644 --- a/lib/pods/userinfo.dart +++ b/lib/pods/userinfo.dart @@ -1,10 +1,6 @@ import 'dart:convert'; -import 'dart:io' show Platform; - import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:island/widgets/alert.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,6 +9,7 @@ import 'package:island/models/account.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/talker.dart'; +import 'package:island/services/analytics_service.dart'; class UserInfoNotifier extends AsyncNotifier { @override @@ -31,9 +28,7 @@ class UserInfoNotifier extends AsyncNotifier { final response = await client.get('/pass/accounts/me'); final user = SnAccount.fromJson(response.data); - if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) { - FirebaseAnalytics.instance.setUserId(id: user.id); - } + AnalyticsService().setUserId(user.id); return user; } catch (error, stackTrace) { if (error is DioException) { @@ -91,9 +86,8 @@ class UserInfoNotifier extends AsyncNotifier { final prefs = ref.read(sharedPreferencesProvider); await prefs.remove(kTokenPairStoreKey); ref.invalidate(tokenProvider); - if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) { - FirebaseAnalytics.instance.setUserId(id: null); - } + AnalyticsService().setUserId(null); + AnalyticsService().logLogout(); } } diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 60d58c8f..e96b2453 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -22,6 +22,7 @@ import "package:island/pods/chat/chat_online_count.dart"; import "package:island/pods/config.dart"; import "package:island/pods/userinfo.dart"; import "package:island/screens/chat/search_messages.dart"; +import "package:island/services/analytics_service.dart"; import "package:island/services/file_uploader.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/alert.dart"; @@ -55,6 +56,16 @@ class ChatRoomScreen extends HookConsumerWidget { final onlineCount = ref.watch(chatOnlineCountProvider(id)); final settings = ref.watch(appSettingsProvider); + useEffect(() { + if (!chatRoom.isLoading && chatRoom.value != null) { + AnalyticsService().logChatRoomOpened( + id, + chatRoom.value!.isCommunity == true ? 'group' : 'direct', + ); + } + return null; + }, []); + if (chatIdentity.isLoading || chatRoom.isLoading) { return AppScaffold( appBar: AppBar(leading: const PageBackButton()), diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart new file mode 100644 index 00000000..a7cb9b13 --- /dev/null +++ b/lib/services/analytics_service.dart @@ -0,0 +1,519 @@ +import 'dart:io'; +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:island/talker.dart'; + +class AnalyticsService { + static final AnalyticsService _instance = AnalyticsService._internal(); + factory AnalyticsService() => _instance; + AnalyticsService._internal(); + + final _analytics = FirebaseAnalytics.instance; + bool _enabled = true; + + bool get _supportsAnalytics => + Platform.isAndroid || Platform.isIOS || Platform.isMacOS; + + void logEvent(String name, Map? parameters) { + if (!_enabled || !_supportsAnalytics) return; + try { + _analytics.logEvent(name: name, parameters: parameters); + } catch (e) { + talker.warning('[Analytics] Failed to log event $name: $e'); + } + } + + void setEnabled(bool enabled) { + _enabled = enabled; + } + + void setUserId(String? id) { + if (!_supportsAnalytics) return; + try { + _analytics.setUserId(id: id); + } catch (e) { + talker.warning('[Analytics] Failed to set user ID: $e'); + } + } + + void logAppOpen() { + logEvent('app_open', null); + } + + void logLogin(String authMethod) { + logEvent('login', {'auth_method': authMethod, 'platform': _getPlatform()}); + } + + void logLogout() { + logEvent('logout', null); + } + + void logPostViewed(String postId, String postType, String viewSource) { + logEvent('post_viewed', { + 'post_id': postId, + 'post_type': postType, + 'view_source': viewSource, + }); + } + + void logPostCreated( + String postType, + String visibility, + bool hasAttachments, + String publisherId, + ) { + logEvent('post_created', { + 'post_type': postType, + 'visibility': visibility, + 'has_attachments': hasAttachments, + 'publisher_id': publisherId, + }); + } + + void logPostReacted( + String postId, + String reactionSymbol, + int attitude, + bool isRemoving, + ) { + logEvent('post_reacted', { + 'post_id': postId, + 'reaction_symbol': reactionSymbol, + 'attitude': attitude, + 'is_removing': isRemoving, + }); + } + + void logPostReplied( + String postId, + String parentId, + int characterCount, + bool hasAttachments, + ) { + logEvent('post_replied', { + 'post_id': postId, + 'parent_id': parentId, + 'character_count': characterCount, + 'has_attachments': hasAttachments, + }); + } + + void logPostShared(String postId, String shareMethod, String postType) { + logEvent('post_shared', { + 'post_id': postId, + 'share_method': shareMethod, + 'post_type': postType, + }); + } + + void logPostEdited(String postId, int contentChangeDelta) { + logEvent('post_edited', { + 'post_id': postId, + 'content_change_delta': contentChangeDelta, + }); + } + + void logPostDeleted(String postId, String postType, int timeSinceCreation) { + logEvent('post_deleted', { + 'post_id': postId, + 'post_type': postType, + 'time_since_creation': timeSinceCreation, + }); + } + + void logPostPinned(String postId, String pinMode, String realmId) { + logEvent('post_pinned', { + 'post_id': postId, + 'pin_mode': pinMode, + 'realm_id': realmId, + }); + } + + void logPostAwarded( + String postId, + double amount, + String attitude, + bool hasMessage, + ) { + logEvent('post_awarded', { + 'post_id': postId, + 'amount': amount, + 'attitude': attitude, + 'has_message': hasMessage, + 'currency': 'NSP', + }); + } + + void logPostTranslated( + String postId, + String sourceLanguage, + String targetLanguage, + ) { + logEvent('post_translated', { + 'post_id': postId, + 'source_language': sourceLanguage, + 'target_language': targetLanguage, + }); + } + + void logPostForwarded( + String postId, + String originalPostId, + String publisherId, + ) { + logEvent('post_forwarded', { + 'post_id': postId, + 'original_post_id': originalPostId, + 'publisher_id': publisherId, + }); + } + + void logMessageSent( + String channelId, + String messageType, + bool hasAttachments, + int attachmentCount, + ) { + logEvent('message_sent', { + 'channel_id': channelId, + 'message_type': messageType, + 'has_attachments': hasAttachments, + 'attachment_count': attachmentCount, + }); + } + + void logMessageReceived( + String channelId, + String messageType, + bool isMentioned, + ) { + logEvent('message_received', { + 'channel_id': channelId, + 'message_type': messageType, + 'is_mentioned': isMentioned, + }); + } + + void logMessageReplied( + String channelId, + String originalMessageId, + int replyDepth, + ) { + logEvent('message_replied', { + 'channel_id': channelId, + 'original_message_id': originalMessageId, + 'reply_depth': replyDepth, + }); + } + + void logMessageEdited( + String channelId, + String messageId, + int contentChangeDelta, + ) { + logEvent('message_edited', { + 'channel_id': channelId, + 'message_id': messageId, + 'content_change_delta': contentChangeDelta, + }); + } + + void logMessageDeleted( + String channelId, + String messageId, + String messageType, + bool isOwn, + ) { + logEvent('message_deleted', { + 'channel_id': channelId, + 'message_id': messageId, + 'message_type': messageType, + 'is_own': isOwn, + }); + } + + void logChatRoomOpened(String channelId, String roomType) { + logEvent('chat_room_opened', { + 'channel_id': channelId, + 'room_type': roomType, + }); + } + + void logChatJoined(String channelId, String roomType, bool isPublic) { + logEvent('chat_joined', { + 'channel_id': channelId, + 'room_type': roomType, + 'is_public': isPublic, + }); + } + + void logChatLeft(String channelId, String roomType) { + logEvent('chat_left', {'channel_id': channelId, 'room_type': roomType}); + } + + void logChatInvited(String channelId, String invitedUserId) { + logEvent('chat_invited', { + 'channel_id': channelId, + 'invited_user_id': invitedUserId, + }); + } + + void logFileUploaded( + String fileType, + String fileSizeCategory, + String uploadSource, + ) { + logEvent('file_uploaded', { + 'file_type': fileType, + 'file_size_category': fileSizeCategory, + 'upload_source': uploadSource, + }); + } + + void logFileDownloaded( + String fileType, + String fileSizeCategory, + String downloadMethod, + ) { + logEvent('file_downloaded', { + 'file_type': fileType, + 'file_size_category': fileSizeCategory, + 'download_method': downloadMethod, + }); + } + + void logFileDeleted( + String fileType, + String fileSizeCategory, + String deleteSource, + bool isBatchDelete, + int batchCount, + ) { + logEvent('file_deleted', { + 'file_type': fileType, + 'file_size_category': fileSizeCategory, + 'delete_source': deleteSource, + 'is_batch_delete': isBatchDelete, + 'batch_count': batchCount, + }); + } + + void logStickerUsed(String packId, String stickerSlug, String context) { + logEvent('sticker_used', { + 'pack_id': packId, + 'sticker_slug': stickerSlug, + 'context': context, + }); + } + + void logStickerPackAdded(String packId, String packName, int stickerCount) { + logEvent('sticker_pack_added', { + 'pack_id': packId, + 'pack_name': packName, + 'sticker_count': stickerCount, + }); + } + + void logStickerPackViewed(String packId, int stickerCount, bool isOwned) { + logEvent('sticker_pack_viewed', { + 'pack_id': packId, + 'sticker_count': stickerCount, + 'is_owned': isOwned, + }); + } + + void logSearchPerformed( + String searchType, + String query, + int resultCount, + bool hasFilters, + ) { + logEvent('search_performed', { + 'search_type': searchType, + 'query': query, + 'result_count': resultCount, + 'has_filters': hasFilters, + }); + } + + void logFeedSubscribed(String feedId, String feedUrl, int articleCount) { + logEvent('feed_subscribed', { + 'feed_id': feedId, + 'feed_url': feedUrl, + 'article_count': articleCount, + }); + } + + void logFeedUnsubscribed(String feedId, String feedUrl) { + logEvent('feed_unsubscribed', {'feed_id': feedId, 'feed_url': feedUrl}); + } + + void logWalletTransfer( + double amount, + String currency, + String payeeId, + bool hasRemark, + ) { + logEvent('wallet_transfer', { + 'amount': amount, + 'currency': currency, + 'payee_id': payeeId, + 'has_remark': hasRemark, + }); + } + + void logWalletBalanceChecked(List currenciesViewed) { + logEvent('wallet_balance_checked', { + 'currencies_viewed': currenciesViewed.join(','), + }); + } + + void logWalletOpened(String activeTab) { + logEvent('wallet_opened', {'active_tab': activeTab}); + } + + void logRealmJoined(String realmSlug, String realmType) { + logEvent('realm_joined', { + 'realm_slug': realmSlug, + 'realm_type': realmType, + }); + } + + void logRealmLeft(String realmSlug) { + logEvent('realm_left', {'realm_slug': realmSlug}); + } + + void logFriendAdded(String friendId, String pickerMethod) { + logEvent('friend_added', { + 'friend_id': friendId, + 'picker_method': pickerMethod, + }); + } + + void logFriendRemoved(String relationshipId, String relationshipType) { + logEvent('friend_removed', { + 'relationship_id': relationshipId, + 'relationship_type': relationshipType, + }); + } + + void logUserBlocked(String blockedUserId, String previousRelationship) { + logEvent('user_blocked', { + 'blocked_user_id': blockedUserId, + 'previous_relationship': previousRelationship, + }); + } + + void logUserUnblocked(String unblockedUserId) { + logEvent('user_unblocked', {'unblocked_user_id': unblockedUserId}); + } + + void logFriendRequestAccepted(String requesterId) { + logEvent('friend_request_accepted', {'requester_id': requesterId}); + } + + void logFriendRequestDeclined(String requesterId) { + logEvent('friend_request_declined', {'requester_id': requesterId}); + } + + void logThemeChanged(String oldMode, String newMode) { + logEvent('theme_changed', {'old_mode': oldMode, 'new_mode': newMode}); + } + + void logLanguageChanged(String oldLanguage, String newLanguage) { + logEvent('language_changed', { + 'old_language': oldLanguage, + 'new_language': newLanguage, + }); + } + + void logAiQuerySent( + int messageLength, + String contextType, + int attachedPostsCount, + ) { + logEvent('ai_query_sent', { + 'message_length': messageLength, + 'context_type': contextType, + 'attached_posts_count': attachedPostsCount, + }); + } + + void logAiResponseReceived(int responseThoughtCount, String sequenceId) { + logEvent('ai_response_received', { + 'response_thought_count': responseThoughtCount, + 'sequence_id': sequenceId, + }); + } + + void logShuffleViewed(int postIndex, int totalPostsLoaded) { + logEvent('shuffle_viewed', { + 'post_index': postIndex, + 'total_posts_loaded': totalPostsLoaded, + }); + } + + void logDraftSaved(String draftId, String postType, bool hasContent) { + logEvent('draft_saved', { + 'draft_id': draftId, + 'post_type': postType, + 'has_content': hasContent, + }); + } + + void logDraftDeleted(String draftId, String postType) { + logEvent('draft_deleted', {'draft_id': draftId, 'post_type': postType}); + } + + void logCategoryViewed(String categorySlug, String categoryId) { + logEvent('category_viewed', { + 'category_slug': categorySlug, + 'category_id': categoryId, + }); + } + + void logTagViewed(String tagSlug, String tagId) { + logEvent('tag_viewed', {'tag_slug': tagSlug, 'tag_id': tagId}); + } + + void logCategorySubscribed(String categorySlug, String categoryId) { + logEvent('category_subscribed', { + 'category_slug': categorySlug, + 'category_id': categoryId, + }); + } + + void logTagSubscribed(String tagSlug, String tagId) { + logEvent('tag_subscribed', {'tag_slug': tagSlug, 'tag_id': tagId}); + } + + void logNotificationViewed() { + logEvent('notification_viewed', null); + } + + void logNotificationActioned(String actionType, String notificationType) { + logEvent('notification_actioned', { + 'action_type': actionType, + 'notification_type': notificationType, + }); + } + + void logProfileUpdated(List fieldsUpdated) { + logEvent('profile_updated', {'fields_updated': fieldsUpdated.join(',')}); + } + + void logAvatarChanged(String imageSource, bool isCropped) { + logEvent('avatar_changed', { + 'image_source': imageSource, + 'is_cropped': isCropped, + }); + } + + String _getPlatform() { + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + if (Platform.isLinux) return 'linux'; + return 'web'; + } +} diff --git a/lib/services/app_intents/ios.dart b/lib/services/app_intents/ios.dart index 670c5434..2049ea82 100644 --- a/lib/services/app_intents/ios.dart +++ b/lib/services/app_intents/ios.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter_app_intents/flutter_app_intents.dart'; import 'package:go_router/go_router.dart'; import 'package:island/models/auth.dart'; @@ -532,6 +533,17 @@ class AppIntentsService { return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecondsSinceEpoch}'; } + void _logDonation(String eventName, Map parameters) { + try { + FirebaseAnalytics.instance.logEvent( + name: eventName, + parameters: parameters.isEmpty ? null : parameters, + ); + } catch (e) { + talker.warning('[AppIntents] Failed to log analytics: $e'); + } + } + Future _handleCheckUnreadChatsIntent( Map parameters, ) async { @@ -616,6 +628,7 @@ class AppIntentsService { relevanceScore: 0.8, context: {'feature': 'chat', 'userAction': true}, ); + _logDonation('open_chat', {'channel_id': channelId}); talker.info('[AppIntents] Donated open_chat intent'); } catch (e, stack) { talker.error('[AppIntents] Failed to donate open_chat', e, stack); @@ -631,6 +644,7 @@ class AppIntentsService { relevanceScore: 0.8, context: {'feature': 'posts', 'userAction': true}, ); + _logDonation('open_post', {'post_id': postId}); talker.info('[AppIntents] Donated open_post intent'); } catch (e, stack) { talker.error('[AppIntents] Failed to donate open_post', e, stack); @@ -646,6 +660,7 @@ class AppIntentsService { relevanceScore: 0.9, context: {'feature': 'compose', 'userAction': true}, ); + _logDonation('open_compose', {}); talker.info('[AppIntents] Donated compose intent'); } catch (e, stack) { talker.error('[AppIntents] Failed to donate compose', e, stack); @@ -661,6 +676,7 @@ class AppIntentsService { relevanceScore: 0.7, context: {'feature': 'search', 'userAction': true}, ); + _logDonation('search_content', {'query': query}); talker.info('[AppIntents] Donated search intent'); } catch (e, stack) { talker.error('[AppIntents] Failed to donate search', e, stack); @@ -676,6 +692,7 @@ class AppIntentsService { relevanceScore: 0.6, context: {'feature': 'notifications', 'userAction': true}, ); + _logDonation('check_notifications', {}); talker.info('[AppIntents] Donated check_notifications intent'); } catch (e, stack) { talker.error( @@ -695,6 +712,7 @@ class AppIntentsService { relevanceScore: 0.8, context: {'feature': 'chat', 'userAction': true}, ); + _logDonation('send_message', {'channel_id': channelId}); talker.info('[AppIntents] Donated send_message intent'); } catch (e, stack) { talker.error('[AppIntents] Failed to donate send_message', e, stack); @@ -710,6 +728,7 @@ class AppIntentsService { relevanceScore: 0.7, context: {'feature': 'chat', 'userAction': true}, ); + _logDonation('read_messages', {'channel_id': channelId}); talker.info('[AppIntents] Donated read_messages intent'); } catch (e, stack) { talker.error('[AppIntents] Failed to donate read_messages', e, stack); @@ -725,6 +744,7 @@ class AppIntentsService { relevanceScore: 0.7, context: {'feature': 'chat', 'userAction': true}, ); + _logDonation('check_unread_chats', {}); talker.info('[AppIntents] Donated check_unread_chats intent'); } catch (e, stack) { talker.error( @@ -744,6 +764,7 @@ class AppIntentsService { relevanceScore: 0.6, context: {'feature': 'notifications', 'userAction': true}, ); + _logDonation('mark_notifications_read', {}); talker.info('[AppIntents] Donated mark_notifications_read intent'); } catch (e, stack) { talker.error( diff --git a/lib/utils/share_utils.dart b/lib/utils/share_utils.dart index c1d80d63..6e4575b8 100644 --- a/lib/utils/share_utils.dart +++ b/lib/utils/share_utils.dart @@ -11,6 +11,7 @@ import 'package:island/widgets/post/post_shared.dart'; import 'package:path_provider/path_provider.dart' show getTemporaryDirectory; import 'package:screenshot/screenshot.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:island/services/analytics_service.dart'; /// Shares a post as a screenshot image Future sharePostAsScreenshot( @@ -62,5 +63,9 @@ Future sharePostAsScreenshot( .catchError((err) { if (context.mounted) hideLoadingModal(context); showErrorAlert(err); + }) + .whenComplete(() { + final postTypeStr = post.type == 0 ? 'regular' : 'article'; + AnalyticsService().logPostShared(post.id, 'screenshot', postTypeStr); }); } diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index c3822fc3..5364f25d 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -27,6 +27,7 @@ import 'package:island/widgets/post/compose_recorder.dart'; import 'package:island/pods/drive/file_pool.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:island/talker.dart'; +import 'package:island/services/analytics_service.dart'; class ComposeState { final TextEditingController titleController; @@ -738,6 +739,17 @@ class ComposeLogic { onSuccess(); eventBus.fire(PostCreatedEvent()); + final postTypeStr = state.postType == 0 ? 'regular' : 'article'; + final visibilityStr = state.visibility.value.toString(); + final publisherId = state.currentPublisher.value?.id ?? 'unknown'; + + AnalyticsService().logPostCreated( + postTypeStr, + visibilityStr, + state.attachments.value.isNotEmpty, + publisherId, + ); + return post; } catch (err) { showErrorAlert(err); diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 99ad3e1d..a265d802 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -28,6 +28,7 @@ import 'package:island/widgets/post/compose_sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:super_context_menu/super_context_menu.dart'; +import 'package:island/services/analytics_service.dart'; const kAvailableStickers = { 'angry', @@ -367,7 +368,15 @@ class PostItem extends HookConsumerWidget { ), ); HapticFeedback.heavyImpact(); + + AnalyticsService().logPostReacted( + item.id, + symbol, + attitude, + isRemoving, + ); }); + reacting.value = false; }