diff --git a/lib/matomo_tracker.dart b/lib/matomo_tracker.dart index a1a6428..d918080 100644 --- a/lib/matomo_tracker.dart +++ b/lib/matomo_tracker.dart @@ -1,5 +1,6 @@ library matomo_tracker; +export 'src/campaign.dart'; export 'src/event_info.dart'; export 'src/exceptions.dart'; export 'src/local_storage/local_storage.dart'; diff --git a/lib/src/campaign.dart b/lib/src/campaign.dart new file mode 100644 index 0000000..f83a324 --- /dev/null +++ b/lib/src/campaign.dart @@ -0,0 +1,119 @@ +import 'package:matomo_tracker/src/assert.dart'; + +/// Describes a campaign. +/// +/// Read more about [Campaign Tracking](https://matomo.org/faq/reports/what-is-campaign-tracking-and-why-it-is-important/). +class Campaign { + /// Creates a campaign description. + /// + /// Note: Strings filled with whitespace will be considered as (invalid) empty + /// values. + factory Campaign({ + required String name, + String? keyword, + String? source, + String? medium, + String? content, + String? id, + String? group, + String? placement, + }) { + assertStringIsFilled(value: name, name: 'name'); + assertStringIsFilled(value: keyword, name: 'keyword'); + assertStringIsFilled(value: source, name: 'source'); + assertStringIsFilled(value: medium, name: 'medium'); + assertStringIsFilled(value: content, name: 'content'); + assertStringIsFilled(value: id, name: 'id'); + assertStringIsFilled(value: group, name: 'group'); + assertStringIsFilled(value: placement, name: 'placement'); + + return Campaign._( + name: name, + keyword: keyword, + source: source, + medium: medium, + content: content, + id: id, + group: group, + placement: placement, + ); + } + + const Campaign._({ + required this.name, + this.keyword, + this.source, + this.medium, + this.content, + this.id, + this.group, + this.placement, + }); + + /// A descriptive name for the campaign, e.g. a blog post title or email campaign name. + /// + /// Corresponds with `mtm_campaign`. + final String name; + + /// The specific keyword that someone searched for, or category of interest. + /// + /// Corresponds with `mtm_keyword`. + final String? keyword; + + /// The actual source of the traffic, e.g. newsletter, twitter, ebay, etc. + /// + /// Requires Matomo Cloud or Marketing Campaigns Reporting Plugin. + /// Corresponds with `mtm_source`. + final String? source; + + /// The type of marketing channel, e.g. email, social, paid, etc. + /// + /// Requires Matomo Cloud or Marketing Campaigns Reporting Plugin. + /// Corresponds with `mtm_medium`. + final String? medium; + + /// This is a specific link or content that somebody clicked. e.g. banner, big-green-button. + /// + /// Requires Matomo Cloud or Marketing Campaigns Reporting Plugin. + /// Corresponds with `mtm_content`. + final String? content; + + /// A unique identifier for your specific ad. This parameter is often used with the numeric IDs automatically generated by advertising platforms. + /// + /// Requires Matomo Cloud or Marketing Campaigns Reporting Plugin. + /// Corresponds with `mtm_cid`. + final String? id; + + /// The audience your campaign is targeting e.g. customers, retargeting, etc. + /// + /// Requires Matomo Cloud or Matomo 4 or above with Marketing Campaigns Reporting Plugin. + /// Corresponds with `mtm_group`. + final String? group; + + ///The placement on an advertising network e.g. newsfeed, sidebar, home-banner, etc. + /// + /// Requires Matomo Cloud or Matomo 4 or above with Marketing Campaigns Reporting Plugin. + /// Corresponds with `mtm_placement`. + final String? placement; + + Map toMap() { + final mtmKeyword = keyword; + final mtmSource = source; + final mtmMedium = medium; + final mtmContent = content; + final mtmCid = id; + final mtmGroup = group; + final mtmPlacement = placement; + + return { + 'mtm_campaign': name, + if (mtmKeyword != null) 'mtm_keyword': mtmKeyword, + if (mtmSource != null) 'mtm_source': mtmSource, + if (mtmMedium != null) 'mtm_medium': mtmMedium, + if (mtmContent != null) 'mtm_content': mtmContent, + if (mtmCid != null) 'mtm_cid': mtmCid, + if (mtmGroup != null) 'mtm_group': mtmGroup, + if (mtmPlacement != null) 'mtm_placement': mtmPlacement, + }; + } +} diff --git a/lib/src/matomo.dart b/lib/src/matomo.dart index e0a0967..7e9c8db 100644 --- a/lib/src/matomo.dart +++ b/lib/src/matomo.dart @@ -5,6 +5,7 @@ import 'package:clock/clock.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:matomo_tracker/src/campaign.dart'; import 'package:matomo_tracker/src/event_info.dart'; import 'package:matomo_tracker/src/exceptions.dart'; import 'package:matomo_tracker/src/local_storage/cookieless_storage.dart'; @@ -329,11 +330,14 @@ class MatomoTracker { /// `null`, it will be combined to [contentBase] to create a URL. This combination /// corresponds with `url`. /// + /// - `campaign`: The campaign that lead to this page view. + /// /// For remarks on [dimensions] see [trackDimensions]. void trackScreen( BuildContext context, { String? pvId, String? path, + Campaign? campaign, Map? dimensions, }) { final actionName = context.widget.toStringShort(); @@ -342,6 +346,7 @@ class MatomoTracker { actionName: actionName, pvId: pvId, path: path, + campaign: campaign, dimensions: dimensions, ); } @@ -360,11 +365,14 @@ class MatomoTracker { /// `null`, it will be combined to [contentBase] to create a URL. This /// combination corresponds with `url`. /// + /// - `campaign`: The campaign that lead to this page view. + /// /// For remarks on [dimensions] see [trackDimensions]. void trackScreenWithName({ required String actionName, String? pvId, String? path, + Campaign? campaign, Map? dimensions, }) { _initializationCheck(); @@ -383,6 +391,7 @@ class MatomoTracker { tracker: this, action: actionName, path: path, + campaign: campaign, dimensions: dimensions, screenId: pvId ?? randomAlphaNumeric(6), ), diff --git a/lib/src/matomo_event.dart b/lib/src/matomo_event.dart index 0f782e4..d52925e 100644 --- a/lib/src/matomo_event.dart +++ b/lib/src/matomo_event.dart @@ -24,6 +24,7 @@ class MatomoEvent { this.searchCategory, this.searchCount, this.link, + this.campaign, this.dimensions, }) : // we use clock.now instead of DateTime.now to make testing easier @@ -73,6 +74,8 @@ class MatomoEvent { final String? link; + final Campaign? campaign; + // The dimensions associated with the event final Map? dimensions; @@ -81,8 +84,17 @@ class MatomoEvent { final uid = tracker.visitor.uid; final pvId = screenId; final actionName = action; - final url = - path != null ? '${tracker.contentBase}/$path' : tracker.contentBase; + final camp = campaign; + final campKeyword = camp?.keyword; + final uri = Uri.parse( + path != null ? '${tracker.contentBase}/$path' : tracker.contentBase, + ); + final url = uri.replace( + queryParameters: { + if (camp != null) ...camp.toMap(), + ...uri.queryParameters, + }, + ).toString(); final idgoal = goalId; final aRevenue = revenue; final event = eventInfo; @@ -93,9 +105,9 @@ class MatomoEvent { final ecSh = shippingCost; final ecDt = discountAmount; final ua = tracker.userAgent; + final dims = dimensions; final locale = PlatformDispatcher.instance.locale; final country = locale.countryCode; - final dims = dimensions ?? {}; return { // Required parameters @@ -105,6 +117,8 @@ class MatomoEvent { // Recommended parameters if (actionName != null) 'action_name': actionName, 'url': url, + if (camp != null) '_rcn': camp.name, + if (campKeyword != null) '_rck': campKeyword, if (id != null) '_id': id, 'rand': '${Random().nextInt(1000000000)}', 'apiv': '1', @@ -150,6 +164,8 @@ class MatomoEvent { // Other parameters (require authentication via `token_auth`) 'cdt': _date.toIso8601String(), - }..addAll(dims); + + if (dims != null) ...dims, + }; } } diff --git a/lib/src/traceable_widget.dart b/lib/src/traceable_widget.dart index bc0e835..5c120e6 100644 --- a/lib/src/traceable_widget.dart +++ b/lib/src/traceable_widget.dart @@ -11,6 +11,7 @@ class TraceableWidget extends StatefulWidget { this.pvId, this.path, this.dimensions, + this.campaign, this.tracker, }); @@ -23,6 +24,9 @@ class TraceableWidget extends StatefulWidget { /// {@macro traceableClientMixin.path} final String? path; + /// {@macro traceableClientMixin.campaign} + final Campaign? campaign; + /// {@macro traceableClientMixin.dimensions} final Map? dimensions; @@ -51,6 +55,9 @@ class _TraceableWidgetState extends State @override String? get path => widget.path; + @override + Campaign? get campaign => widget.campaign; + @override Map? get dimensions => widget.dimensions; diff --git a/lib/src/traceable_widget_mixin.dart b/lib/src/traceable_widget_mixin.dart index 45fc8ec..e2af703 100644 --- a/lib/src/traceable_widget_mixin.dart +++ b/lib/src/traceable_widget_mixin.dart @@ -28,6 +28,13 @@ mixin TraceableClientMixin on State @protected String? path; + /// {@template traceableClientMixin.campaign} + /// The campaign that lead to this interaction or `null` for a + /// default entry. + /// {@endtemplate} + @protected + Campaign? campaign; + /// {@template traceableClientMixin.dimensions} /// A Custom Dimension value for a specific Custom Dimension ID. /// @@ -82,6 +89,7 @@ mixin TraceableClientMixin on State actionName: actionName, pvId: pvId, path: path, + campaign: campaign, dimensions: dimensions, ); } diff --git a/test/ressources/mock/data.dart b/test/ressources/mock/data.dart index 426354f..d17edbc 100644 --- a/test/ressources/mock/data.dart +++ b/test/ressources/mock/data.dart @@ -19,6 +19,14 @@ const trackingOrderItemPrice = 1.0; const trackingOrderItemQuantity = 1; // MatomoEvent +const matomoCampaignName = 'name'; +const matomoCampaignKeyword = 'keyword'; +const matomoCampaignSource = 'source'; +const matomoCampaignMedium = 'medium'; +const matomoCampaignContent = 'content'; +const matomoCampaignId = 'id'; +const matomoCampaignGroup = 'group'; +const matomoCampaignPlacement = 'placement'; const matomoEventPath = 'path'; const matomoEventAction = 'action'; const matomoEventCategory = 'eventCategory'; @@ -43,7 +51,10 @@ Map getWantedEventMap(DateTime now, {String? userAgent}) => { "idsite": "1", "rec": "1", "action_name": "action", - "url": "contentBase/path", + "url": + "contentBase/path?mtm_campaign=name&mtm_keyword=keyword&mtm_source=source&mtm_medium=medium&mtm_content=content&mtm_cid=id&mtm_group=group&mtm_placement=placement", + "_rcn": "name", + "_rck": "keyword", "_id": "visitorId", "apiv": "1", "_idvc": "1", @@ -133,3 +144,18 @@ final wantedEventMapFull = { 'e_n': matomoEventName, 'e_v': matomoEventValue.toString(), }; + +// Campaign +final wantedCampaignMap = { + 'mtm_campaign': matomoCampaignName, +}; +final wantedCampaignMapFull = { + 'mtm_campaign': matomoCampaignName, + 'mtm_keyword': matomoCampaignKeyword, + 'mtm_source': matomoCampaignSource, + 'mtm_medium': matomoCampaignMedium, + 'mtm_content': matomoCampaignContent, + 'mtm_cid': matomoCampaignId, + 'mtm_group': matomoCampaignGroup, + 'mtm_placement': matomoCampaignPlacement, +}; diff --git a/test/src/campaign_test.dart b/test/src/campaign_test.dart new file mode 100644 index 0000000..008d349 --- /dev/null +++ b/test/src/campaign_test.dart @@ -0,0 +1,325 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:matomo_tracker/src/campaign.dart'; + +import '../ressources/mock/data.dart'; + +void main() { + group('Campaign', () { + test('should create a valid Campaign', () { + final campaign = Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + + expect(campaign.name, matomoCampaignName); + expect(campaign.keyword, matomoCampaignKeyword); + expect(campaign.source, matomoCampaignSource); + expect(campaign.medium, matomoCampaignMedium); + expect(campaign.content, matomoCampaignContent); + expect(campaign.id, matomoCampaignId); + expect(campaign.group, matomoCampaignGroup); + expect(campaign.placement, matomoCampaignPlacement); + }); + + test( + 'should throw an ArgumentError if name is empty or contains only whitespace', + () { + Campaign campaignWithEmptyName() { + return Campaign( + name: '', + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + Campaign campaignWithEmptyNameAndWhitespace() { + return Campaign( + name: ' ', + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + expect(campaignWithEmptyName, throwsArgumentError); + expect(campaignWithEmptyNameAndWhitespace, throwsArgumentError); + }, + ); + + test( + 'should throw an ArgumentError if keyword is empty or contains only whitespace', + () { + Campaign campaignWithEmptyKeyword() { + return Campaign( + name: matomoCampaignName, + keyword: '', + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + Campaign campaignWithEmptyKeywordAndWhitespace() { + return Campaign( + name: matomoCampaignName, + keyword: ' ', + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + expect(campaignWithEmptyKeyword, throwsArgumentError); + expect(campaignWithEmptyKeywordAndWhitespace, throwsArgumentError); + }, + ); + + test( + 'should throw an ArgumentError if source is empty or contains only whitespace', + () { + Campaign campaignWithEmptySource() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: '', + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + Campaign campaignWithEmptySourceAndWhitespace() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: ' ', + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + expect(campaignWithEmptySource, throwsArgumentError); + expect(campaignWithEmptySourceAndWhitespace, throwsArgumentError); + }, + ); + + test( + 'should throw an ArgumentError if medium is empty or contains only whitespace', + () { + Campaign campaignWithEmptyMedium() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: '', + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + Campaign campaignWithEmptyMediumAndWhitespace() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: ' ', + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + expect(campaignWithEmptyMedium, throwsArgumentError); + expect(campaignWithEmptyMediumAndWhitespace, throwsArgumentError); + }, + ); + + test( + 'should throw an ArgumentError if content is empty or contains only whitespace', + () { + Campaign campaignWithEmptyContent() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: '', + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + Campaign campaignWithEmptyContentAndWhitespace() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: ' ', + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + expect(campaignWithEmptyContent, throwsArgumentError); + expect(campaignWithEmptyContentAndWhitespace, throwsArgumentError); + }, + ); + + test( + 'should throw an ArgumentError if id is empty or contains only whitespace', + () { + Campaign campaignWithEmptyId() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: '', + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + Campaign campaignWithEmptyIdAndWhitespace() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: ' ', + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ); + } + + expect(campaignWithEmptyId, throwsArgumentError); + expect(campaignWithEmptyIdAndWhitespace, throwsArgumentError); + }, + ); + + test( + 'should throw an ArgumentError if group is empty or contains only whitespace', + () { + Campaign campaignWithEmptyGroup() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: '', + placement: matomoCampaignPlacement, + ); + } + + Campaign campaignWithEmptyGroupAndWhitespace() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: ' ', + placement: matomoCampaignPlacement, + ); + } + + expect(campaignWithEmptyGroup, throwsArgumentError); + expect(campaignWithEmptyGroupAndWhitespace, throwsArgumentError); + }, + ); + + test( + 'should throw an ArgumentError if placement is empty or contains only whitespace', + () { + Campaign campaignWithEmptyPlacement() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: '', + ); + } + + Campaign campaignWithEmptyPlacementAndWhitespace() { + return Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: ' ', + ); + } + + expect(campaignWithEmptyPlacement, throwsArgumentError); + expect(campaignWithEmptyPlacementAndWhitespace, throwsArgumentError); + }, + ); + + group('toMap', () { + test('should return all non null properties inside the map', () { + final map = Campaign( + name: matomoCampaignName, + ).toMap(); + + final mapFull = Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ).toMap(); + + expect(mapEquals(map, wantedCampaignMap), isTrue); + expect(mapEquals(mapFull, wantedCampaignMapFull), isTrue); + }); + }); + }); +} diff --git a/test/src/matomo_event_test.dart b/test/src/matomo_event_test.dart index 0573ba0..c74e548 100644 --- a/test/src/matomo_event_test.dart +++ b/test/src/matomo_event_test.dart @@ -1,6 +1,7 @@ import 'package:clock/clock.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:matomo_tracker/src/campaign.dart'; import 'package:matomo_tracker/src/event_info.dart'; import 'package:matomo_tracker/src/matomo_event.dart'; import 'package:mocktail/mocktail.dart'; @@ -22,6 +23,16 @@ void main() { name: matomoEventName, value: matomoEventValue, ), + campaign: Campaign( + name: matomoCampaignName, + keyword: matomoCampaignKeyword, + source: matomoCampaignSource, + medium: matomoCampaignMedium, + content: matomoCampaignContent, + id: matomoCampaignId, + group: matomoCampaignGroup, + placement: matomoCampaignPlacement, + ), goalId: matomoGoalId, link: matomoLink, orderId: matomoOrderId,