diff --git a/feedback/example/android/app/build.gradle b/feedback/example/android/app/build.gradle index fdc675a7..80026f6e 100644 --- a/feedback/example/android/app/build.gradle +++ b/feedback/example/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,10 +12,10 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} +//def flutterRoot = localProperties.getProperty('flutter.sdk') +//if (flutterRoot == null) { +// throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +//} def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { @@ -21,12 +27,12 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" +//apply plugin: 'com.android.application' +//apply plugin: 'kotlin-android' +//apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 33 + compileSdkVersion 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -37,9 +43,11 @@ android { } defaultConfig { + namespace "com.example.example" applicationId "com.example.example" - minSdkVersion 19 + minSdkVersion flutter.minSdkVersion targetSdkVersion 33 + multiDexEnabled true versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -57,8 +65,9 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +// implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + implementation 'com.android.support:multidex:2.0.1' } diff --git a/feedback/example/android/build.gradle b/feedback/example/android/build.gradle index bf7d9f68..5869be3d 100644 --- a/feedback/example/android/build.gradle +++ b/feedback/example/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.9.23' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() diff --git a/feedback/example/android/settings.gradle b/feedback/example/android/settings.gradle index 5a2f14fb..259b6ec0 100644 --- a/feedback/example/android/settings.gradle +++ b/feedback/example/android/settings.gradle @@ -1,15 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -def plugins = new Properties() -def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') -if (pluginsFile.exists()) { - pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } } -plugins.each { name, path -> - def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() - include ":$name" - project(":$name").projectDir = pluginDirectory +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.23" apply false } + +include ":app" \ No newline at end of file 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..6391ad31 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 */ @@ -68,7 +68,6 @@ 553502822E43C0A05ECA673C /* Pods-Runner.release.xcconfig */, A4931B4FF5CA58F3689F1E8E /* Pods-Runner.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -164,7 +163,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -214,14 +213,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 +231,12 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -246,6 +247,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -355,7 +357,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; @@ -371,6 +373,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 94V52BVTWJ; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -385,7 +388,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_BUNDLE_IDENTIFIER = com.bashieralsaed.ladders; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -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; @@ -506,6 +509,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 94V52BVTWJ; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -520,7 +524,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_BUNDLE_IDENTIFIER = com.bashieralsaed.ladders; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -536,6 +540,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 94V52BVTWJ; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -550,7 +555,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_BUNDLE_IDENTIFIER = com.bashieralsaed.ladders; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; 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 + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -22,6 +24,8 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,7 +45,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - diff --git a/feedback/lib/src/color_picker/alpha_slider.dart b/feedback/lib/src/color_picker/alpha_slider.dart new file mode 100644 index 00000000..b07e611c --- /dev/null +++ b/feedback/lib/src/color_picker/alpha_slider.dart @@ -0,0 +1,81 @@ +part of 'color_picker_icon.dart'; + +class _AlphaSlider extends StatefulWidget { + const _AlphaSlider({ + required this.color, + required this.whiteToColorValue, + required this.outsideSetter, + }); + + final Color color; + final double whiteToColorValue; + final ValueSetter outsideSetter; + + @override + State<_AlphaSlider> createState() => _AlphaSliderState(); +} + +class _AlphaSliderState extends State<_AlphaSlider> { + + Color get color => Color.lerp(_startColor, widget.color, widget.whiteToColorValue)!; + late var _sliderValue = _cachedAlphaSlider; + + @override + Widget build(BuildContext context) => SizedBox( + height: _thumbRadius, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + height: _thumbRadius / 2, + margin: EdgeInsets.symmetric(horizontal: _thumbRadius), + decoration: ShapeDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + color.withAlpha(_minAlpha), + color.withAlpha(_maxAlpha), + ], + ), + shape: StadiumBorder(), + ), + ), + Material( + color: Colors.transparent, + child: SliderTheme( + data: SliderThemeData( + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: _thumbRadius * 0.65, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: _thumbRadius, + ), + ), + child: Slider( + activeColor: Colors.transparent, + min: _minAlpha.toDouble(), + max: _maxAlpha.toDouble(), + inactiveColor: Colors.transparent, + thumbColor: color.withAlpha( + // We limit only the look of the thumb to the middle of [_maxAlpha] + // however the value in [_sliderValue] is still reflected properly + max(_maxAlpha / 2, _sliderValue).round(), + ), + value: _sliderValue, + onChanged: onChanged, + ), + ), + ), + ], + ), + ); + + void onChanged(double value) { + setState(() { + _sliderValue = value; + _cachedAlphaSlider = _sliderValue; + }); + widget.outsideSetter(color.withAlpha(_sliderValue.round())); + } +} diff --git a/feedback/lib/src/color_picker/color_picker.dart b/feedback/lib/src/color_picker/color_picker.dart new file mode 100644 index 00000000..82d5e7bc --- /dev/null +++ b/feedback/lib/src/color_picker/color_picker.dart @@ -0,0 +1,54 @@ +part of 'color_picker_icon.dart'; + +class _ColorPicker extends StatefulWidget { + const _ColorPicker({ + required this.onColorChanged, + required this.activeColor, + }); + + final ValueChanged onColorChanged; + final Color activeColor; + + @override + State<_ColorPicker> createState() => _ColorPickerState(); +} + +class _ColorPickerState extends State<_ColorPicker> { + late Color _hueSlideColor = widget.activeColor; + double _whiteToColorValue = _cachedWhiteToColorValue; + + void onHueColorChanged(Color color) => setState( + () => _hueSlideColor = color, + ); + + void onWhiteToColorChanged(double value) => setState( + () => _whiteToColorValue = value, + ); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: _WhiteToColorBox( + color: _hueSlideColor, + innerSetter: onWhiteToColorChanged, + outsideSetter: widget.onColorChanged, + ), + ), + SizedBox(height: _thumbRadius), + _AlphaSlider( + color: _hueSlideColor, + whiteToColorValue: _whiteToColorValue, + outsideSetter: widget.onColorChanged, + ), + SizedBox(height: _thumbRadius), + /// This is not affected by the alpha slider nor the white to color + _HueSlider( + activeColor: widget.activeColor, + insideSetter: onHueColorChanged, + outSideSetter: widget.onColorChanged, + ), + ], + ); +} diff --git a/feedback/lib/src/color_picker/color_picker_icon.dart b/feedback/lib/src/color_picker/color_picker_icon.dart new file mode 100644 index 00000000..1bdabb34 --- /dev/null +++ b/feedback/lib/src/color_picker/color_picker_icon.dart @@ -0,0 +1,129 @@ +import 'dart:math'; + +import 'package:feedback/src/utilities/back_button_interceptor.dart'; +import 'package:flutter/material.dart'; + +part 'alpha_slider.dart'; +part 'color_picker.dart'; +part 'constants.dart'; +part 'hue_slider.dart'; +part 'overlay_stack.dart'; +part 'ring_clipper.dart'; +part 'white_to_color_box.dart'; + +/// +class ColorPickerIcon extends StatelessWidget { + /// + const ColorPickerIcon({ + super.key, + required this.activeColor, + required this.onColorChanged, + }); + + /// + final ValueChanged? onColorChanged; + + /// + final Color activeColor; + + List get _brightColors => [ + Colors.blue, + Colors.red, + Colors.yellow, + Colors.green, + ]; + + static const _iconSize = 30.0; + static const _outerRingSize = _iconSize / 7; + static const _innerRingSize = _outerRingSize; + + // This last ` - 1 ` is for easier visual separation between alpha and hue representation + // especially when color is lighter while at max alpha due to [_WhiteToColorBox] + static const _innerColorSize = _iconSize - (_outerRingSize * 2) - (_innerRingSize * 2) - 1; + + @override + Widget build(BuildContext context) => IconButton( + onPressed: onColorChanged == null ? null : () => _showColorPicker(context), + icon: Stack( + alignment: Alignment.center, + children: [ + ClipPath( + clipper: _RingClipper(width: _outerRingSize), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.decelerate, + width: _iconSize, + height: _iconSize, + decoration: BoxDecoration( + gradient: SweepGradient( + colors: onColorChanged == null + ? _brightColors.map((c) => c.withAlpha(50)).toList() + : _brightColors, + ), + shape: BoxShape.circle, + ), + ), + ), + + /// That ring is the full representation of active color and its alpha + ClipPath( + clipper: _RingClipper(width: _innerRingSize), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.decelerate, + width: _iconSize - _outerRingSize * 2, + height: _iconSize - _outerRingSize * 2, + decoration: BoxDecoration( + /// Found it better in indicating lower + /// alpha values when adding `withAlpha(170)` + color: Colors.white.withAlpha(170), + shape: BoxShape.circle, + ), + foregroundDecoration: BoxDecoration( + color: _borderColor, + shape: BoxShape.circle, + ), + ), + ), + + /// That is the color representation without alpha + Container( + width: _innerColorSize, + height: _innerColorSize, + decoration: BoxDecoration( + color: _topColor, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + + /// Solid representation of active color without alpha + Color get _topColor => + onColorChanged == null ? activeColor.withAlpha(50) : activeColor.withAlpha(_maxAlpha); + + /// Full representation of active color including alpha + Color get _borderColor => onColorChanged == null ? activeColor.withAlpha(50) : activeColor; + + static OverlayEntry? _entry; + + void _showColorPicker(BuildContext context) { + final overlayState = Overlay.of(context); + + _entry = OverlayEntry( + builder: (context) => _OverlayStack( + closeCallback: _close, + activeColor: activeColor, + onColorChanged: onColorChanged!, + ), + ); + + overlayState.insert(_entry!); + } + + bool _close() { + _entry?.remove(); + return true; + } +} diff --git a/feedback/lib/src/color_picker/constants.dart b/feedback/lib/src/color_picker/constants.dart new file mode 100644 index 00000000..366002f5 --- /dev/null +++ b/feedback/lib/src/color_picker/constants.dart @@ -0,0 +1,19 @@ +part of 'color_picker_icon.dart'; + +final _colorsList = List.from( + Colors.primaries.take(Colors.primaries.length - 2), +).followedBy( + [Colors.brown.shade500, Colors.black], +).toList(); +final _colorShare = 1 / (_colorsList.length - 1); + +const _thumbRadius = 24.0; +double _cachedHueSlider = 1.0; + +double _cachedAlphaSlider = _maxAlpha.toDouble(); +const _minAlpha = 50; +const _maxAlpha = 255; + +final GlobalKey _whiteToColorKey = GlobalKey(); +const Color _startColor = Colors.white; +double _cachedWhiteToColorValue = 1.0; diff --git a/feedback/lib/src/color_picker/hue_slider.dart b/feedback/lib/src/color_picker/hue_slider.dart new file mode 100644 index 00000000..93019f2a --- /dev/null +++ b/feedback/lib/src/color_picker/hue_slider.dart @@ -0,0 +1,132 @@ +part of 'color_picker_icon.dart'; + +class _HueSlider extends StatefulWidget { + const _HueSlider({ + required this.activeColor, + required this.insideSetter, + required this.outSideSetter, + }); + + final Color activeColor; + final ValueSetter insideSetter; + final ValueSetter outSideSetter; + + @override + State<_HueSlider> createState() => _HueSliderState(); +} + +class _HueSliderState extends State<_HueSlider> { + @override + void initState() { + super.initState(); + Future(() { + _presetToActiveColorIfExists(); + onChanged(_cachedHueSlider); + }); + } + + void _presetToActiveColorIfExists() { + /// Presetting [_cachedHueSlider] to move the slider to match the provided + /// [activeColor] if it is present in [_colorsList] + Color? matchedColor; + _colorsList.any( + (element) { + if (widget.activeColor == element) { + matchedColor = element; + return true; + } + return false; + }, + ); + if (matchedColor != null) { + final index = _colorsList.indexOf(matchedColor!); + _cachedHueSlider = index * _colorShare; + } + } + + Color _hueSlideColor = Colors.transparent; + double _slideValue = _cachedHueSlider; + + @override + Widget build(BuildContext context) => SizedBox( + height: _thumbRadius, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + height: _thumbRadius / 2, + margin: EdgeInsets.symmetric(horizontal: _thumbRadius), + decoration: ShapeDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: _colorsList, + ), + shape: StadiumBorder(), + ), + ), + Material( + color: Colors.transparent, + child: SliderTheme( + data: SliderThemeData( + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: _thumbRadius * 0.65, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: _thumbRadius, + ), + ), + child: Slider( + activeColor: Colors.transparent, + inactiveColor: Colors.transparent, + thumbColor: _hueSlideColor, + value: _slideValue, + onChanged: onChanged, + ), + ), + ), + ], + ), + ); + + void onChanged(double value) { + final hueColor = _getColorFromSliderValue(value); + + setState(() { + _hueSlideColor = hueColor; + _slideValue = value; + _cachedHueSlider = _slideValue; + }); + widget.insideSetter(_hueSlideColor); + + /// Note that [_getColorFromSliderValue] returns a color without taking alpha + /// nor white to color value into account so we have to adjust it before setting + /// the outside [activeColor] + final colorWithWhiteToColor = Color.lerp( + _startColor, + _hueSlideColor, + _cachedWhiteToColorValue, + )!; + final allSetColor = colorWithWhiteToColor.withAlpha(_cachedAlphaSlider.round()); + + widget.outSideSetter(allSetColor); + } + + Color _getColorFromSliderValue(double value) { + final firstIndex = value ~/ _colorShare; + final secondIndex = min(firstIndex + 1, _colorsList.length - 1); + final (first, second) = ( + _colorsList[firstIndex], + _colorsList[secondIndex], + ); + + final interpolation = value / _colorShare - min(firstIndex, secondIndex); + + final hueColor = Color.lerp( + first, + second, + interpolation, + ); + return hueColor!; + } +} diff --git a/feedback/lib/src/color_picker/overlay_stack.dart b/feedback/lib/src/color_picker/overlay_stack.dart new file mode 100644 index 00000000..763c849b --- /dev/null +++ b/feedback/lib/src/color_picker/overlay_stack.dart @@ -0,0 +1,81 @@ +part of 'color_picker_icon.dart'; + +class _OverlayStack extends StatefulWidget { + const _OverlayStack({ + required this.closeCallback, + required this.activeColor, + required this.onColorChanged, + }); + + final BoolCallback closeCallback; + final Color activeColor; + final ValueChanged onColorChanged; + + @override + State<_OverlayStack> createState() => _OverlayStackState(); +} + +class _OverlayStackState extends State<_OverlayStack> { + static const Duration _animationDuration = Duration(milliseconds: 320); + + @override + void initState() { + super.initState(); + BackButtonInterceptor.add(widget.closeCallback, priority: 1); + Future(() => setState(() => opacity = 1.0)); + } + + double opacity = 0.0; + + void onTapOutside() { + setState(() => opacity = 0.0); + Future.delayed(_animationDuration).then( + (_) => widget.closeCallback(), + ); + } + + @override + void dispose() { + BackButtonInterceptor.remove(widget.closeCallback); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: GestureDetector( + onTap: onTapOutside, + ), + ), + AnimatedOpacity( + duration: _animationDuration, + opacity: opacity, + curve: Curves.decelerate, + child: Container( + constraints: BoxConstraints( + maxWidth: min( + 450, + MediaQuery.sizeOf(context).width, + ), + maxHeight: min( + 450, + MediaQuery.sizeOf(context).height * 0.85, + ), + ), + margin: EdgeInsets.symmetric(horizontal: 20, vertical: 40), + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceDim, + borderRadius: BorderRadius.circular(8), + ), + child: _ColorPicker( + activeColor: widget.activeColor, + onColorChanged: widget.onColorChanged, + ), + ), + ), + ], + ); +} diff --git a/feedback/lib/src/color_picker/ring_clipper.dart b/feedback/lib/src/color_picker/ring_clipper.dart new file mode 100644 index 00000000..6641b770 --- /dev/null +++ b/feedback/lib/src/color_picker/ring_clipper.dart @@ -0,0 +1,31 @@ +part of 'color_picker_icon.dart'; + +class _RingClipper extends CustomClipper { + final double width; + + const _RingClipper({required this.width}); + + @override + Path getClip(Size size) { + final outerRadius = size.width / 2; + assert(size.width == size.height, 'Width is not equal to height'); + + final path = Path()..fillType = PathFillType.evenOdd; + path.addOval( + Rect.fromCircle( + center: Offset(outerRadius, outerRadius), + radius: outerRadius, + ), + ); + path.addOval( + Rect.fromCircle( + center: Offset(outerRadius, outerRadius), + radius: outerRadius - width, + ), + ); + return path; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => true; +} diff --git a/feedback/lib/src/color_picker/white_to_color_box.dart b/feedback/lib/src/color_picker/white_to_color_box.dart new file mode 100644 index 00000000..9a937cee --- /dev/null +++ b/feedback/lib/src/color_picker/white_to_color_box.dart @@ -0,0 +1,285 @@ +part of 'color_picker_icon.dart'; + +class _WhiteToColorBox extends StatefulWidget { + const _WhiteToColorBox({ + required this.color, + required this.outsideSetter, + required this.innerSetter, + }); + + final Color color; + final ValueSetter outsideSetter; + final ValueSetter innerSetter; + + @override + State<_WhiteToColorBox> createState() => _WhiteToColorBoxState(); +} + +class _WhiteToColorBoxState extends State<_WhiteToColorBox> { + static const double _padding = _thumbRadius * 0.65; + static const AlignmentGeometry _startAlignment = Alignment.topLeft; + static const AlignmentGeometry _endAlignment = Alignment.bottomRight; + + Color get color => widget.color.withAlpha(_maxAlpha); + + @override + void initState() { + super.initState(); + Future( + () => setState( + () => offset = _calculateInitialOffset(), + ), + ); + } + + @override + didUpdateWidget(covariant _WhiteToColorBox oldWidget) { + super.didUpdateWidget(oldWidget); + setState( + () => interpolatedColor = Color.lerp( + _startColor, + color, + _cachedWhiteToColorValue, + )!, + ); + } + + late Offset offset = Offset.zero; + late Color interpolatedColor = Color.lerp( + _startColor, + color, + _cachedWhiteToColorValue, + )!; + + @override + Widget build(BuildContext context) => Stack( + key: _whiteToColorKey, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: EdgeInsets.all(_padding), + // decoration: BoxDecoration( + // image: DecorationImage( + // image: AssetImage('assets/checkerboard.png'), + // fit: BoxFit.fill, + // ), + // ), + foregroundDecoration: ShapeDecoration( + /// If This [LinearGradient] changes, It will need another algorithm to calculate + /// the interpolated color according to the new gradient type. + gradient: LinearGradient( + /// These may change freely even with [AlignmentDirectional] values + begin: _startAlignment, + end: _endAlignment, + colors: [ + _startColor, + color, + ], + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(width: 1), + ), + ), + ), + Positioned.fill( + child: GestureDetector( + onTapDown: (details) => _onPointerUpdate(details.globalPosition), + onPanUpdate: (details) => _onPointerUpdate(details.globalPosition), + ), + ), + Positioned( + top: offset.dy, + left: offset.dx, + child: GestureDetector( + onPanUpdate: (details) => _onPointerUpdate(details.globalPosition), + child: Container( + width: _padding * 2, + height: _padding * 2, + decoration: ShapeDecoration( + shape: CircleBorder( + side: BorderSide(width: 1), + ), + color: interpolatedColor, + ), + ), + ), + ) + ], + ); + + Offset _calculateInitialOffset() { + final renderBox = _whiteToColorKey.currentContext!.findRenderObject() as RenderBox; + + /// Note we have a margin of [padding] all around the box. + final xUpperLimit = renderBox.size.width - _padding * 2; + final yUpperLimit = renderBox.size.height - _padding * 2; + + final bottomRight = Offset(xUpperLimit, yUpperLimit); + + final (start, end) = _startEnd( + bottomRight: bottomRight, + direction: Directionality.of(context), + ); + + /// m = (h - 0) / ( w - 0 ) + /// c = y - mx + final m = (end.dy - start.dy) / (end.dx - start.dx); + final c = start.dy - m * start.dx; + + final gradientLineLength = calculateDistance(start, end); + final distanceOfInterpolation = gradientLineLength * _cachedWhiteToColorValue; + + final otherPoint = calculateOtherPoint( + start, + distanceOfInterpolation, + m, + c, + ); + + return otherPoint; + } + + double calculateDistance(Offset start, Offset end) => sqrt( + pow(end.dx - start.dx, 2) + pow(end.dy - start.dy, 2), + ); + + Offset calculateOtherPoint( + Offset point, + double distance, + double lineSlope, + double coefficient, + ) { + /// There values are the coefficients of the quadratic equation + /// obtained from solving the given line equation and the distance equation. + /// y1 = m * x1 + c; + /// d = sqrt( (x1 - x0)^2 + (y1 - y0)^2 ) + final a = 1 + lineSlope * lineSlope; + final b = 2 * lineSlope * coefficient - 2 * point.dx - point.dy * lineSlope; + final c = pow(point.dx, 2) - + point.dy * coefficient + + pow(point.dy, 2) + + pow(coefficient, 2) - + pow(distance, 2); + + double x; + x = (-b + sqrt(b * b - 4 * a * c)) / (2 * a); + if (x.isNegative) x = (-b - sqrt(b * b - 4 * a * c)) / (2 * a); + + final y = lineSlope * x + coefficient; + return Offset(x, y); + } + + void _onPointerUpdate(Offset global) { + final renderBox = _whiteToColorKey.currentContext!.findRenderObject() as RenderBox; + + /// Note we have a margin of [padding] all around the box. + final boxTopLeftGlobalOffset = renderBox.localToGlobal(Offset(_padding, _padding)); + final xUpperLimit = renderBox.size.width - _padding * 2; + final yUpperLimit = renderBox.size.height - _padding * 2; + + final local = global - boxTopLeftGlobalOffset; + final tapPosition = Offset( + local.dx.clamp(0, xUpperLimit), + local.dy.clamp(0, yUpperLimit), + ); + + final bottomRight = Offset(xUpperLimit, yUpperLimit); + final newColor = _interpolateTapPosition( + bottomRight, + tapPosition, + ); + setState( + () { + offset = tapPosition; + interpolatedColor = newColor; + }, + ); + widget.innerSetter(_cachedWhiteToColorValue); + final colorWithAlpha = interpolatedColor.withAlpha(_cachedAlphaSlider.round()); + widget.outsideSetter(colorWithAlpha); + } + + Color _interpolateTapPosition(Offset bottomRight, Offset tapPosition) { + final x0 = tapPosition.dx; + final y0 = tapPosition.dy; + + final (start, end) = _startEnd( + bottomRight: bottomRight, + direction: Directionality.of(context), + ); + + /// m = (h - 0) / ( w - 0 ) + /// c = y - mx + final m = (end.dy - start.dy) / (end.dx - start.dx); + final c = start.dy - m * start.dx; + + /// x1 = ( x0 + m y0 - m c ) / ( m^2 + 1 ) + /// y1 = m (x1) + c + final x1 = (x0 + m * y0 - m * c) / (m * m + 1); + final y1 = m * x1 + c; + + /// Performing Interpolation + final distance = calculateDistance(start, Offset(x1, y1)); + final gradientLineLength = calculateDistance(start, end); + final interpolationValue = distance / gradientLineLength; + + /// Caching interpolation value + _cachedWhiteToColorValue = interpolationValue; + + return Color.lerp( + _startColor, + color, + interpolationValue, + )!; + } + + (Offset start, Offset end) _startEnd({ + required Offset bottomRight, + required TextDirection direction, + }) { + final startAlignment = _startAlignment.resolve(direction); + final endAlignment = _endAlignment.resolve(direction); + + final double startXOffset; + final double startYOffset; + if (startAlignment.x < 0) { + startXOffset = 0; + } else if (startAlignment.x == 0) { + startXOffset = bottomRight.dx / 2; + } else { + startXOffset = bottomRight.dx; + } + + if (startAlignment.y < 0) { + startYOffset = 0; + } else if (startAlignment.y == 0) { + startYOffset = bottomRight.dy / 2; + } else { + startYOffset = bottomRight.dy; + } + + final double endXOffset; + final double endYOffset; + if (endAlignment.x < 0) { + endXOffset = 0; + } else if (endAlignment.x == 0) { + endXOffset = bottomRight.dx / 2; + } else { + endXOffset = bottomRight.dx; + } + + if (endAlignment.y < 0) { + endYOffset = 0; + } else if (endAlignment.y == 0) { + endYOffset = bottomRight.dy / 2; + } else { + endYOffset = bottomRight.dy; + } + + return ( + Offset(startXOffset, startYOffset), + Offset(endXOffset, endYOffset), + ); + } +} diff --git a/feedback/lib/src/controls_column.dart b/feedback/lib/src/controls_column.dart index 27eec0d1..3cf22b21 100644 --- a/feedback/lib/src/controls_column.dart +++ b/feedback/lib/src/controls_column.dart @@ -5,6 +5,9 @@ import 'package:feedback/src/l18n/translation.dart'; import 'package:feedback/src/theme/feedback_theme.dart'; import 'package:flutter/material.dart'; +import 'color_picker/color_picker_icon.dart'; +import 'utilities/custom_color_position.dart'; + /// This is the Widget on the right side of the app when the feedback view /// is active. class ControlsColumn extends StatelessWidget { @@ -19,11 +22,14 @@ class ControlsColumn extends StatelessWidget { required this.onCloseFeedback, required this.onClearDrawing, required this.colors, - }) : assert( + required this.showCustomColor, + required this.customColorPosition, + }) : assert( colors.isNotEmpty, 'There must be at least one color to draw in colors', - ), - assert(colors.contains(activeColor), 'colors must contain activeColor'); + ); + + // assert(colors.contains(activeColor), 'colors must contain activeColor'); final ValueChanged onColorChanged; final VoidCallback onUndo; @@ -31,6 +37,8 @@ class ControlsColumn extends StatelessWidget { final VoidCallback onCloseFeedback; final VoidCallback onClearDrawing; final List colors; + final bool showCustomColor; + final CustomColorPosition customColorPosition; final Color activeColor; final FeedbackMode mode; @@ -91,6 +99,11 @@ class ControlsColumn extends StatelessWidget { icon: const Icon(Icons.delete), onPressed: isNavigatingActive ? null : onClearDrawing, ), + if (customColorPosition.isLeading && showCustomColor) + ColorPickerIcon( + onColorChanged: isNavigatingActive ? null : onColorChanged, + activeColor: activeColor, + ), for (final color in colors) _ColorSelectionIconButton( key: ValueKey(color), @@ -98,6 +111,11 @@ class ControlsColumn extends StatelessWidget { onPressed: isNavigatingActive ? null : onColorChanged, isActive: activeColor == color, ), + if (customColorPosition.isTrailing && showCustomColor) + ColorPickerIcon( + onColorChanged: isNavigatingActive ? null : onColorChanged, + activeColor: activeColor, + ), ], ), ); diff --git a/feedback/lib/src/feedback_widget.dart b/feedback/lib/src/feedback_widget.dart index f22acedb..36c6e3e7 100644 --- a/feedback/lib/src/feedback_widget.dart +++ b/feedback/lib/src/feedback_widget.dart @@ -220,6 +220,8 @@ class FeedbackWidgetState extends State mode: mode, activeColor: painterController.drawColor, colors: widget.drawColors, + showCustomColor: feedbackThemeData.showCustomColor, + customColorPosition: feedbackThemeData.customColorPosition, onColorChanged: (color) { setState(() { painterController.drawColor = color; diff --git a/feedback/lib/src/theme/feedback_theme.dart b/feedback/lib/src/theme/feedback_theme.dart index 5befc0fb..d558e0db 100644 --- a/feedback/lib/src/theme/feedback_theme.dart +++ b/feedback/lib/src/theme/feedback_theme.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../utilities/custom_color_position.dart'; + const _defaultDrawColors = [ Colors.red, Colors.green, @@ -38,6 +40,8 @@ class FeedbackThemeData { this.feedbackSheetHeight = .25, this.activeFeedbackModeColor = _blue, this.drawColors = _defaultDrawColors, + this.showCustomColor = true, + this.customColorPosition = CustomColorPosition.trailing, this.bottomSheetDescriptionStyle = _defaultBottomSheetDescriptionStyle, this.bottomSheetTextInputStyle = _defaultBottomSheetTextInputStyle, this.sheetIsDraggable = true, @@ -108,6 +112,12 @@ class FeedbackThemeData { /// Colors which can be used to draw while in feedback mode. final List drawColors; + /// Determines whether or not to show the custom color picker icon. + final bool showCustomColor; + + /// Determines the position of the custom color picker icon. + final CustomColorPosition customColorPosition; + /// Text Style of the text above of the feedback text input. final TextStyle bottomSheetDescriptionStyle; diff --git a/feedback/lib/src/utilities/back_button_interceptor.dart b/feedback/lib/src/utilities/back_button_interceptor.dart index a661673d..c5bbdd0c 100644 --- a/feedback/lib/src/utilities/back_button_interceptor.dart +++ b/feedback/lib/src/utilities/back_button_interceptor.dart @@ -15,17 +15,18 @@ class BackButtonInterceptor with WidgetsBindingObserver { static final SplayTreeMap> _prioritizedCallbacks = SplayTreeMap(); + /// priority of 0 is considered the highest priority. static void add(BoolCallback callback, {int? priority}) { + assert(priority == null || priority >= 0, 'Priority ,if not omitted, must be >= 0'); if (_prioritizedCallbacks.isEmpty) { _mount(); } - final List? callbacksList = _prioritizedCallbacks[priority]; + // Convert to double so that we have a valid maximum value to sort null + // priorities last. + final doublePriority = priority?.toDouble() ?? double.infinity; + final List? callbacksList = _prioritizedCallbacks[doublePriority]; if (callbacksList == null) { - // Convert to double so that we have a valid maximum value to sort null - // priorities last. - _prioritizedCallbacks[priority?.toDouble() ?? double.infinity] = [ - callback - ]; + _prioritizedCallbacks[doublePriority] = [callback]; return; } callbacksList.add(callback); diff --git a/feedback/lib/src/utilities/custom_color_position.dart b/feedback/lib/src/utilities/custom_color_position.dart new file mode 100644 index 00000000..269b59a4 --- /dev/null +++ b/feedback/lib/src/utilities/custom_color_position.dart @@ -0,0 +1,14 @@ +/// Position of the custom color picker icon. +enum CustomColorPosition { + /// Places the custom color picker icon at the BEGINNING of the drawing colors list. + leading, + + /// Places the custom color picker icon at the END of the drawing colors list. + trailing; + + /// Returns true if the icon is placed at the beginning of the drawing colors list. + bool get isLeading => this == CustomColorPosition.leading; + + /// Returns true if the icon is placed at the end of the drawing colors list. + bool get isTrailing => this == CustomColorPosition.trailing; +} diff --git a/feedback/test/controls_column_test.dart b/feedback/test/controls_column_test.dart index 0560ff1b..32328b9e 100644 --- a/feedback/test/controls_column_test.dart +++ b/feedback/test/controls_column_test.dart @@ -1,8 +1,9 @@ import 'package:feedback/src/controls_column.dart'; import 'package:feedback/src/feedback_mode.dart'; import 'package:feedback/src/l18n/localization.dart'; -import 'package:flutter_test/flutter_test.dart'; +import 'package:feedback/src/utilities/custom_color_position.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { Widget create({ @@ -14,6 +15,8 @@ void main() { VoidCallback? onCloseFeedback, VoidCallback? onClearDrawing, List? colors, + bool? showCustomColor, + CustomColorPosition? customColorPosition, }) { return FeedbackLocalization( child: ControlsColumn( @@ -26,6 +29,8 @@ void main() { onColorChanged: onColorChanged ?? (newColor) {}, onControlModeChanged: onControlModeChanged ?? (newMode) {}, onUndo: onUndo ?? () {}, + showCustomColor: showCustomColor ?? true, + customColorPosition: customColorPosition ?? CustomColorPosition.trailing, ), ); }