diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e66070d2..d6e61530 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - dev pull_request: concurrency: @@ -31,11 +32,11 @@ jobs: run: flutter pub publish --dry-run - name: Test run: flutter test --coverage - - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 android: name: Android Integration Tests diff --git a/.gitignore b/.gitignore index 95a841b5..41a386e5 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,6 @@ app.*.symbols .gradle *.xcworkspacedata *.jar + +# don't check in golden failure output +**/failures/*.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 25952de5..018557e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.3.0 +* Add `renderFlutterWidget` method to save a Flutter Widget as an Image [#126](https://github.com/ABausG/home_widget/pull/126) by [leighajarett](https://github.com/leighajarett) + ## 0.2.1 * Update Gradle and Kotlin Versions * Update to support Flutter 3.10 diff --git a/README.md b/README.md index eed7a112..dec5e629 100644 --- a/README.md +++ b/README.md @@ -220,3 +220,143 @@ With home_widget you can use this by following these steps: ```dart HomeWidget.registerBackgroundCallback(backgroundCallback); ``` + +### Using images of Flutter widgets + +In some cases, you may not want to rewrite UI code in the native frameworks for your widgets. +For example, say you have a chart in your Flutter app configured with `CustomPaint`: + +```dart +class LineChart extends StatelessWidget { + const LineChart({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: LineChartPainter(), + child: const SizedBox( + height: 200, + width: 200, + ), + ); + } +} +``` + +Screenshot 2023-06-07 at 12 33 44 PM + +Rewriting the code to create this chart on both Android and iOS might be time consuming. +Instead, you can generate a png file of the Flutter widget and save it to a shared container +between your Flutter app and the home screen widget. + +```dart +var path = await HomeWidget.renderFlutterWidget( + const LineChart(), + key: 'lineChart', + logicalSize: Size(width: 400, height: 400), +); +``` +- `LineChart()` is the widget that will be rendered as an image. +- `key` is the key in the key/value storage on the device that stores the path of the file for easy retrieval on the native side + +#### iOS +To retrieve the image and display it in a widget, you can use the following SwiftUI code: + +1. In your `TimelineEntry` struct add a property to retrieve the path: + ```swift + struct MyEntry: TimelineEntry { + … + let lineChartPath: String + } + ``` + +2. Get the path from the `UserDefaults` in `getSnapshot`: + ```swift + func getSnapshot( + ... + let lineChartPath = userDefaults?.string(forKey: "lineChart") ?? "No screenshot available" + ``` +3. Create a `View` to display the chart and resize the image based on the `displaySize` of the widget: + ```swift + struct WidgetEntryView : View { + … + var ChartImage: some View { + if let uiImage = UIImage(contentsOfFile: entry.lineChartPath) { + let image = Image(uiImage: uiImage) + .resizable() + .frame(width: entry.displaySize.height*0.5, height: entry.displaySize.height*0.5, alignment: .center) + return AnyView(image) + } + print("The image file could not be loaded") + return AnyView(EmptyView()) + } + … + } + ``` + +4. Display the chart in the body of the widget's `View`: + ```swift + VStack { + Text(entry.title) + Text(entry.description) + ChartImage + } + ``` + +Screenshot 2023-06-07 at 12 57 28 PM + +#### Android + +1. Add an image UI element to your xml file: + ```xml + + ``` +2. Update your Kotlin code to get the chart image and put it into the widget, if it exists. + ```kotlin + class NewsWidget : AppWidgetProvider() { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + for (appWidgetId in appWidgetIds) { + // Get reference to SharedPreferences + val widgetData = HomeWidgetPlugin.getData(context) + val views = RemoteViews(context.packageName, R.layout.news_widget).apply { + // Get chart image and put it in the widget, if it exists + val imagePath = widgetData.getString("lineChart", null) + val imageFile = File(imagePath) + val imageExists = imageFile.exists() + if (imageExists) { + val myBitmap: Bitmap = BitmapFactory.decodeFile(imageFile.absolutePath) + setImageViewBitmap(R.id.widget_image, myBitmap) + } else { + println("image not found!, looked @: $imagePath") + } + // End new code + } + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + } + ``` diff --git a/example/android/app/src/main/kotlin/es/antonborri/home_widget_example/HomeWidgetExampleProvider.kt b/example/android/app/src/main/kotlin/es/antonborri/home_widget_example/HomeWidgetExampleProvider.kt index 4c869e2d..21d0d6d6 100644 --- a/example/android/app/src/main/kotlin/es/antonborri/home_widget_example/HomeWidgetExampleProvider.kt +++ b/example/android/app/src/main/kotlin/es/antonborri/home_widget_example/HomeWidgetExampleProvider.kt @@ -3,7 +3,9 @@ package es.antonborri.home_widget_example import android.appwidget.AppWidgetManager import android.content.Context import android.content.SharedPreferences +import android.graphics.BitmapFactory import android.net.Uri +import android.view.View import android.widget.RemoteViews import es.antonborri.home_widget.HomeWidgetBackgroundIntent import es.antonborri.home_widget.HomeWidgetLaunchIntent @@ -32,6 +34,15 @@ class HomeWidgetExampleProvider : HomeWidgetProvider() { val message = widgetData.getString("message", null) setTextViewText(R.id.widget_message, message ?: "No Message Set") + // Show Images saved with `renderFlutterWidget` + val image = widgetData.getString("dashIcon", null) + if (image != null) { + setImageViewBitmap(R.id.widget_img, BitmapFactory.decodeFile(image)) + setViewVisibility(R.id.widget_img, View.VISIBLE) + } else { + setViewVisibility(R.id.widget_img, View.GONE) + } + // Detect App opened via Click inside Flutter val pendingIntentWithData = HomeWidgetLaunchIntent.getActivity( context, diff --git a/example/android/app/src/main/res/layout/example_layout.xml b/example/android/app/src/main/res/layout/example_layout.xml index bdc3f482..941857a0 100644 --- a/example/android/app/src/main/res/layout/example_layout.xml +++ b/example/android/app/src/main/res/layout/example_layout.xml @@ -24,4 +24,10 @@ android:layout_height="wrap_content" android:textSize="18sp" tools:text="Message" /> + + \ No newline at end of file diff --git a/example/ios/HomeWidgetExample/HomeWidgetExample.swift b/example/ios/HomeWidgetExample/HomeWidgetExample.swift index d284c55d..b632a32f 100644 --- a/example/ios/HomeWidgetExample/HomeWidgetExample.swift +++ b/example/ios/HomeWidgetExample/HomeWidgetExample.swift @@ -38,6 +38,13 @@ struct ExampleEntry: TimelineEntry { struct HomeWidgetExampleEntryView : View { var entry: Provider.Entry let data = UserDefaults.init(suiteName:widgetGroupId) + let iconPath: String? + + init(entry: Provider.Entry) { + self.entry = entry + iconPath = data?.string(forKey: "dashIcon") + + } var body: some View { VStack.init(alignment: .leading, spacing: /*@START_MENU_TOKEN@*/nil/*@END_MENU_TOKEN@*/, content: { @@ -45,6 +52,11 @@ struct HomeWidgetExampleEntryView : View { Text(entry.message) .font(.body) .widgetURL(URL(string: "homeWidgetExample://message?message=\(entry.message)&homeWidget")) + if (iconPath != nil) { + Image(uiImage: UIImage(contentsOfFile: iconPath!)!).resizable() + .scaledToFill() + .frame(width: 64, height: 64) + } } ) } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6e..c87d15a3 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -27,8 +27,6 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + + - - com.apple.security.application-groups - group.de.zweidenker.homeWidgetExample + YOUR_GROUP_ID diff --git a/example/lib/main.dart b/example/lib/main.dart index cc93f768..4499b85e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -97,6 +97,14 @@ class _MyAppState extends State { return Future.wait([ HomeWidget.saveWidgetData('title', _titleController.text), HomeWidget.saveWidgetData('message', _messageController.text), + HomeWidget.renderFlutterWidget( + Icon( + Icons.flutter_dash, + size: 200, + ), + logicalSize: Size(200, 200), + key: 'dashIcon', + ), ]); } on PlatformException catch (exception) { debugPrint('Error Sending Data. $exception'); diff --git a/lib/dart_test.yaml b/lib/dart_test.yaml new file mode 100644 index 00000000..62bd2746 --- /dev/null +++ b/lib/dart_test.yaml @@ -0,0 +1,2 @@ +tags: + golden: \ No newline at end of file diff --git a/lib/home_widget.dart b/lib/home_widget.dart index e7729c47..b613c752 100644 --- a/lib/home_widget.dart +++ b/lib/home_widget.dart @@ -1,15 +1,23 @@ import 'dart:async'; -import 'dart:ui'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:home_widget/home_widget_callback_dispatcher.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path_provider_foundation/path_provider_foundation.dart'; /// A Flutter Plugin to simplify setting up and communicating with HomeScreenWidgets class HomeWidget { static const MethodChannel _channel = MethodChannel('home_widget'); static const EventChannel _eventChannel = EventChannel('home_widget/updates'); + /// The AppGroupId used for iOS Widgets + static String? groupId; + /// Save [data] to the Widget Storage /// /// Returns whether the data was saved or not @@ -55,6 +63,7 @@ class HomeWidget { /// Required on iOS to set the AppGroupId [groupId] in order to ensure /// communication between the App and the Widget Extension static Future setAppGroupId(String groupId) { + HomeWidget.groupId = groupId; return _channel.invokeMethod('setAppGroupId', {'groupId': groupId}); } @@ -92,9 +101,127 @@ class HomeWidget { /// More Info on setting this up in the README static Future registerBackgroundCallback(Function(Uri?) callback) { final args = [ - PluginUtilities.getCallbackHandle(callbackDispatcher)?.toRawHandle(), - PluginUtilities.getCallbackHandle(callback)?.toRawHandle() + ui.PluginUtilities.getCallbackHandle(callbackDispatcher)?.toRawHandle(), + ui.PluginUtilities.getCallbackHandle(callback)?.toRawHandle() ]; return _channel.invokeMethod('registerBackgroundCallback', args); } + + /// Generate a screenshot based on a given widget. + /// This method renders the widget to an image (png) file with the provided filename. + /// The png file is saved to the App Group container and the full path is returned as a string. + /// The filename is saved to UserDefaults using the provided key. + static Future renderFlutterWidget( + Widget widget, { + required String key, + Size logicalSize = const Size(200, 200), + double pixelRatio = 1, + }) async { + /// finding the widget in the current context by the key. + final RenderRepaintBoundary repaintBoundary = RenderRepaintBoundary(); + + /// create a new pipeline owner + final PipelineOwner pipelineOwner = PipelineOwner(); + + /// create a new build owner + final BuildOwner buildOwner = BuildOwner(focusManager: FocusManager()); + + try { + final RenderView renderView = RenderView( + view: ui.PlatformDispatcher.instance.implicitView!, + child: RenderPositionedBox( + alignment: Alignment.center, + child: repaintBoundary, + ), + configuration: ViewConfiguration( + size: logicalSize, + devicePixelRatio: 1.0, + ), + ); + + /// setting the rootNode to the renderview of the widget + pipelineOwner.rootNode = renderView; + + /// setting the renderView to prepareInitialFrame + renderView.prepareInitialFrame(); + + /// setting the rootElement with the widget that has to be captured + final RenderObjectToWidgetElement rootElement = + RenderObjectToWidgetAdapter( + container: repaintBoundary, + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + // image is center aligned + mainAxisAlignment: MainAxisAlignment.center, + children: [ + widget, + ], + ), + ), + ).attachToRenderTree(buildOwner); + + ///adding the rootElement to the buildScope + buildOwner.buildScope(rootElement); + + ///adding the rootElement to the buildScope + buildOwner.buildScope(rootElement); + + /// finialize the buildOwner + buildOwner.finalizeTree(); + + ///Flush Layout + pipelineOwner.flushLayout(); + + /// Flush Compositing Bits + pipelineOwner.flushCompositingBits(); + + /// Flush paint + pipelineOwner.flushPaint(); + + final ui.Image image = + await repaintBoundary.toImage(pixelRatio: pixelRatio); + + /// The raw image is converted to byte data. + final ByteData? byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + + try { + late final String? directory; + try { + // coverage:ignore-start + if (Platform.environment.containsKey('FLUTTER_TEST')) { + throw UnsupportedError( + 'Tests should always use default Path provider for easier mocking', + ); + } + final PathProviderFoundation provider = PathProviderFoundation(); + directory = await provider.getContainerPath( + appGroupIdentifier: HomeWidget.groupId!, + ); + // coverage:ignore-end + } on UnsupportedError catch (_) { + directory = (await getApplicationSupportDirectory()).path; + } + final String path = '$directory/home_widget/$key.png'; + final File file = File(path); + if (!await file.exists()) { + await file.create(recursive: true); + } + await file.writeAsBytes(byteData!.buffer.asUint8List()); + + // Save the filename to UserDefaults if a key was provided + _channel.invokeMethod('saveWidgetData', { + 'id': key, + 'data': path, + }); + + return path; + } catch (e) { + throw Exception('Failed to save screenshot to app group container: $e'); + } + } catch (e) { + throw Exception('Failed to render the widget: $e'); + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 7deb1d1b..8defa1b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: home_widget description: A plugin to provide a common interface for creating HomeScreen Widgets for Android and iOS. -version: 0.2.1 +version: 0.3.0 repository: https://github.com/ABausG/home_widget environment: @@ -10,11 +10,17 @@ environment: dependencies: flutter: sdk: flutter + path_provider: ^2.0.15 + path_provider_foundation: ^2.2.3 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.1 + golden_toolkit: ^0.15.0 + mocktail: ^0.3.0 + path_provider_platform_interface: + plugin_platform_interface: flutter: plugin: diff --git a/test/goldens/render-flutter-widget.png b/test/goldens/render-flutter-widget.png new file mode 100644 index 00000000..249c25b3 Binary files /dev/null and b/test/goldens/render-flutter-widget.png differ diff --git a/test/home_widget_test.dart b/test/home_widget_test.dart index 4fa14eee..07b94ca4 100644 --- a/test/home_widget_test.dart +++ b/test/home_widget_test.dart @@ -1,11 +1,20 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:home_widget/home_widget.dart'; import 'package:home_widget/home_widget_callback_dispatcher.dart'; +import 'package:mocktail/mocktail.dart'; + +// ignore: depend_on_referenced_packages +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; + +import 'mocks.dart'; const updateChannel = MethodChannel('home_widget/updates'); @@ -167,6 +176,146 @@ void main() { await expectation; }); }); + + group('Render Flutter Widget', () { + TestWidgetsFlutterBinding.ensureInitialized(); + final directory = Directory('app/directory'); + + const size = Size(200, 200); + final targetWidget = SizedBox.fromSize( + size: size, + child: const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ColoredBox( + color: Colors.red, + ), + ), + Expanded( + child: ColoredBox( + color: Colors.green, + ), + ), + Expanded( + child: ColoredBox( + color: Colors.blue, + ), + ), + ], + ), + ); + + setUp(() { + final pathProvider = MockPathProvider(); + when(() => pathProvider.getApplicationSupportPath()) + .thenAnswer((invocation) async => directory.path); + PathProviderPlatform.instance = pathProvider; + }); + + testGoldens('Render Flutter Widget', (tester) async { + final byteCompleter = Completer(); + final file = MockFile(); + + when(() => file.exists()).thenAnswer((invocation) async => false); + when(() => file.create(recursive: true)) + .thenAnswer((invocation) async => file); + when(() => file.writeAsBytes(any())).thenAnswer((invocation) async { + byteCompleter + .complete(Uint8List.fromList(invocation.positionalArguments.first)); + return file; + }); + + await IOOverrides.runZoned( + () async { + await tester.runAsync(() async { + final path = await HomeWidget.renderFlutterWidget( + targetWidget, + key: 'screenshot', + logicalSize: size, + ); + final expectedPath = '${directory.path}/home_widget/screenshot.png'; + expect(path, equals(expectedPath)); + + final arguments = await passedArguments.future; + expect(arguments['id'], 'screenshot'); + expect(arguments['data'], expectedPath); + }); + }, + createFile: (path) { + when(() => file.path).thenReturn(path); + return file; + }, + ); + + final bytes = await byteCompleter.future; + + await tester.pumpWidgetBuilder( + Image.memory( + bytes, + width: size.height, + height: size.height, + ), + surfaceSize: size, + ); + + await tester.pumpAndSettle(); + await screenMatchesGolden(tester, 'render-flutter-widget'); + }); + + testGoldens('Error rendering Flutter Widget throws', (tester) async { + final file = MockFile(); + await IOOverrides.runZoned( + () async { + await tester.runAsync( + () async { + expect( + () async => await HomeWidget.renderFlutterWidget( + Builder(builder: (_) => const SizedBox()), + logicalSize: Size.zero, + key: 'screenshot', + ), + throwsException, + ); + }, + ); + }, + createFile: (path) { + when(() => file.path).thenReturn(path); + return file; + }, + ); + }); + + testGoldens('Error saving Widget throws', (tester) async { + final file = MockFile(); + + when(() => file.exists()).thenAnswer((invocation) async => false); + when(() => file.create(recursive: true)) + .thenAnswer((invocation) async => file); + when(() => file.writeAsBytes(any())) + .thenAnswer((invocation) => Future.error('Error')); + + await IOOverrides.runZoned( + () async { + await tester.runAsync(() async { + expect( + () async => await HomeWidget.renderFlutterWidget( + targetWidget, + logicalSize: size, + key: 'screenshot', + ), + throwsException, + ); + }); + }, + createFile: (path) { + when(() => file.path).thenReturn(path); + return file; + }, + ); + }); + }); } void emitEvent(ByteData? event) { diff --git a/test/mocks.dart b/test/mocks.dart new file mode 100644 index 00000000..8504a699 --- /dev/null +++ b/test/mocks.dart @@ -0,0 +1,13 @@ +// ignore_for_file: depend_on_referenced_packages + +import 'dart:io'; + +import 'package:mocktail/mocktail.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockFile extends Mock implements File {} + +class MockPathProvider extends Mock + with MockPlatformInterfaceMixin + implements PathProviderPlatform {}