diff --git a/example/lib/main.dart b/example/lib/main.dart index 7faf1c4..ee95d51 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -34,6 +34,7 @@ class BrazeFunctionsState extends State { final customEventPropertyValueController = TextEditingController(); StreamSubscription inAppMessageStreamSubscription; StreamSubscription contentCardsStreamSubscription; + List _brazeContentCards = []; void initState() { _braze = new BrazePlugin(customConfigs: {replayCallbacksConfigKey: true}); @@ -314,6 +315,22 @@ class BrazeFunctionsState extends State { )); }, ), + Visibility( + visible: _brazeContentCards.isNotEmpty, + child: SectionHeader("ContentCards"), + ), + Visibility( + visible: _brazeContentCards.isNotEmpty, + child: Column( + children: _brazeContentCards + .map( + (contentCard) => ContentCard( + contentCard: contentCard, + ), + ) + .toList(), + ), + ), SectionHeader("Other"), TextButton( child: const Text('SET LAST KNOWN LOCATION'), @@ -467,6 +484,9 @@ class BrazeFunctionsState extends State { )); return; } + setState(() { + _brazeContentCards = contentCards; + }); contentCards.forEach((contentCard) { print("[$prefix] Received card: " + contentCard.toString()); ScaffoldMessenger.of(context).showSnackBar(new SnackBar( diff --git a/lib/braze_plugin.dart b/lib/braze_plugin.dart index 0939b0b..e31b1cf 100644 --- a/lib/braze_plugin.dart +++ b/lib/braze_plugin.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert' as json; import 'package:flutter/services.dart'; +export 'widgets/widgets.dart'; + /* Custom configuration keys */ const String replayCallbacksConfigKey = 'ReplayCallbacksKey'; @@ -598,6 +600,17 @@ enum ClickAction { news_feed, uri, none } /// Braze in-app message types enum MessageType { slideup, modal, full, html_full } +/// Braze content card types +enum ContentCardType { + bannerImage('banner_image'), + shortNews('short_news'), + captionedImage('captioned_image'); + + const ContentCardType(this._jsonValue); + + final String _jsonValue; +} + class BrazeContentCard { /// Content Card json String contentCardJsonString = ""; @@ -642,7 +655,7 @@ class BrazeContentCard { String title = ""; /// Content Card type - String type = ""; + ContentCardType type = ContentCardType.shortNews; /// Content Card url String url = ""; @@ -653,9 +666,6 @@ class BrazeContentCard { /// Content Card viewed bool viewed = false; - /// Content Card control - bool isControl = false; - BrazeContentCard(String _data) { contentCardJsonString = _data; var contentCardJson = json.jsonDecode(_data); @@ -718,10 +728,12 @@ class BrazeContentCard { } var typeJson = contentCardJson["tp"]; if (typeJson is String) { - type = typeJson; - } - if (type == "control") { - isControl = true; + for (final contentType in ContentCardType.values) { + if (contentType._jsonValue == typeJson) { + type = contentType; + break; + } + } } var urlJson = contentCardJson["u"]; if (urlJson is String) { @@ -744,7 +756,7 @@ class BrazeContentCard { " url:" + url + " type:" + - type + + type.toString() + " useWebView:" + useWebView.toString() + " title:" + @@ -775,8 +787,6 @@ class BrazeContentCard { expiresAt.toString() + " dismissable:" + dismissable.toString() + - " isControl:" + - isControl.toString() + " contentCardJsonString:" + contentCardJsonString; } diff --git a/lib/widgets/content_card.dart b/lib/widgets/content_card.dart new file mode 100644 index 0000000..88263e1 --- /dev/null +++ b/lib/widgets/content_card.dart @@ -0,0 +1,208 @@ +import 'package:braze_plugin/braze_plugin.dart'; +import 'package:flutter/material.dart'; + +class ContentCard extends StatelessWidget { + const ContentCard({ + super.key, + required this.contentCard, + this.cardPressedUrl, + }); + + final BrazeContentCard contentCard; + final ValueChanged? cardPressedUrl; + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: contentCard.url.isNotEmpty && cardPressedUrl != null + ? () => cardPressedUrl!(contentCard.url) + : null, + child: Column( + children: [ + Stack( + children: [ + Builder( + builder: (context) { + switch (contentCard.type) { + case ContentCardType.bannerImage: + return BannerImage( + contentCard: contentCard, + ); + case ContentCardType.shortNews: + return ShortNews( + contentCard: contentCard, + ); + case ContentCardType.captionedImage: + return CaptionedImage( + contentCard: contentCard, + ); + } + }, + ), + Visibility( + visible: contentCard.pinned, + child: const Align( + alignment: Alignment.topRight, + child: StarTriangleBackground(), + ), + ), + ], + ), + Visibility( + visible: !contentCard.viewed, + child: const NotViewedCard(), + ), + ], + ), + ), + ); + } +} + +@visibleForTesting +class NotViewedCard extends StatelessWidget { + const NotViewedCard({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.blue, + ), + height: 3, + width: double.infinity, + ); + } +} + +@visibleForTesting +class StarTriangleBackground extends StatelessWidget { + const StarTriangleBackground(); + + @override + Widget build(BuildContext context) { + return Container( + height: 27, + width: 27, + padding: const EdgeInsets.all(1), + decoration: const BoxDecoration( + gradient: LinearGradient( + stops: [.5, .5], + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [ + Colors.blue, + Colors.transparent, // top Right part + ], + ), + ), + child: const Align( + alignment: Alignment.topRight, + child: Icon( + Icons.star, + color: Colors.white, + size: 13, + ), + ), + ); + } +} + +@visibleForTesting +class CaptionedImage extends StatelessWidget { + const CaptionedImage({ + super.key, + required this.contentCard, + }); + + final BrazeContentCard contentCard; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.network( + contentCard.image, + width: double.infinity, + fit: BoxFit.cover, + ), + const SizedBox( + height: 16, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + contentCard.title, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + const SizedBox( + height: 12, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + contentCard.description, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + const SizedBox( + height: 16, + ), + ], + ); + } +} + +@visibleForTesting +class ShortNews extends StatelessWidget { + const ShortNews({ + super.key, + required this.contentCard, + }); + + final BrazeContentCard contentCard; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: contentCard.image.isNotEmpty + ? Image.network( + contentCard.image, + width: 60, + ) + : null, + contentPadding: const EdgeInsets.all(16), + title: Text( + contentCard.title, + style: Theme.of(context).textTheme.headlineSmall, + ), + subtitle: Text( + contentCard.description, + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } +} + +@visibleForTesting +class BannerImage extends StatelessWidget { + const BannerImage({ + super.key, + required this.contentCard, + }); + + final BrazeContentCard contentCard; + + @override + Widget build(BuildContext context) { + return Center( + child: Image.network( + contentCard.image, + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart new file mode 100644 index 0000000..50d79b6 --- /dev/null +++ b/lib/widgets/widgets.dart @@ -0,0 +1 @@ +export 'content_card.dart'; \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 84f6d50..2751c1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://www.braze.com/ repository: https://github.com/braze-inc/braze-flutter-sdk environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" flutter: ">=1.10.0" dependencies: @@ -15,6 +15,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + mocktail: ^0.3.0 + mocktail_image_network: ^0.3.1 flutter: plugin: diff --git a/test/braze_plugin_test.dart b/test/braze_plugin_test.dart index 176507e..b26148e 100644 --- a/test/braze_plugin_test.dart +++ b/test/braze_plugin_test.dart @@ -99,11 +99,22 @@ void main() { ]); }); - test('should include isControl field', () { - BrazePlugin _braze = new BrazePlugin(); - String _data = '{"tp":"control"}'; + test('should parse ContentCardType to a bannerImage', () { + String _data = '{"tp":"banner_image"}'; + BrazeContentCard _contentCard = new BrazeContentCard(_data); + expect(_contentCard.type, equals(ContentCardType.bannerImage)); + }); + + test('should parse ContentCardType to a shortNews', () { + String _data = '{"tp":"short_news"}'; + BrazeContentCard _contentCard = new BrazeContentCard(_data); + expect(_contentCard.type, equals(ContentCardType.shortNews)); + }); + + test('should parse ContentCardType to a captionedImage', () { + String _data = '{"tp":"captioned_image"}'; BrazeContentCard _contentCard = new BrazeContentCard(_data); - expect(_contentCard.isControl, equals(true)); + expect(_contentCard.type, equals(ContentCardType.captionedImage)); }); test('should call logContentCardImpression', () { @@ -1060,7 +1071,7 @@ void main() { bool testPinned = true; bool testRemoved = false; String testTitle = "some title"; - String testType = "some type"; + String testType = "banner_image"; String testUri = "https:\\/\\/www.sometesturi.com"; bool testUseWebView = true; bool testViewed = false; @@ -1084,7 +1095,7 @@ void main() { expect(contentCard.pinned, equals(testPinned)); expect(contentCard.removed, equals(testRemoved)); expect(contentCard.title, equals(testTitle)); - expect(contentCard.type, equals(testType)); + expect(contentCard.type, equals(ContentCardType.bannerImage)); expect(contentCard.url, equals(json.jsonDecode('"$testUri"'))); expect(contentCard.useWebView, equals(testUseWebView)); expect(contentCard.viewed, equals(testViewed)); diff --git a/test/widgets/content_card_test.dart b/test/widgets/content_card_test.dart new file mode 100644 index 0000000..1aa6345 --- /dev/null +++ b/test/widgets/content_card_test.dart @@ -0,0 +1,237 @@ +import 'package:braze_plugin/braze_plugin.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; + +class MockBrazeContentCard extends Mock implements BrazeContentCard {} + +void main() { + group('ContentCard', () { + late MockBrazeContentCard mockBrazeContentCard; + late Widget widget; + + setUp(() { + mockBrazeContentCard = MockBrazeContentCard(); + widget = MaterialApp( + home: ListView( + children: [ + ContentCard( + contentCard: mockBrazeContentCard, + ), + ], + ), + ); + + when(() => mockBrazeContentCard.image).thenReturn(''); + when(() => mockBrazeContentCard.url).thenReturn(''); + when(() => mockBrazeContentCard.viewed).thenReturn(true); + when(() => mockBrazeContentCard.pinned).thenReturn(false); + }); + + group('ViewedCard', () { + setUp(() { + when(() => mockBrazeContentCard.title).thenReturn('Title'); + when(() => mockBrazeContentCard.description).thenReturn('Description'); + when(() => mockBrazeContentCard.type) + .thenReturn(ContentCardType.shortNews); + }); + + testWidgets('displays ViewedCard when not viewed', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.viewed).thenReturn(false); + await tester.pumpWidget(widget); + + expect(find.byType(NotViewedCard), findsOneWidget); + }); + + testWidgets('does not display ViewedCard when viewed', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.viewed).thenReturn(true); + await tester.pumpWidget(widget); + + expect(find.byType(NotViewedCard), findsNothing); + }); + }); + + group('StarTriangleBackground', () { + setUp(() { + when(() => mockBrazeContentCard.title).thenReturn('Title'); + when(() => mockBrazeContentCard.description).thenReturn('Description'); + }); + + testWidgets('displays StarTriangleBackground when pinned is active', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.pinned).thenReturn(true); + when(() => mockBrazeContentCard.type) + .thenReturn(ContentCardType.shortNews); + await tester.pumpWidget(widget); + + expect(find.byType(StarTriangleBackground), findsOneWidget); + }); + + testWidgets('displays StarTriangleBackground when pinned is not active', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.pinned).thenReturn(false); + when(() => mockBrazeContentCard.type) + .thenReturn(ContentCardType.shortNews); + await tester.pumpWidget(widget); + + expect(find.byType(StarTriangleBackground), findsNothing); + }); + }); + + group('ShortNews', () { + setUp(() { + when(() => mockBrazeContentCard.title).thenReturn('Title'); + when(() => mockBrazeContentCard.description).thenReturn('Description'); + when(() => mockBrazeContentCard.type) + .thenReturn(ContentCardType.shortNews); + when(() => mockBrazeContentCard.pinned).thenReturn(false); + }); + + testWidgets('displays ShortNews when ContentCardType is shortNews', + (WidgetTester tester) async { + await tester.pumpWidget(widget); + + expect(find.byType(ShortNews), findsOneWidget); + }); + + testWidgets('displays title and description', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.image).thenReturn(''); + await tester.pumpWidget(widget); + + expect(find.text('Title'), findsOneWidget); + expect(find.text('Description'), findsOneWidget); + }); + + testWidgets('does not display Image when image is empty', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.image).thenReturn(''); + await tester.pumpWidget(widget); + + expect(find.byType(Image), findsNothing); + }); + + testWidgets('displays Image when image is not empty', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.image).thenReturn('image'); + + await mockNetworkImages(() async { + await tester.pumpWidget(widget); + expect(find.byType(Image), findsOneWidget); + }); + }); + }); + + group('CaptionedImage', () { + setUp(() { + when(() => mockBrazeContentCard.title).thenReturn('Title'); + when(() => mockBrazeContentCard.description).thenReturn('Description'); + when(() => mockBrazeContentCard.type) + .thenReturn(ContentCardType.captionedImage); + when(() => mockBrazeContentCard.pinned).thenReturn(false); + }); + + testWidgets('displays title, description and image', + (WidgetTester tester) async { + when(() => mockBrazeContentCard.image).thenReturn('image'); + + await mockNetworkImages(() async { + await tester.pumpWidget(widget); + + expect(find.text('Title'), findsOneWidget); + expect(find.text('Description'), findsOneWidget); + expect(find.byType(Image), findsOneWidget); + }); + }); + }); + + group('BannerImage', () { + testWidgets('displays image', (WidgetTester tester) async { + when(() => mockBrazeContentCard.image).thenReturn('image'); + when(() => mockBrazeContentCard.type) + .thenReturn(ContentCardType.bannerImage); + when(() => mockBrazeContentCard.pinned).thenReturn(false); + + await mockNetworkImages(() async { + await tester.pumpWidget(widget); + + expect(find.byType(Image), findsOneWidget); + }); + }); + }); + + group('onTappedCard', () { + late String url; + late Widget widgetWithOnChanged; + + void onChanged(String cardPressedUrl) { + url = cardPressedUrl; + } + + setUp(() { + url = ''; + + widgetWithOnChanged = MaterialApp( + home: ListView( + children: [ + ContentCard( + contentCard: mockBrazeContentCard, + cardPressedUrl: onChanged, + ), + ], + ), + ); + + when(() => mockBrazeContentCard.title).thenReturn('Title'); + when(() => mockBrazeContentCard.description).thenReturn('Description'); + when(() => mockBrazeContentCard.type) + .thenReturn(ContentCardType.shortNews); + when(() => mockBrazeContentCard.pinned).thenReturn(false); + }); + + testWidgets('when url is empty does not call cardPressedUrl on tap', + (WidgetTester tester) async { + await tester.pumpWidget(widget); + + await tester.tap(find.byType(ContentCard)); + + expect(url, isEmpty); + }); + + testWidgets( + 'when url is empty and onChanged is empty ' + 'does not call cardPressedUrl on tap', (WidgetTester tester) async { + await tester.pumpWidget(widgetWithOnChanged); + + await tester.tap(find.byType(ContentCard)); + + expect(url, isEmpty); + }); + + testWidgets( + 'when onChanged is empty ' + 'does not call cardPressedUrl on tap', (WidgetTester tester) async { + when(() => mockBrazeContentCard.url).thenReturn('link'); + await tester.pumpWidget(widget); + + await tester.tap(find.byType(ContentCard)); + + expect(url, isEmpty); + }); + + testWidgets( + 'when url is not empty and onChanged is not empty ' + 'calls cardPressedUrl on tap', (WidgetTester tester) async { + when(() => mockBrazeContentCard.url).thenReturn('link'); + await tester.pumpWidget(widgetWithOnChanged); + + await tester.tap(find.byType(ContentCard)); + + expect(url, 'link'); + }); + }); + }); +}