diff --git a/example/lib/main.dart b/example/lib/main.dart index 8b548e5..038e984 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -228,10 +228,27 @@ class _DemoPageState extends State { url: 'https://example.com/demo', buttonId: 'demo_btn_01', ), - customTags: const {'tenantId': 't1', 'feature': 'demo'}, + customTags: TagTemplates.merge([ + TagTemplates.businessContext( + tenantId: 't1', + appVersion: '1.0.0', + channel: 'internal_test', + ), + TagTemplates.forScreen(screenName: 'DemoPage', featureModule: 'demo'), + ]), ); } + Future _setUserInfo() async { + const userInfo = UserInfo( + userId: 10086, + userName: 'Test User', + account: 'test_account', + ); + await Analytics.setUser(userInfo); + _addLog('Set user: ${userInfo.toJson()}'); + } + Future _flushNow() async { await Analytics.flush(force: true); } @@ -510,6 +527,16 @@ class _DemoPageState extends State { ), child: const Text('Track Demo Event'), ), + ElevatedButton( + onPressed: _isRunning('set_user') + ? null + : () => _runAction( + 'set_user', + 'Set User Info', + _setUserInfo, + ), + child: const Text('Set User Info'), + ), ElevatedButton( onPressed: _isRunning('flush') ? null diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 252c004..0a7b7cb 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,12 @@ import FlutterMacOS import Foundation +import device_info_plus import path_provider_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/example/pubspec.lock b/example/pubspec.lock index 1f10ae0..7652a07 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.8" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.3" dio: dependency: transitive description: @@ -81,6 +97,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -99,6 +123,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" http_parser: dependency: transitive description: @@ -376,6 +405,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/lib/src/model/device_info.dart b/lib/src/model/device_info.dart index c6d9d6f..bf119f3 100644 --- a/lib/src/model/device_info.dart +++ b/lib/src/model/device_info.dart @@ -7,7 +7,7 @@ class DeviceInfo { required this.screenResolution, }); - /// 操作系统名称或版本。 + /// 操作系统名称或版本(例如 "Android 10" 或 "Version 14.4")。 final String os; /// 设备型号。 diff --git a/lib/src/util/device_util.dart b/lib/src/util/device_util.dart index 05d65d3..e80b37c 100644 --- a/lib/src/util/device_util.dart +++ b/lib/src/util/device_util.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'dart:ui'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:yx_tracking_flutter/src/model/device_info.dart'; @@ -17,10 +19,53 @@ class DeviceUtil { static Future collectDeviceInfo() async { WidgetsFlutterBinding.ensureInitialized(); - final os = Platform.operatingSystem; - final model = Platform.operatingSystemVersion; + var os = 'unknown'; + var model = 'unknown'; final screenResolution = _screenResolution(); + try { + final deviceInfo = DeviceInfoPlugin(); + if (kIsWeb) { + final webInfo = await deviceInfo.webBrowserInfo; + os = '${webInfo.browserName.name} ${webInfo.appVersion}'; + model = webInfo.userAgent ?? 'unknown'; + } else { + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + // 例如: Android 12 + os = 'Android ${androidInfo.version.release}'; + // 例如: Google Pixel 6 + model = '${androidInfo.brand} ${androidInfo.model}'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + // 例如: iOS 15.4 + os = '${iosInfo.systemName} ${iosInfo.systemVersion}'; + // 例如: iPhone14,2 + model = iosInfo.utsname.machine; + } else if (Platform.isMacOS) { + final macInfo = await deviceInfo.macOsInfo; + os = 'macOS ${macInfo.majorVersion}.${macInfo.minorVersion}'; + model = macInfo.model; + } else if (Platform.isWindows) { + final windowsInfo = await deviceInfo.windowsInfo; + os = + 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}'; + model = 'PC'; + } else if (Platform.isLinux) { + final linuxInfo = await deviceInfo.linuxInfo; + os = 'Linux ${linuxInfo.versionId}'; + model = linuxInfo.name; + } else { + os = Platform.operatingSystemVersion; + } + } + } catch (e) { + // Fallback if plugin fails + if (!kIsWeb) { + os = Platform.operatingSystemVersion; + } + } + return DeviceInfo( os: os, model: model, @@ -29,6 +74,7 @@ class DeviceUtil { } @visibleForTesting + /// 测试钩子:通过注入 view 列表计算分辨率。 static String screenResolutionForTesting({ Iterable Function()? viewsProvider, @@ -37,6 +83,7 @@ class DeviceUtil { } @visibleForTesting + /// 测试钩子:通过 size 计算分辨率字符串。 static String resolutionFromSizeForTesting(Size size) { return _resolutionFromSize(size); diff --git a/lib/src/util/tag_templates.dart b/lib/src/util/tag_templates.dart new file mode 100644 index 0000000..b485573 --- /dev/null +++ b/lib/src/util/tag_templates.dart @@ -0,0 +1,277 @@ +/// 自定义标签模板工具类。 +/// +/// 提供符合行业标准(Firebase GA4、Amplitude、Mixpanel)的自定义标签模板, +/// 使用 snake_case 命名规范,便于与数据仓库(BigQuery、Snowflake)集成。 +/// +/// ## 设计原则 / Design Principle +/// +/// `TagTemplates` 是 `Event` 的**补充**,不重复 `Event` 已有的字段: +/// +/// | Event 已有字段 | 说明 | +/// |---|---| +/// | `systemCode` | 系统编码(配置提供) | +/// | `eventType` | 事件类型(调用时传入) | +/// | `userInfo` | 用户信息(`Analytics.setUser` 设置) | +/// | `clientType` | 客户端类型(自动检测:iOS/Android/Web) | +/// | `deviceInfo` | 设备信息(自动采集:OS/Model/屏幕) | +/// | `timestamp` | 时间戳(自动生成) | +/// +/// `TagTemplates` 提供 `Event` **未覆盖**的业务维度标签。 +class TagTemplates { + /// 私有构造,避免外部实例化。 + TagTemplates._(); + + // --------------------------------------------------------------------------- + // 业务上下文标签 / Business Context Tags + // --------------------------------------------------------------------------- + + /// 创建业务上下文标签。 + /// + /// > **注意**: 不包含 `platform`,因为 `Event.clientType` 已自动检测平台。 + /// + /// - [tenantId]: 租户 ID(多租户系统必填)。 + /// - [appVersion]: 应用版本号,例如 "1.2.3"。 + /// - [channel]: 分发渠道,例如 "appstore", "huawei", "xiaomi"。 + /// - [abTestGroup]: A/B 测试组,例如 "control", "variant_a"。 + /// - [environment]: 环境标识,例如 "dev", "staging", "prod"。 + static Map businessContext({ + required String tenantId, + String? appVersion, + String? channel, + String? abTestGroup, + String? environment, + }) => + { + 'tenant_id': tenantId, + if (appVersion != null) 'app_version': appVersion, + if (channel != null) 'channel': channel, + if (abTestGroup != null) 'ab_test_group': abTestGroup, + if (environment != null) 'environment': environment, + }; + + // --------------------------------------------------------------------------- + // 用户分群标签 / User Segmentation Tags + // --------------------------------------------------------------------------- + + /// 创建用户分群标签。 + /// + /// - [userType]: 用户类型,例如 "free", "premium", "enterprise"。 + /// - [subscriptionTier]: 订阅级别,例如 "basic", "pro", "enterprise"。 + /// - [isFirstTimeUser]: 是否为首次使用用户。 + /// - [daysFromSignup]: 注册天数,用于生命周期分析。 + /// - [cohort]: 用户群组标识,例如 "2024-01", "beta_testers"。 + /// - [acquisitionSource]: 获客来源,例如 "organic", "paid", "referral"。 + /// - [lifetimeValueTier]: 用户价值层级,例如 "high", "medium", "low"。 + static Map userSegmentation({ + String? userType, + String? subscriptionTier, + bool? isFirstTimeUser, + int? daysFromSignup, + String? cohort, + String? acquisitionSource, + String? lifetimeValueTier, + }) => + { + if (userType != null) 'user_type': userType, + if (subscriptionTier != null) 'subscription_tier': subscriptionTier, + if (isFirstTimeUser != null) 'is_first_time_user': isFirstTimeUser, + if (daysFromSignup != null) 'days_from_signup': daysFromSignup, + if (cohort != null) 'cohort': cohort, + if (acquisitionSource != null) 'acquisition_source': acquisitionSource, + if (lifetimeValueTier != null) 'lifetime_value_tier': lifetimeValueTier, + }; + + // --------------------------------------------------------------------------- + // 功能追踪标签 / Feature Tracking Tags + // --------------------------------------------------------------------------- + + /// 创建页面/功能追踪标签。 + /// + /// - [screenName]: 当前页面名称。 + /// - [featureModule]: 功能模块,例如 "profile", "checkout", "search"。 + /// - [entrySource]: 入口来源,例如 "deeplink", "push", "organic"。 + /// - [previousScreen]: 上一个页面名称(用于漏斗分析)。 + /// - [sessionDepth]: 当前会话中的页面深度。 + static Map forScreen({ + required String screenName, + String? featureModule, + String? entrySource, + String? previousScreen, + int? sessionDepth, + }) => + { + 'screen_name': screenName, + if (featureModule != null) 'feature_module': featureModule, + if (entrySource != null) 'entry_source': entrySource, + if (previousScreen != null) 'previous_screen': previousScreen, + if (sessionDepth != null) 'session_depth': sessionDepth, + }; + + // --------------------------------------------------------------------------- + // 技术上下文标签 / Technical Context Tags + // --------------------------------------------------------------------------- + + /// 创建技术上下文标签。 + /// + /// - [networkType]: 网络类型,例如 "wifi", "4g", "5g"。 + /// - [isOffline]: 是否离线模式。 + /// - [buildMode]: 构建模式,例如 "debug", "release", "profile"。 + /// - [appInstallSource]: 安装来源,例如 "play_store", "app_store"。 + /// - [locale]: 语言区域,例如 "zh_CN", "en_US"。 + /// - [timezone]: 时区,例如 "Asia/Shanghai", "America/New_York"。 + static Map technicalContext({ + String? networkType, + bool? isOffline, + String? buildMode, + String? appInstallSource, + String? locale, + String? timezone, + }) => + { + if (networkType != null) 'network_type': networkType, + if (isOffline != null) 'is_offline': isOffline, + if (buildMode != null) 'build_mode': buildMode, + if (appInstallSource != null) 'app_install_source': appInstallSource, + if (locale != null) 'locale': locale, + if (timezone != null) 'timezone': timezone, + }; + + // --------------------------------------------------------------------------- + // 营销归因标签 / Campaign Attribution Tags + // --------------------------------------------------------------------------- + + /// 创建营销归因标签(UTM 参数)。 + /// + /// - [campaignId]: 活动 ID。 + /// - [campaignName]: 活动名称。 + /// - [campaignSource]: 流量来源,例如 "google", "facebook", "tiktok"。 + /// - [campaignMedium]: 媒介类型,例如 "cpc", "email", "social"。 + /// - [campaignTerm]: 搜索关键词(付费搜索)。 + /// - [campaignContent]: 广告内容/创意标识。 + static Map campaign({ + String? campaignId, + String? campaignName, + String? campaignSource, + String? campaignMedium, + String? campaignTerm, + String? campaignContent, + }) => + { + if (campaignId != null) 'campaign_id': campaignId, + if (campaignName != null) 'campaign_name': campaignName, + if (campaignSource != null) 'campaign_source': campaignSource, + if (campaignMedium != null) 'campaign_medium': campaignMedium, + if (campaignTerm != null) 'campaign_term': campaignTerm, + if (campaignContent != null) 'campaign_content': campaignContent, + }; + + // --------------------------------------------------------------------------- + // 电商标签 / E-commerce Tags (Firebase GA4 Compatible) + // --------------------------------------------------------------------------- + + /// 创建电商标签(兼容 Firebase GA4 标准)。 + /// + /// - [currency]: 货币代码,例如 "CNY", "USD"。 + /// - [value]: 交易金额。 + /// - [transactionId]: 交易 ID。 + /// - [paymentMethod]: 支付方式,例如 "alipay", "wechat_pay", "credit_card"。 + /// - [coupon]: 优惠券代码。 + /// - [shipping]: 运费。 + /// - [tax]: 税费。 + static Map ecommerce({ + String? currency, + double? value, + String? transactionId, + String? paymentMethod, + String? coupon, + double? shipping, + double? tax, + }) => + { + if (currency != null) 'currency': currency, + if (value != null) 'value': value, + if (transactionId != null) 'transaction_id': transactionId, + if (paymentMethod != null) 'payment_method': paymentMethod, + if (coupon != null) 'coupon': coupon, + if (shipping != null) 'shipping': shipping, + if (tax != null) 'tax': tax, + }; + + // --------------------------------------------------------------------------- + // 会话上下文标签 / Session Context Tags + // --------------------------------------------------------------------------- + + /// 创建会话上下文标签。 + /// + /// - [sessionId]: 会话唯一标识。 + /// - [sessionNumber]: 第几次会话(用户历史会话计数)。 + /// - [eventIndex]: 当前事件在会话中的序号。 + /// - [sessionDurationSeconds]: 会话持续时长(秒)。 + static Map session({ + String? sessionId, + int? sessionNumber, + int? eventIndex, + int? sessionDurationSeconds, + }) => + { + if (sessionId != null) 'session_id': sessionId, + if (sessionNumber != null) 'session_number': sessionNumber, + if (eventIndex != null) 'event_index': eventIndex, + if (sessionDurationSeconds != null) + 'session_duration_seconds': sessionDurationSeconds, + }; + + // --------------------------------------------------------------------------- + // 内容/媒体标签 / Content/Media Tags + // --------------------------------------------------------------------------- + + /// 创建内容/媒体标签。 + /// + /// - [contentType]: 内容类型,例如 "video", "article", "audio", "image"。 + /// - [contentId]: 内容唯一标识。 + /// - [contentName]: 内容名称/标题。 + /// - [contentCategory]: 内容分类。 + /// - [contentDurationSeconds]: 内容时长(秒,适用于视频/音频)。 + /// - [contentAuthor]: 内容作者/创作者。 + static Map content({ + String? contentType, + String? contentId, + String? contentName, + String? contentCategory, + int? contentDurationSeconds, + String? contentAuthor, + }) => + { + if (contentType != null) 'content_type': contentType, + if (contentId != null) 'content_id': contentId, + if (contentName != null) 'content_name': contentName, + if (contentCategory != null) 'content_category': contentCategory, + if (contentDurationSeconds != null) + 'content_duration_seconds': contentDurationSeconds, + if (contentAuthor != null) 'content_author': contentAuthor, + }; + + // --------------------------------------------------------------------------- + // 合并工具 / Merge Utility + // --------------------------------------------------------------------------- + + /// 合并多个标签模板为一个 Map。 + /// + /// 后面的模板会覆盖前面的同名键。 + /// + /// 示例: + /// ```dart + /// final tags = TagTemplates.merge([ + /// TagTemplates.businessContext(tenantId: 't1', appVersion: '1.0.0'), + /// TagTemplates.forScreen(screenName: 'HomePage'), + /// TagTemplates.campaign(campaignSource: 'google', campaignMedium: 'cpc'), + /// ]); + /// ``` + static Map merge(List> templates) { + final result = {}; + for (final template in templates) { + result.addAll(template); + } + return result; + } +} diff --git a/lib/yx_tracking_flutter.dart b/lib/yx_tracking_flutter.dart index e4b4882..3770d07 100644 --- a/lib/yx_tracking_flutter.dart +++ b/lib/yx_tracking_flutter.dart @@ -15,6 +15,7 @@ export 'src/model/device_info.dart'; export 'src/model/event.dart'; export 'src/model/recent_event_summary.dart'; export 'src/model/user_info.dart'; +export 'src/util/tag_templates.dart'; /// 对外唯一入口(Facade)。 class Analytics { diff --git a/pubspec.yaml b/pubspec.yaml index 3ae2528..0835342 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: flutter: ">=3.22.0" dependencies: + device_info_plus: ^12.3.0 dio: ^5.4.3+1 flutter: sdk: flutter