From 38ae0c7bb55a0b241a55664a2a420d669df15d06 Mon Sep 17 00:00:00 2001 From: Casey Rogers Date: Fri, 15 Nov 2024 14:08:26 -0500 Subject: [PATCH] initial refactor to move to typed API --- .../ios/Flutter/AppFrameworkInfo.plist | 2 +- feedback/example/ios/Podfile | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 17 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- feedback/example/ios/Runner/AppDelegate.swift | 2 +- feedback/example/ios/Runner/Info.plist | 2 + feedback/example/lib/custom_feedback.dart | 46 +++-- feedback/example/lib/feedback_functions.dart | 108 ++++++++++- feedback/example/lib/main.dart | 183 +++++------------- feedback/lib/feedback.dart | 1 + feedback/lib/src/better_feedback.dart | 102 +++++----- feedback/lib/src/feedback_bottom_sheet.dart | 41 ++-- .../src/feedback_builder/string_feedback.dart | 109 ++++++++--- feedback/lib/src/feedback_controller.dart | 35 ++-- feedback/lib/src/feedback_data.dart | 6 +- .../lib/src/feedback_form_controller.dart | 27 +++ feedback/lib/src/feedback_widget.dart | 125 +++--------- feedback/pubspec.yaml | 1 + feedback/test/feedback_test.dart | 6 +- 19 files changed, 432 insertions(+), 385 deletions(-) create mode 100644 feedback/lib/src/feedback_form_controller.dart diff --git a/feedback/example/ios/Flutter/AppFrameworkInfo.plist b/feedback/example/ios/Flutter/AppFrameworkInfo.plist index f2872cf4..8c6e5614 100644 --- a/feedback/example/ios/Flutter/AppFrameworkInfo.plist +++ b/feedback/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 12.0 diff --git a/feedback/example/ios/Podfile b/feedback/example/ios/Podfile index 1e8c3c90..279576f3 100644 --- a/feedback/example/ios/Podfile +++ b/feedback/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '9.0' +# platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/feedback/example/ios/Runner.xcodeproj/project.pbxproj b/feedback/example/ios/Runner.xcodeproj/project.pbxproj index 74406c5c..071f1101 100644 --- a/feedback/example/ios/Runner.xcodeproj/project.pbxproj +++ b/feedback/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -164,7 +164,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -214,14 +214,14 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", "${BUILT_PRODUCTS_DIR}/flutter_email_sender/flutter_email_sender.framework", - "${BUILT_PRODUCTS_DIR}/path_provider_ios/path_provider_ios.framework", + "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_email_sender.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_ios.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", ); @@ -232,10 +232,12 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -246,6 +248,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -355,7 +358,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -440,7 +443,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -489,7 +492,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/feedback/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/feedback/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3db53b6e..e67b2808 100644 --- a/feedback/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/feedback/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + diff --git a/feedback/example/lib/custom_feedback.dart b/feedback/example/lib/custom_feedback.dart index d3d32d0c..cdc4757f 100644 --- a/feedback/example/lib/custom_feedback.dart +++ b/feedback/example/lib/custom_feedback.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + import 'package:feedback/feedback.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// A data type holding user feedback consisting of a feedback type, free from @@ -53,11 +56,11 @@ class CustomFeedbackForm extends StatefulWidget { const CustomFeedbackForm({ super.key, required this.onSubmit, - required this.scrollController, + required this.formController, }); final OnSubmit onSubmit; - final ScrollController? scrollController; + final FeedbackFormController formController; @override State createState() => _CustomFeedbackFormState(); @@ -73,13 +76,11 @@ class _CustomFeedbackFormState extends State { Expanded( child: Stack( children: [ - if (widget.scrollController != null) - const FeedbackSheetDragHandle(), + if (widget.formController.scrollController != null) const FeedbackSheetDragHandle(), ListView( - controller: widget.scrollController, + controller: widget.formController.scrollController, // Pad the top by 20 to match the corner radius if drag enabled. - padding: EdgeInsets.fromLTRB( - 16, widget.scrollController != null ? 20 : 16, 16, 0), + padding: EdgeInsets.fromLTRB(16, widget.formController.scrollController != null ? 20 : 16, 16, 0), children: [ const Text('What kind of feedback do you want to give?'), Row( @@ -99,16 +100,11 @@ class _CustomFeedbackFormState extends State { .map( (type) => DropdownMenuItem( value: type, - child: Text(type - .toString() - .split('.') - .last - .replaceAll('_', ' ')), + child: Text(type.toString().split('.').last.replaceAll('_', ' ')), ), ) .toList(), - onChanged: (feedbackType) => setState(() => - _customFeedback.feedbackType = feedbackType), + onChanged: (feedbackType) => setState(() => _customFeedback.feedbackType = feedbackType), ), ElevatedButton( child: const Text('Open Dialog #2'), @@ -132,8 +128,7 @@ class _CustomFeedbackFormState extends State { const SizedBox(height: 16), const Text('What is your feedback?'), TextField( - onChanged: (newFeedback) => - _customFeedback.feedbackText = newFeedback, + onChanged: (newFeedback) => _customFeedback.feedbackText = newFeedback, ), const SizedBox(height: 16), const Text('How does this make you feel?'), @@ -149,10 +144,21 @@ class _CustomFeedbackFormState extends State { TextButton( // disable this button until the user has specified a feedback type onPressed: _customFeedback.feedbackType != null - ? () => widget.onSubmit( - _customFeedback.feedbackText ?? '', - extras: _customFeedback.toMap(), - ) + ? () async { + final Uint8List screenshot = await widget.formController.takeScreenshot(context); + if (!context.mounted) { + // User popped the page while screenshotting, abort. + return; + } + await widget.onSubmit( + context, + UserFeedback( + text: _customFeedback.feedbackText ?? '', + screenshot: screenshot, + extra: _customFeedback.toMap(), + ), + ); + } : null, child: const Text('submit'), ), diff --git a/feedback/example/lib/feedback_functions.dart b/feedback/example/lib/feedback_functions.dart index e5835615..224e5e4c 100644 --- a/feedback/example/lib/feedback_functions.dart +++ b/feedback/example/lib/feedback_functions.dart @@ -1,7 +1,62 @@ // ignore_for_file: avoid_print +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + import 'package:feedback/feedback.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_email_sender/flutter_email_sender.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Map from a string route to the underlying feedback route and it's corresponding `onSubmit`. +/// This is just here to support the legacy `simpleFeedback` API. +Future onSubmitWithStringRoute( + BuildContext context, + String? route, + UserFeedback feedback, +) { + return switch (route) { + 'alert_dialog' => onSubmitAlertDialog(context, feedback), + 'email' => onSubmitEmail(context, feedback), + 'platform_sharing' => onSubmitPlatformSharing(context, feedback), + _ => throw UnsupportedError('Unsupported route: `$route`'), + }; +} + +Future onSubmitEmail(BuildContext context, UserFeedback feedback) async { + // draft an email and send to developer + final screenshotFilePath = await writeImageToStorage(feedback.screenshot); + + final Email email = Email( + body: feedback.text, + subject: 'App Feedback', + recipients: ['john.doe@flutter.dev'], + attachmentPaths: [screenshotFilePath], + isHTML: false, + ); + await FlutterEmailSender.send(email); +} + +Future onSubmitPlatformSharing(BuildContext context, UserFeedback feedback) async { + final screenshotFilePath = await writeImageToStorage(feedback.screenshot); + + // ignore: deprecated_member_use + await Share.shareFiles( + [screenshotFilePath], + text: feedback.text, + ); +} + +Future writeImageToStorage(Uint8List feedbackScreenshot) async { + final Directory output = await getTemporaryDirectory(); + final String screenshotFilePath = '${output.path}/feedback.png'; + final File screenshotFile = File(screenshotFilePath); + await screenshotFile.writeAsBytes(feedbackScreenshot); + return screenshotFilePath; +} /// Prints the given feedback to the console. /// This is useful for debugging purposes. @@ -17,14 +72,9 @@ void consoleFeedbackFunction( } } -/// Shows an [AlertDialog] with the given feedback. -/// This is useful for debugging purposes. -void alertFeedbackFunction( - BuildContext outerContext, - UserFeedback feedback, -) { - showDialog( - context: outerContext, +Future onSubmitAlertDialog(BuildContext context, UserFeedback feedback) async { + await showDialog( + context: context, builder: (context) { return AlertDialog( title: Text(feedback.text), @@ -54,3 +104,45 @@ void alertFeedbackFunction( }, ); } + +Future createGitlabIssueFromFeedback(BuildContext context, UserFeedback feedback) async { + const projectId = 'your-gitlab-project-id'; + const apiToken = 'your-gitlab-api-token'; + + final screenshotFilePath = await writeImageToStorage(feedback.screenshot); + + // Upload screenshot + final uploadRequest = http.MultipartRequest( + 'POST', + Uri.https( + 'gitlab.com', + '/api/v4/projects/$projectId/uploads', + ), + ) + ..files.add(await http.MultipartFile.fromPath( + 'file', + screenshotFilePath, + )) + ..headers.putIfAbsent('PRIVATE-TOKEN', () => apiToken); + final uploadResponse = await uploadRequest.send(); + + final dynamic uploadResponseMap = jsonDecode( + await uploadResponse.stream.bytesToString(), + ); + + // Create issue + await http.post( + Uri.https( + 'gitlab.com', + '/api/v4/projects/$projectId/issues', + { + 'title': feedback.text.padRight(80), + 'description': '${feedback.text}\n' + "${uploadResponseMap["markdown"] ?? "Missing image!"}", + }, + ), + headers: { + 'PRIVATE-TOKEN': apiToken, + }, + ); +} diff --git a/feedback/example/lib/main.dart b/feedback/example/lib/main.dart index 0f0fda02..22d6e1ce 100644 --- a/feedback/example/lib/main.dart +++ b/feedback/example/lib/main.dart @@ -1,17 +1,11 @@ -import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:example/feedback_functions.dart'; import 'package:feedback/feedback.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; -import 'package:flutter_email_sender/flutter_email_sender.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'custom_feedback.dart'; @@ -32,35 +26,47 @@ bool _useCustomFeedback = false; class _MyAppState extends State { @override Widget build(BuildContext context) { - return BetterFeedback( - // If custom feedback is not enabled, supply null and the default text - // feedback form will be used. - feedbackBuilder: _useCustomFeedback - ? (context, onSubmit, scrollController) => CustomFeedbackForm( - onSubmit: onSubmit, - scrollController: scrollController, - ) - : null, - theme: FeedbackThemeData( - background: Colors.grey, - feedbackSheetColor: Colors.grey[50]!, - drawColors: [ - Colors.red, - Colors.green, - Colors.blue, - Colors.yellow, - ], - ), - darkTheme: FeedbackThemeData.dark(), - localizationsDelegates: [ - GlobalFeedbackLocalizationsDelegate(), - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, + final theme = FeedbackThemeData( + background: Colors.grey, + feedbackSheetColor: Colors.grey[50]!, + drawColors: [ + Colors.red, + Colors.green, + Colors.blue, + Colors.yellow, ], + ); + final delegates = >[ + GlobalFeedbackLocalizationsDelegate(), + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + if (_useCustomFeedback) { + return BetterFeedback.customFeedback( + feedbackBuilder: (context, onSubmit, formController) => CustomFeedbackForm( + onSubmit: onSubmit, + formController: formController, + ), + theme: theme, + darkTheme: FeedbackThemeData.dark(), + localizationsDelegates: delegates, + localeOverride: const Locale('en'), + mode: FeedbackMode.draw, + child: MaterialApp( + title: 'Feedback Demo', + theme: ThemeData( + primarySwatch: _useCustomFeedback ? Colors.green : Colors.blue, + ), + home: MyHomePage(_toggleCustomizedFeedback), + ), + ); + } + return BetterFeedback.simpleFeedback( + darkTheme: FeedbackThemeData.dark(), + localizationsDelegates: delegates, localeOverride: const Locale('en'), mode: FeedbackMode.draw, - pixelRatio: 1, child: MaterialApp( title: 'Feedback Demo', theme: ThemeData( @@ -71,8 +77,7 @@ class _MyAppState extends State { ); } - void _toggleCustomizedFeedback() => - setState(() => _useCustomFeedback = !_useCustomFeedback); + void _toggleCustomizedFeedback() => setState(() => _useCustomFeedback = !_useCustomFeedback); } class MyHomePage extends StatelessWidget { @@ -84,9 +89,7 @@ class MyHomePage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(_useCustomFeedback - ? '(Custom) Feedback Example' - : 'Feedback Example'), + title: Text(_useCustomFeedback ? '(Custom) Feedback Example' : 'Feedback Example'), ), drawer: Drawer( child: Container(color: Colors.blue), @@ -126,17 +129,13 @@ class MyHomePage extends StatelessWidget { const Divider(), ElevatedButton( child: const Text('Provide feedback'), - onPressed: () { - BetterFeedback.of(context).show( - (feedback) async { - // upload to server, share whatever - // for example purposes just show it to the user - alertFeedbackFunction( - context, - feedback, - ); - }, - ); + onPressed: () async { + // We're going to open the alert dialog after the feedback is complete, so + // there's no need to provide an `onSubmit` callback. + final feedback = await BetterFeedback.simpleFeedbackOf(context).show(null); + // User cancelled feedback before submitting and/or navigated away. + if (feedback == null || !context.mounted) return; + onSubmitAlertDialog(context, feedback); }, ), const SizedBox(height: 10), @@ -144,20 +143,7 @@ class MyHomePage extends StatelessWidget { TextButton( child: const Text('Provide E-Mail feedback'), onPressed: () { - BetterFeedback.of(context).show((feedback) async { - // draft an email and send to developer - final screenshotFilePath = - await writeImageToStorage(feedback.screenshot); - - final Email email = Email( - body: feedback.text, - subject: 'App Feedback', - recipients: ['john.doe@flutter.dev'], - attachmentPaths: [screenshotFilePath], - isHTML: false, - ); - await FlutterEmailSender.send(email); - }); + BetterFeedback.simpleFeedbackOf(context).show(onSubmitEmail); }, ), const SizedBox(height: 10), @@ -165,18 +151,7 @@ class MyHomePage extends StatelessWidget { ElevatedButton( child: const Text('Provide feedback via platform sharing'), onPressed: () { - BetterFeedback.of(context).show( - (feedback) async { - final screenshotFilePath = - await writeImageToStorage(feedback.screenshot); - - // ignore: deprecated_member_use - await Share.shareFiles( - [screenshotFilePath], - text: feedback.text, - ); - }, - ); + BetterFeedback.simpleFeedbackOf(context).show(onSubmitPlatformSharing); }, ), const Divider(), @@ -209,13 +184,11 @@ class MyHomePage extends StatelessWidget { ), floatingActionButton: MaterialButton( color: Theme.of(context).primaryColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20))), - child: const Text('toggle feedback mode', - style: TextStyle(color: Colors.white)), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20))), + child: const Text('toggle feedback mode', style: TextStyle(color: Colors.white)), onPressed: () { // don't toggle the feedback mode if it's currently visible - if (!BetterFeedback.of(context).isVisible) { + if (!BetterFeedback.simpleFeedbackOf(context).isVisible) { toggleCustomizedFeedback(); } }, @@ -237,55 +210,3 @@ class _SecondaryScaffold extends StatelessWidget { ); } } - -Future writeImageToStorage(Uint8List feedbackScreenshot) async { - final Directory output = await getTemporaryDirectory(); - final String screenshotFilePath = '${output.path}/feedback.png'; - final File screenshotFile = File(screenshotFilePath); - await screenshotFile.writeAsBytes(feedbackScreenshot); - return screenshotFilePath; -} - -Future createGitlabIssueFromFeedback(BuildContext context) async { - BetterFeedback.of(context).show((feedback) async { - const projectId = 'your-gitlab-project-id'; - const apiToken = 'your-gitlab-api-token'; - - final screenshotFilePath = await writeImageToStorage(feedback.screenshot); - - // Upload screenshot - final uploadRequest = http.MultipartRequest( - 'POST', - Uri.https( - 'gitlab.com', - '/api/v4/projects/$projectId/uploads', - ), - ) - ..files.add(await http.MultipartFile.fromPath( - 'file', - screenshotFilePath, - )) - ..headers.putIfAbsent('PRIVATE-TOKEN', () => apiToken); - final uploadResponse = await uploadRequest.send(); - - final dynamic uploadResponseMap = jsonDecode( - await uploadResponse.stream.bytesToString(), - ); - - // Create issue - await http.post( - Uri.https( - 'gitlab.com', - '/api/v4/projects/$projectId/issues', - { - 'title': feedback.text.padRight(80), - 'description': '${feedback.text}\n' - "${uploadResponseMap["markdown"] ?? "Missing image!"}", - }, - ), - headers: { - 'PRIVATE-TOKEN': apiToken, - }, - ); - }); -} diff --git a/feedback/lib/feedback.dart b/feedback/lib/feedback.dart index 121146be..5d1198f2 100644 --- a/feedback/lib/feedback.dart +++ b/feedback/lib/feedback.dart @@ -8,3 +8,4 @@ export 'src/feedback_mode.dart'; export 'src/l18n/translation.dart'; export 'src/theme/feedback_theme.dart' show FeedbackThemeData; export 'src/user_feedback.dart'; +export 'src/feedback_form_controller.dart'; diff --git a/feedback/lib/src/better_feedback.dart b/feedback/lib/src/better_feedback.dart index 9657add6..f85a2895 100644 --- a/feedback/lib/src/better_feedback.dart +++ b/feedback/lib/src/better_feedback.dart @@ -11,11 +11,7 @@ import 'package:feedback/src/utilities/renderer/renderer.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -/// The function to be called when the user submits his feedback. -typedef OnSubmit = Future Function( - String feedback, { - Map? extras, -}); +typedef OnSubmit = FutureOr Function(BuildContext context, UserFeedback feedback); /// A function that returns a Widget that prompts the user for feedback and /// calls [OnSubmit] when the user wants to submit their feedback. @@ -27,10 +23,11 @@ typedef OnSubmit = Future Function( /// scrolled. Typically, this will be a `ListView` or `SingleChildScrollView` /// wrapping the feedback sheet's content. /// See: [FeedbackThemeData.sheetIsDraggable] and [DraggableScrollableSheet]. -typedef FeedbackBuilder = Widget Function( - BuildContext, - OnSubmit, - ScrollController?, +typedef FeedbackBuilder = Widget Function( + BuildContext context, + T route, + // All the callbacks the sheet needs to communicate with `BetterFeedback`. + FeedbackFormController formController, ); /// A drag handle to be placed at the top of a draggable feedback sheet. @@ -71,12 +68,6 @@ class FeedbackSheetDragHandle extends StatelessWidget { } } -/// Function which gets called when the user submits his feedback. -/// [feedback] is the user generated feedback. A string, by default. -/// [screenshot] is a raw png encoded image. -/// [OnFeedbackCallback] should cast [feedback] to the appropriate type. -typedef OnFeedbackCallback = FutureOr Function(UserFeedback); - /// A feedback widget that uses a custom widget and data type for /// prompting the user for their feedback. This widget should be the root of /// your widget tree. Specifically, it should be above any [Navigator] widgets, @@ -90,32 +81,39 @@ typedef OnFeedbackCallback = FutureOr Function(UserFeedback); /// home: MyHomePage(), /// ); /// ``` -/// -class BetterFeedback extends StatefulWidget { - /// Creates a [BetterFeedback]. - /// - /// /// ```dart - /// BetterFeedback( - /// child: MaterialApp( - /// title: 'App', - /// home: MyHomePage(), - /// ); - /// ``` - const BetterFeedback({ +class BetterFeedback extends StatefulWidget { + static BetterFeedback simpleFeedback({ + Key? key, + required Widget child, + ThemeMode? themeMode, + FeedbackThemeData? theme, + FeedbackThemeData? darkTheme, + List>? localizationsDelegates, + Locale? localeOverride, + FeedbackMode mode = FeedbackMode.draw, + }) => + BetterFeedback.customFeedback( + key: key, + feedbackBuilder: simpleFeedbackBuilder, + themeMode: themeMode, + darkTheme: darkTheme, + localizationsDelegates: localizationsDelegates, + localeOverride: localeOverride, + mode: mode, + child: child, + ); + + const BetterFeedback.customFeedback({ super.key, required this.child, - this.feedbackBuilder, + required this.feedbackBuilder, this.themeMode, this.theme, this.darkTheme, this.localizationsDelegates, this.localeOverride, this.mode = FeedbackMode.draw, - this.pixelRatio = 3.0, - }) : assert( - pixelRatio > 0, - 'pixelRatio needs to be larger than 0', - ); + }); /// The application to wrap, typically a [MaterialApp]. final Widget child; @@ -125,7 +123,7 @@ class BetterFeedback extends StatefulWidget { /// some form fields and a submit button that calls [OnSubmit] when pressed. /// Defaults to [StringFeedback] which uses a single editable text field to /// prompt for input. - final FeedbackBuilder? feedbackBuilder; + final FeedbackBuilder feedbackBuilder; /// Determines which theme will be used by the Feedback UI. /// If set to [ThemeMode.system], the choice of which theme to use will be based @@ -166,16 +164,6 @@ class BetterFeedback extends StatefulWidget { /// See [FeedbackMode] for other options. final FeedbackMode mode; - /// The pixelRatio describes the scale between - /// the logical pixels and the size of the output image. - /// Specifying 1.0 will give you a 1:1 mapping between - /// logical pixels and the output pixels in the image. - /// The default is a pixel ration of 3 and a value below 1 is not recommended. - /// - /// See [RenderRepaintBoundary](https://api.flutter.dev/flutter/rendering/RenderRepaintBoundary/toImage.html) - /// for information on the underlying implementation. - final double pixelRatio; - /// Call `BetterFeedback.of(context)` to get an /// instance of [FeedbackData] on which you can call `.show()` or `.hide()` /// to enable or disable the feedback view. @@ -185,22 +173,25 @@ class BetterFeedback extends StatefulWidget { /// BetterFeedback.of(context).show(...); /// BetterFeedback.of(context).hide(...); /// ``` - static FeedbackController of(BuildContext context) { - final feedbackData = - context.dependOnInheritedWidgetOfExactType(); + static FeedbackController of(BuildContext context) { + final feedbackData = context.dependOnInheritedWidgetOfExactType>(); assert( feedbackData != null, - 'You need to add a $BetterFeedback widget above this context!', + 'You need to add a ${BetterFeedback} widget above this context!', ); return feedbackData!.controller; } + static FeedbackController simpleFeedbackOf(BuildContext context) { + return of(context); + } + @override - State createState() => _BetterFeedbackState(); + State> createState() => _BetterFeedbackState(); } -class _BetterFeedbackState extends State { - FeedbackController controller = FeedbackController(); +class _BetterFeedbackState extends State> { + FeedbackController controller = FeedbackController(); @override void initState() { @@ -229,13 +220,12 @@ class _BetterFeedbackState extends State { child: Builder( builder: (context) { assert(debugCheckHasFeedbackLocalizations(context)); - return FeedbackWidget( - isFeedbackVisible: controller.isVisible, + return FeedbackWidget( + isVisible: controller.isVisible, + route: controller.currentRoute, drawColors: FeedbackTheme.of(context).drawColors, mode: widget.mode, - pixelRatio: widget.pixelRatio, - feedbackBuilder: - widget.feedbackBuilder ?? simpleFeedbackBuilder, + feedbackBuilder: widget.feedbackBuilder, child: widget.child, ); }, diff --git a/feedback/lib/src/feedback_bottom_sheet.dart b/feedback/lib/src/feedback_bottom_sheet.dart index 8a09c097..ba39bfe5 100644 --- a/feedback/lib/src/feedback_bottom_sheet.dart +++ b/feedback/lib/src/feedback_bottom_sheet.dart @@ -1,30 +1,35 @@ // ignore_for_file: public_member_api_docs import 'package:feedback/src/better_feedback.dart'; +import 'package:feedback/src/feedback_form_controller.dart'; +import 'package:feedback/src/screenshot.dart'; import 'package:feedback/src/theme/feedback_theme.dart'; import 'package:feedback/src/utilities/back_button_interceptor.dart'; import 'package:flutter/material.dart'; /// Shows the text input in which the user can describe his feedback. -class FeedbackBottomSheet extends StatelessWidget { +class FeedbackBottomSheet extends StatelessWidget { const FeedbackBottomSheet({ super.key, + required this.route, + required this.screenshotController, required this.feedbackBuilder, - required this.onSubmit, required this.sheetProgress, }); - final FeedbackBuilder feedbackBuilder; - final OnSubmit onSubmit; + final T route; + final ScreenshotController screenshotController; + final FeedbackBuilder feedbackBuilder; final ValueNotifier sheetProgress; @override Widget build(BuildContext context) { if (FeedbackTheme.of(context).sheetIsDraggable) { return DraggableScrollableActuator( - child: _DraggableFeedbackSheet( + child: _DraggableFeedbackSheet( + route: route, + screenshotController: screenshotController, feedbackBuilder: feedbackBuilder, - onSubmit: onSubmit, sheetProgress: sheetProgress, ), ); @@ -43,8 +48,8 @@ class FeedbackBottomSheet extends StatelessWidget { return MaterialPageRoute( builder: (_) => feedbackBuilder( context, - onSubmit, - null, + route, + FeedbackFormController(screenshotController), ), ); }, @@ -55,23 +60,25 @@ class FeedbackBottomSheet extends StatelessWidget { } } -class _DraggableFeedbackSheet extends StatefulWidget { +class _DraggableFeedbackSheet extends StatefulWidget { const _DraggableFeedbackSheet({ + required this.route, + required this.screenshotController, required this.feedbackBuilder, - required this.onSubmit, required this.sheetProgress, }); - final FeedbackBuilder feedbackBuilder; - final OnSubmit onSubmit; + final T route; + final ScreenshotController screenshotController; + final FeedbackBuilder feedbackBuilder; final ValueNotifier sheetProgress; @override - State<_DraggableFeedbackSheet> createState() => + State<_DraggableFeedbackSheet> createState() => _DraggableFeedbackSheetState(); } -class _DraggableFeedbackSheetState extends State<_DraggableFeedbackSheet> { +class _DraggableFeedbackSheetState extends State<_DraggableFeedbackSheet> { @override void initState() { super.initState(); @@ -111,7 +118,7 @@ class _DraggableFeedbackSheetState extends State<_DraggableFeedbackSheet> { ), Expanded( child: DraggableScrollableSheet( - controller: BetterFeedback.of(context).sheetController, + controller: BetterFeedback.of(context).sheetController, snap: true, minChildSize: collapsedHeight, initialChildSize: collapsedHeight, @@ -136,8 +143,8 @@ class _DraggableFeedbackSheetState extends State<_DraggableFeedbackSheet> { return MaterialPageRoute( builder: (_) => widget.feedbackBuilder( context, - widget.onSubmit, - scrollController, + widget.route, + FeedbackFormController(widget.screenshotController, scrollController), ), ); }, diff --git a/feedback/lib/src/feedback_builder/string_feedback.dart b/feedback/lib/src/feedback_builder/string_feedback.dart index 78c9ea2b..a188723f 100644 --- a/feedback/lib/src/feedback_builder/string_feedback.dart +++ b/feedback/lib/src/feedback_builder/string_feedback.dart @@ -1,15 +1,18 @@ -import 'package:feedback/src/better_feedback.dart'; -import 'package:feedback/src/l18n/translation.dart'; +import 'package:feedback/feedback.dart'; import 'package:feedback/src/theme/feedback_theme.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Prompt the user for feedback using `StringFeedback`. Widget simpleFeedbackBuilder( BuildContext context, - OnSubmit onSubmit, - ScrollController? scrollController, + OnSubmit? onSubmit, + FeedbackFormController formController, ) => - StringFeedback(onSubmit: onSubmit, scrollController: scrollController); + StringFeedback( + onSubmit: onSubmit, + formController: formController, + ); /// A form that prompts the user for feedback with a single text field. /// This is the default feedback widget used by [BetterFeedback]. @@ -19,18 +22,11 @@ class StringFeedback extends StatefulWidget { const StringFeedback({ super.key, required this.onSubmit, - required this.scrollController, + required this.formController, }); - /// Should be called when the user taps the submit button. - final OnSubmit onSubmit; - - /// A scroll controller that expands the sheet when it's attached to a - /// scrollable widget and that widget is scrolled. - /// - /// Non null if the sheet is draggable. - /// See: [FeedbackThemeData.sheetIsDraggable]. - final ScrollController? scrollController; + final OnSubmit? onSubmit; + final FeedbackFormController formController; @override State createState() => _StringFeedbackState(); @@ -51,6 +47,10 @@ class _StringFeedbackState extends State { controller = TextEditingController(); } + Future? submitting; + + FeedbackFormController get formController => widget.formController; + @override Widget build(BuildContext context) { return Column( @@ -59,16 +59,14 @@ class _StringFeedbackState extends State { child: Stack( children: [ ListView( - controller: widget.scrollController, + controller: formController.scrollController, // Pad the top by 20 to match the corner radius if drag enabled. - padding: EdgeInsets.fromLTRB( - 16, widget.scrollController != null ? 20 : 16, 16, 0), + padding: EdgeInsets.fromLTRB(16, formController.scrollController != null ? 20 : 16, 16, 0), children: [ Text( FeedbackLocalizations.of(context).feedbackDescriptionText, maxLines: 2, - style: - FeedbackTheme.of(context).bottomSheetDescriptionStyle, + style: FeedbackTheme.of(context).bottomSheetDescriptionStyle, ), TextField( style: FeedbackTheme.of(context).bottomSheetTextInputStyle, @@ -83,23 +81,72 @@ class _StringFeedbackState extends State { ), ], ), - if (widget.scrollController != null) - const FeedbackSheetDragHandle(), + if (formController.scrollController != null) const FeedbackSheetDragHandle(), ], ), ), - TextButton( - key: const Key('submit_feedback_button'), - child: Text( - FeedbackLocalizations.of(context).submitButtonText, - style: TextStyle( - color: FeedbackTheme.of(context).activeFeedbackModeColor, - ), - ), - onPressed: () => widget.onSubmit(controller.text), + _FeedbackSubmitButton( + onTap: () async { + final Uint8List screenshot = await formController.takeScreenshot(context); + if (!context.mounted) return; + + final feedback = UserFeedback(text: controller.text, screenshot: screenshot); + await widget.onSubmit?.call(context, feedback); + if (!context.mounted) return; + + BetterFeedback.simpleFeedbackOf(context).hide(feedback); + }, ), const SizedBox(height: 8), ], ); } } + +class _FeedbackSubmitButton extends StatefulWidget { + const _FeedbackSubmitButton({required this.onTap}); + + final AsyncCallback onTap; + + @override + State<_FeedbackSubmitButton> createState() => _FeedbackSubmitButtonState(); +} + +class _FeedbackSubmitButtonState extends State<_FeedbackSubmitButton> { + Future? submitting; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: submitting, + builder: (context, snap) { + return TextButton( + key: const Key('submit_feedback_button'), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 50), + child: snap.connectionState == ConnectionState.waiting + ? Container( + padding: const EdgeInsets.all(2), + alignment: Alignment.center, + child: CircularProgressIndicator( + color: FeedbackTheme.of(context).activeFeedbackModeColor, + ), + ) + : Text( + FeedbackLocalizations.of(context).submitButtonText, + style: TextStyle( + color: FeedbackTheme.of(context).activeFeedbackModeColor, + ), + ), + ), + onPressed: () async { + setState(() { + submitting = widget.onTap(); + }); + await submitting; + }, + ); + }, + ); + } +} diff --git a/feedback/lib/src/feedback_controller.dart b/feedback/lib/src/feedback_controller.dart index f8f68f9b..eaa2f1b5 100644 --- a/feedback/lib/src/feedback_controller.dart +++ b/feedback/lib/src/feedback_controller.dart @@ -1,30 +1,33 @@ +import 'dart:async'; + import 'package:feedback/feedback.dart'; import 'package:flutter/material.dart'; /// Controls the state of the feeback ui. -class FeedbackController extends ChangeNotifier { - bool _isVisible = false; +class FeedbackController extends ChangeNotifier { + _FeedbackSession? _session; - /// Whether the feedback ui is currently visible. - bool get isVisible => _isVisible; + /// The current route, if any. + T? get currentRoute => _session?.route; - /// This function is called when the user submits his feedback. - OnFeedbackCallback? onFeedback; + /// Whether the feedback ui is currently visible. + bool get isVisible => _session != null; /// Open the feedback ui. /// After the user submitted his feedback [onFeedback] is called. /// If the user aborts the process of giving feedback, [onFeedback] is /// not called. - void show(OnFeedbackCallback onFeedback) { - _isVisible = true; - this.onFeedback = onFeedback; + Future show(T route) async { + _session = _FeedbackSession(route); notifyListeners(); + return _session!.result.future; } /// Hides the feedback ui. - /// Typically, this does not need to be called by the user of this library - void hide() { - _isVisible = false; + void hide([R? result]) { + if (_session == null) return; + _session!.result.complete(result); + _session = null; notifyListeners(); } @@ -35,3 +38,11 @@ class FeedbackController extends ChangeNotifier { final DraggableScrollableController sheetController = DraggableScrollableController(); } + + +class _FeedbackSession { + _FeedbackSession(this.route); + + final T route; + final Completer result = Completer(); +} \ No newline at end of file diff --git a/feedback/lib/src/feedback_data.dart b/feedback/lib/src/feedback_data.dart index fb11ee68..dd2372d3 100644 --- a/feedback/lib/src/feedback_data.dart +++ b/feedback/lib/src/feedback_data.dart @@ -3,17 +3,17 @@ import 'package:feedback/src/feedback_controller.dart'; import 'package:flutter/material.dart'; -class FeedbackData extends InheritedWidget { +class FeedbackData extends InheritedWidget { const FeedbackData({ super.key, required super.child, required this.controller, }); - final FeedbackController controller; + final FeedbackController controller; @override - bool updateShouldNotify(FeedbackData oldWidget) { + bool updateShouldNotify(FeedbackData oldWidget) { return oldWidget.controller != controller; } } diff --git a/feedback/lib/src/feedback_form_controller.dart b/feedback/lib/src/feedback_form_controller.dart new file mode 100644 index 00000000..215e7994 --- /dev/null +++ b/feedback/lib/src/feedback_form_controller.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +import 'package:feedback/src/screenshot.dart'; +import 'package:flutter/material.dart'; + +class FeedbackFormController { + const FeedbackFormController(this._screenshotController, [this.scrollController]); + + final ScreenshotController _screenshotController; + + final ScrollController? scrollController; + + Future takeScreenshot(BuildContext context, { + bool showKeyboard = false, + double pixelRatio = 3.0, + Duration delay = const Duration(milliseconds: 2000), + }) async { + if (!showKeyboard) { + FocusScope.of(context).requestFocus(FocusNode()); + } + await Future.delayed(delay); + return _screenshotController.capture( + pixelRatio: pixelRatio, + delay: Duration.zero, + ); + } +} diff --git a/feedback/lib/src/feedback_widget.dart b/feedback/lib/src/feedback_widget.dart index f22acedb..44c2b179 100644 --- a/feedback/lib/src/feedback_widget.dart +++ b/feedback/lib/src/feedback_widget.dart @@ -18,14 +18,14 @@ typedef FeedbackButtonPress = void Function(BuildContext context); const kScaleOrigin = Alignment(-.3, -.65); const kScaleFactor = .65; -class FeedbackWidget extends StatefulWidget { +class FeedbackWidget extends StatefulWidget { const FeedbackWidget({ super.key, + required this.route, + required this.isVisible, required this.child, - required this.isFeedbackVisible, required this.drawColors, required this.mode, - required this.pixelRatio, required this.feedbackBuilder, }) : assert( // This way, we can have a const constructor @@ -34,20 +34,22 @@ class FeedbackWidget extends StatefulWidget { 'There must be at least one color to draw', ); - final bool isFeedbackVisible; + // Note that we need both `isVisible` and `route` as route may be + // null even if the feedback is visible as route is nullable. + final T? route; + final bool isVisible; final FeedbackMode mode; - final double pixelRatio; final Widget child; final List drawColors; - final FeedbackBuilder feedbackBuilder; + final FeedbackBuilder feedbackBuilder; @override - FeedbackWidgetState createState() => FeedbackWidgetState(); + FeedbackWidgetState createState() => FeedbackWidgetState(); } @visibleForTesting -class FeedbackWidgetState extends State +class FeedbackWidgetState extends State> with SingleTickerProviderStateMixin { // Padding to put around the interactive screenshot preview. final double padding = 8; @@ -65,12 +67,16 @@ class FeedbackWidgetState extends State ScreenshotController screenshotController = ScreenshotController(); TextEditingController textEditingController = TextEditingController(); + late final FeedbackController feedbackController = BetterFeedback.of(context); + late FeedbackMode mode = widget.mode; late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); + T? lastSeenRoute; + PainterController create() { final controller = PainterController(); controller.thickness = 5.0; @@ -94,11 +100,11 @@ class FeedbackWidgetState extends State @visibleForTesting bool backButtonIntercept() { - if (mode == FeedbackMode.draw && widget.isFeedbackVisible) { + if (mode == FeedbackMode.draw && feedbackController.isVisible) { if (painterController.getStepCount() > 0) { painterController.undo(); } else { - BetterFeedback.of(context).hide(); + BetterFeedback.of(context).hide(); } return true; } @@ -106,21 +112,24 @@ class FeedbackWidgetState extends State } @override - void didUpdateWidget(FeedbackWidget oldWidget) { + void didUpdateWidget(FeedbackWidget oldWidget) { super.didUpdateWidget(oldWidget); // update feedback mode with the initial value mode = widget.mode; - if (oldWidget.isFeedbackVisible != widget.isFeedbackVisible && - oldWidget.isFeedbackVisible == false) { + if (oldWidget.isVisible != widget.isVisible && + oldWidget.isVisible == false) { // Feedback is now visible, - // start animation to show it. + // start animation to show it and update the route. + lastSeenRoute = widget.route; _controller.forward(); } - if (oldWidget.isFeedbackVisible != widget.isFeedbackVisible && - oldWidget.isFeedbackVisible == true) { + if (oldWidget.isVisible != widget.isVisible && + oldWidget.isVisible == true) { // Feedback is no longer visible, // reverse animation to hide it. + // Note that we do not clear the last seen route as the bottom sheet will + // still need to reference it as it animates out. _controller.reverse(); // Reset the sheet progress so the fade is no longer applied. sheetProgress.value = 0; @@ -157,7 +166,7 @@ class FeedbackWidgetState extends State child: PaintOnChild( controller: painterController, isPaintingActive: - mode == FeedbackMode.draw && widget.isFeedbackVisible, + mode == FeedbackMode.draw && feedbackController.isVisible, child: widget.child, ), ), @@ -242,7 +251,7 @@ class FeedbackWidgetState extends State }, onCloseFeedback: () { _hideKeyboard(context); - BetterFeedback.of(context).hide(); + BetterFeedback.of(context).hide(); }, ), ), @@ -260,23 +269,13 @@ class FeedbackWidgetState extends State notification.minExtent); return false; }, - child: FeedbackBottomSheet( + child: FeedbackBottomSheet( key: const Key('feedback_bottom_sheet'), + // We need to forcibly cast to T to handle that current route is + // nullable and T itself may or may not be nullable + route: BetterFeedback.of(context).currentRoute as T, + screenshotController: screenshotController, feedbackBuilder: widget.feedbackBuilder, - onSubmit: ( - String feedback, { - Map? extras, - }) async { - await _sendFeedback( - context, - BetterFeedback.of(context).onFeedback!, - screenshotController, - feedback, - widget.pixelRatio, - extras: extras, - ); - painterController.clear(); - }, sheetProgress: sheetProgress, ), ), @@ -292,66 +291,6 @@ class FeedbackWidgetState extends State ); } - @visibleForTesting - static Future sendFeedback( - OnFeedbackCallback onFeedbackSubmitted, - ScreenshotController controller, - String feedback, - double pixelRatio, { - Duration delay = const Duration(milliseconds: 2000), - Map? extras, - }) async { - // Wait for the keyboard to be closed, and then proceed - // to take a screenshot - await Future.delayed( - delay, - () async { - // Take high resolution screenshot - final screenshot = await controller.capture( - pixelRatio: pixelRatio, - delay: const Duration(milliseconds: 0), - ); - - // Give it to the developer - // to do something with it. - await onFeedbackSubmitted( - UserFeedback( - text: feedback, - screenshot: screenshot, - extra: extras, - ), - ); - }, - ); - } - - static Future _sendFeedback( - BuildContext context, - OnFeedbackCallback onFeedbackSubmitted, - ScreenshotController controller, - String feedback, - double pixelRatio, { - Duration delay = const Duration(milliseconds: 200), - bool showKeyboard = false, - Map? extras, - }) async { - if (!showKeyboard) { - _hideKeyboard(context); - } - await sendFeedback( - onFeedbackSubmitted, - controller, - feedback, - pixelRatio, - delay: delay, - extras: extras, - ); - - // Close feedback mode - // ignore: use_build_context_synchronously - BetterFeedback.of(context).hide(); - } - static void _hideKeyboard(BuildContext context) { FocusScope.of(context).requestFocus(FocusNode()); } diff --git a/feedback/pubspec.yaml b/feedback/pubspec.yaml index 83d13d8f..63fc9ab9 100644 --- a/feedback/pubspec.yaml +++ b/feedback/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + fpdart: ^1.1.1 dev_dependencies: flutter_lints: ^3.0.0 diff --git a/feedback/test/feedback_test.dart b/feedback/test/feedback_test.dart index d55e950a..68508f5a 100644 --- a/feedback/test/feedback_test.dart +++ b/feedback/test/feedback_test.dart @@ -12,7 +12,7 @@ import 'test_app.dart'; void main() { group('BetterFeedback', () { testWidgets('can open feedback with default settings', (tester) async { - final widget = BetterFeedback( + final widget = BetterFeedback.simpleFeedback( child: Builder( builder: (context) { return const MyTestApp(); @@ -41,7 +41,7 @@ void main() { }); testWidgets('can open feedback in drawing mode', (tester) async { - final widget = BetterFeedback( + final widget = BetterFeedback.simpleFeedback( mode: FeedbackMode.draw, child: Builder( builder: (context) { @@ -71,7 +71,7 @@ void main() { }); testWidgets('can open feedback in navigation mode', (tester) async { - final widget = BetterFeedback( + final widget = BetterFeedback.simpleFeedback( mode: FeedbackMode.navigate, child: Builder( builder: (context) {