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,
+ ),
+ );
+ }
+}
+```
+
+
+
+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
+ }
+ ```
+
+
+
+#### 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 {}