Extensible Dart Classes with Extension Methods

I was recently tasked with creating a general-purpose unit conversion class. The class needed to be easily extensible so new conversions could be easily added at any time. It also needed to throw an exception when an unsupported conversion was attempted. Furthermore, I was told that the conversion method needed to take in two string parameters to represent the units of the source value and the units to convert the value to. In addition, the source value would be provided as a double-precision value. The whole thing was to provide unit conversion services for a Flutter app and a Dart-backed website.

My approach was to make use of Dart extension methods. If you are not familiar with extension methods, they were added in Dart version 2.7 as a way to add functionality to a class without the need to inherit or extend the class with another class. Head over to the Dart Language Guide and read up on them first. Philip has a great video provided there that will get you up to speed quickly.

I decided on a sort of command pattern/table-driven approach. The main class simply manages the registration of conversion methods and the units available for use. A developer can add new conversion units and methods to the system at any time by writing the method as an extension method and then registering that method with the parent class. So let’s look at the code which really is more comments than code, but since this class is designed to be extensible by almost anyone, I feel the high level of comments is justified here.

class UnitConverter implements Function {
  /// Map of conversion methods. Keys are base on fromUnit and toUnit values.
  Map<String, Function> conversionMethods = {};

  /// List of supported units available for conversion.
  List<String> availableUnits = [];

  UnitConverter() {
    registerExtensions();
  }

  /// Generate method name from conversion units
  String generateMethodKey(String fromUnit, String toUnit) {
    return fromUnit.toLowerCase() +
        'To' +
        toUnit[1].toUpperCase() +
        toUnit.substring(1).toLowerCase();
  }

  /// registerConversionMethod(fromUnit, toUnit, methodName)
  ///   Registers method to handle the conversion from
  ///   fromUnit to toUnit.
  void registerConversionMethod(
      String fromUnit, String toUnit, Function method) {
    String methodKey = generateMethodKey(fromUnit, toUnit);
    conversionMethods[methodKey] = method;
  }

  void registerExtensions() {
    ///
    /// To add aditional conversions, with each new extension methos particularly known ford
    /// call registerConversionExtensionMethod(String fromUnit, String toUnits, Function method).
    ///
    /// Conversion methods should be named <fromUnit>To<toUnit> with
    /// the first character of <toUnit> being capitalized for example:
    ///
    ///   fehrenheitToCelsius()
    ///
    /// For a method to convert fehrenheit to celsius.
    ///
    /// All conversion methods must take a single parameter:
    ///   double fromValue - The value being converted.
    ///
    /// And return a double-precsiion value on successful conversion.
    ///
    registerConversionExtensionMethods();

    ///
    /// A couple convinence methods have been provided to aid the developer
    /// using this class. They simply allow the registration of new units
    /// to be added to a store for later use in UI menus.s particularly known for
    ///
    /// To register a new unit of measurement, make a call to registerUnit(String unit)
    /// in the registerExtensionUnits() extension method. This will add the unit to
    /// the internal list of units. Then in the UI you can use the list like so:
    ///
    ///   final unitConverter = UnitConverter();
    ///   final supportedUnits = unitConverter.supportedUnits;
    ///
    ///   supportedUnits.map(...);
    ///
    /// This works well with creating drop down menus or lists of buttons for
    /// each supported unit.
    ///
    registerExtensionUnits();
  }

  /// canConvert(fromUnit, toUnit): Returns true if
  /// the conversion is supported. False otherwise.
  /// fromUnit and toUnit are strings.
  bool canConvert(String fromUnit, String toUnit) {
    if (fromUnit.isEmpty || toUnit.isEmpty) return false;
    String methodKey = generateMethodKey(fromUnit, toUnit);
    return conversionMethods.containsKey(methodKey);
  }

  /// Given: fromUnit, fromValue, and toUnit, call
  /// the appropriate conversion method.
  double convert(String fromUnit, double fromValue, String toUnit) {
    if (!canConvert(fromUnit, toUnit))
      throw UnsupportedConversionException(fromUnit, toUnit);
    if (fromValue.isInfinite || fromValue.isNaN)
      throw BadConversionValueException(
          'Connot convert inappropriate value: $fromValue');

    String methodKey = generateMethodKey(fromUnit, toUnit);
    return conversionMethods[methodKey]!.call(fromValue);
  }

  // Convience Methods for UI
  void registerUnit(String unit) {
    availableUnits.add(unit);
  }

  List<String> get supportedUnits => availableUnits;
}

The sheer bulk of comments here make the class seem busy. But if you remove the comments, you’ll find the class is very simple. It really only implements a method registration system and generic “convert” method to locate and call the appropriate conversion extension method. The convert method also does a little error checking to ensure such a method has been registered and throws an UnsupportedConversionException if a suitable method has not been registered.

The class also includes a couple of helper functions just to ease the UI developer’s job. The ‘registerUnit’ method is used to add an entry into the ‘availableUnits’ list. The ‘supportedUnits’ getter returns that list to the caller so it can be used by the UI to build menus.

Adding New Conversions As Easy as 1,2,3!

Now we have our UnitConversion class. So it’s time to add a few conversion methods. To do this, we simply create an extension on the UnitConverter class like so:

//
// Fehrenheit
//
extension ConvertFehrenheit on UnitConverter {
  double fehrenheitToFehrenheit(double fromValue) {
    // Using: °F = °F
    return fromValue;
  }

  double fehrenheitToCelsius(double fromValue) {
    // Using: (32°F − 32) × 5/9 = 0°C
    var factor = 5 / 9;
    return (fromValue - 32.0) * factor;
  }

  double fehrenheitToKelvin(double fromValue) {
    // Using: K = 5/9 (° F - 32) + 273
    var factor = 5 / 9;
    return ((fromValue - 32) * factor) + 273;
  }
}

Here we’ve created an extension to the UnitConverter class named ConvertFehrenheit. Then we added three extension methods to the named extension. It is important that the extension be named. Otherwise, these methods will not be available in other files. Ok, so that’s step 1.

We now need to register our new conversion methods with the UnitConverter class. We can do that easily with a few calls to registerConversionMethod. These calls need to be placed in the RegisterConversions extension in the registerConversionExtensionMethods method.

extension RegisterConversions on UnitConverter {
  void registerConversionExtensionMethods() {
    // Fehrenheit
    registerConversionMethod('fehrenheit', 'fehrenheit', fehrenheitToFehrenheit);
    registerConversionMethod('fehrenheit', 'celsius', fehrenheitToCelsius);
    registerConversionMethod('fehrenheit', 'kelvin', fehrenheitToKelvin);
  }
}

That was pretty simple right! Ok, so we said there were three steps and the last step is only to support using the supportedUnits methods. We just need to register the units we now support with the UnitConverter class. This is done by a call to registerUnit. These calls are placed in the RegisterConversionUnits extension and placed in the registerExtensionUnits method:

extension RegisterConversionUnits on UnitConverter {
  registerExtensionUnits() {
    registerUnit('Fehrenheit');
  }
}

You can now create as many new conversion extension methods as you like and register them with the UnitConversion class. Then they can be found by the convert method to provide the appropriate conversion. A call from the UI such as:

                  try {
                    double fromValue = double.parse(_fromValue!);
                    double cvalue =
                        unitConverter.convert(_fromUnit!, fromValue, _toUnit!);
                    setState(() {
                      _toValue = cvalue.toString();
                    });
                  } on BadConversionValueException {
                    setState(() {
                      _toValue = 'Bad input value given!';
                    });
                  } on UnsupportedConversionException {
                    setState(() {
                      _toValue = 'Unsupported conversion attempted!';
                    });
                  } on FormatException {
                    setState(() {
                      _toValue = 'Invalid value entered!';
                    });
                  }

This code would work well in the ‘onPressed’ method of a Flutter button.

This type of extension system isn’t suitable for all situations where you need to be able to extend a class with new methods. But it is very doable for situations when it is.

Below is the complete sample code that includes many more conversion extension methods and a simple flutter app to exercise the UnitConverter class.

I hope this has been helpful and invokes some ideas on how we can best put extension methods to work for us in Dart/Flutter apps.

Sample Code (Complete Sample App)

// File: unit_converter.dart
class UnsupportedConversionException implements Exception {
String fromUnit = '';
String toUnit = '';
late String message;
UnsupportedConversionException(this.fromUnit, this.toUnit) {
this.message = 'Conversion from $fromUnit to $toUnit is not supported';
}
}
class BadConversionValueException implements Exception {
String message = '';
BadConversionValueException(this.message);
}
class UnitConverter implements Function {
/// Map of conversion methods. Keys are base on fromUnit and toUnit values.
Map<String, Function> conversionMethods = {};
/// List of supported units available for conversion.
List<String> availableUnits = [];
UnitConverter() {
registerExtensions();
}
/// Generate method name from conversion units
String generateMethodKey(String fromUnit, String toUnit) {
return fromUnit.toLowerCase() +
'To' +
toUnit[1].toUpperCase() +
toUnit.substring(1).toLowerCase();
}
/// registerConversionMethod(fromUnit, toUnit, methodName)
///   Registers method to handle the conversion from
///   fromUnit to toUnit.
void registerConversionMethod(
String fromUnit, String toUnit, Function method) {
String methodKey = generateMethodKey(fromUnit, toUnit);
conversionMethods[methodKey] = method;
}
void registerExtensions() {
///
/// To add aditional conversions, with each new extension method
/// call registerConversionExtensionMethod(String fromUnit, String toUnits, Function method).
///
/// Conversion methods should be named <fromUnit>To<toUnit> with
/// the first character of <toUnit> being capitalized for example:
///
///   fehrenheitToCelsius()
///
/// For a method to convert fehrenheit to celsius.
///
/// All conversion methods must take a single parameter:
///   double fromValue - The value being converted.
///
/// And return a double-precsiion value on successful conversion.
///
registerConversionExtensionMethods();
///
/// A couple convinence methods have been provided to aid the developer
/// using this class. They simply allow the registration of new units
/// to be added to a store for later use in UI menus.
///
/// To register a new unit of measurement, make a call to registerUnit(String unit)
/// in the registerExtensionUnits() extension method. This will add the unit to
/// the internal list of units. Then in the UI you can use the list like so:
///
///   final unitConverter = UnitConverter();
///   final supportedUnits = unitConverter.supportedUnits;
///
///   supportedUnits.map(...);
///
/// This works well with creating drop down menus or lists of buttons for
/// each supported unit.
///
registerExtensionUnits();
}
/// canConvert(fromUnit, toUnit): Returns true if
/// the conversion is supported. False otherwise.
/// fromUnit and toUnit are strings.
bool canConvert(String fromUnit, String toUnit) {
if (fromUnit.isEmpty || toUnit.isEmpty) return false;
String methodKey = generateMethodKey(fromUnit, toUnit);
return conversionMethods.containsKey(methodKey);
}
/// Given: fromUnit, fromValue, and toUnit, call
/// the appropriate conversion method.
double convert(String fromUnit, double fromValue, String toUnit) {
if (!canConvert(fromUnit, toUnit))
throw UnsupportedConversionException(fromUnit, toUnit);
if (fromValue.isInfinite || fromValue.isNaN)
throw BadConversionValueException(
'Connot convert inappropriate value: $fromValue');
String methodKey = generateMethodKey(fromUnit, toUnit);
return conversionMethods[methodKey]!.call(fromValue);
}
// Convience Methods for UI
void registerUnit(String unit) {
availableUnits.add(unit);
}
List<String> get supportedUnits => availableUnits;
}
//==============================================================================
// Add new conversion methods below this header
//==============================================================================
//------------------------------------------------------------------------------
// Register all conversion units here
//------------------------------------------------------------------------------
extension RegisterConversionUnits on UnitConverter {
registerExtensionUnits() {
registerUnit('Fehrenheit');
registerUnit('Celsius');
registerUnit('Kelvin');
registerUnit('Inch');
registerUnit('Feet');
registerUnit('Milimeter');
registerUnit('Centimeter');
registerUnit('Meter');
registerUnit('Yard');
}
}
//------------------------------------------------------------------------------
// Register all conversion extension methods here
//------------------------------------------------------------------------------
extension RegisterConversions on UnitConverter {
void registerConversionExtensionMethods() {
// Fehrenheit
registerConversionMethod(
'fehrenheit', 'fehrenheit', fehrenheitToFehrenheit);
registerConversionMethod('fehrenheit', 'celsius', fehrenheitToCelsius);
registerConversionMethod('fehrenheit', 'kelvin', fehrenheitToKelvin);
// Celsius
registerConversionMethod('celsius', 'celsius', celsiusToCelsius);
registerConversionMethod('celsius', 'fehrenheit', celsiusToFehrenheit);
registerConversionMethod('celsius', 'kelvin', celsiusToKelvin);
// Kelvin
registerConversionMethod('kelvin', 'kelvin', kelvinToKelvin);
registerConversionMethod('kelvin', 'fehrenheit', kelvinToFehrenheit);
registerConversionMethod('kelvin', 'celsius', kelvinToCelsius);
// Inches
registerConversionMethod('inch', 'inch', inchToInch);
registerConversionMethod('inch', 'feet', inchToFeet);
registerConversionMethod('inch', 'milimeter', inchToMilimeter);
registerConversionMethod('inch', 'centimeter', inchToCentimeter);
registerConversionMethod('inch', 'meter', inchToMeter);
registerConversionMethod('inch', 'yard', inchToYard);
// Feet
registerConversionMethod('feet', 'yard', feetToYard);
registerConversionMethod('feet', 'feet', feetToFeet);
registerConversionMethod('feet', 'inch', feetToInch);
registerConversionMethod('feet', 'milimeter', feetToMilimeter);
registerConversionMethod('feet', 'centimeter', feetToCentimeter);
registerConversionMethod('feet', 'meter', feetToMeter);
// Yard
registerConversionMethod('yard', 'yard', yardToYard);
registerConversionMethod('yard', 'feet', yardToFeet);
registerConversionMethod('yard', 'inch', yardToInch);
registerConversionMethod('yard', 'meter', yardToMeter);
registerConversionMethod('yard', 'centimeter', yardToCentimeter);
registerConversionMethod('yard', 'milimeter', yardToMilimeter);
//
// Meter
//
registerConversionMethod('meter', 'meter', meterToMeter);
registerConversionMethod('meter', 'yard', meterToYard);
registerConversionMethod('meter', 'feet', meterToFeet);
registerConversionMethod('meter', 'inch', meterToInch); 
registerConversionMethod('meter', 'centimeter', meterToCentimeter);
registerConversionMethod('meter', 'milimeter', meterToMilimeter);
//
// Register new conversion method here
// ...
}
}
//
// Fehrenheit
//
extension ConvertFehrenheit on UnitConverter {
double fehrenheitToFehrenheit(double fromValue) {
// Using: °F = °F
return fromValue;
}
double fehrenheitToCelsius(double fromValue) {
// Using: (32°F − 32) × 5/9 = 0°C
var factor = 5 / 9;
return (fromValue - 32.0) * factor;
}
double fehrenheitToKelvin(double fromValue) {
// Using: K = 5/9 (° F - 32) + 273
var factor = 5 / 9;
return ((fromValue - 32) * factor) + 273;
}
}
//
// Celsius
//
extension ConvertCelsius on UnitConverter {
double celsiusToCelsius(double fromValue) {
// Using: °C = °C
return fromValue;
}
double celsiusToFehrenheit(double fromValue) {
// Using: °F = (°C  × 9/5) + 32
var factor = 9 / 5; // = (9/5)
return (fromValue * factor) + 32;
}
double celsiusToKelvin(double fromValue) {
// Using: K = °C + 273
return fromValue + 273;
}
}
//
// Kelvin
//
extension ConvertKelvin on UnitConverter {
double kelvinToKelvin(double fromValue) {
// Using: K = K
return fromValue;
}
double kelvinToFehrenheit(double fromValue) {
// Using: ° F = 9/5 (K - 273) + 32
// Note 9/5 = 1.8
var factor = 1.8; // = (9/5)
return factor * (fromValue - 273) + 32;
}
double kelvinToCelsius(double fromValue) {
// Using: c = k - 273
return fromValue - 273;
}
}
//
// Inches
//
extension ConvertInches on UnitConverter {
double inchToInch(double fromValue) {
// Using: in = in
return fromValue;
}
double inchToFeet(double fromValue) {
// Using: Ft = inches * 1/12
var factor = 1 / 12;
return fromValue * factor;
}
double inchToYard(double fromValue) {
// Using: yrd = inches * 0.0277778
var factor = 0.0277778;
return fromValue * factor;
}
double inchToMilimeter(double fromValue) {
// Using: mm = in * 25.4
var factor = 25.4;
return fromValue * 25.4;
}
double inchToCentimeter(double fromValue) {
// Using: cm = inches * 2.54
var factor = 2.54;
return fromValue * factor;
}
double inchToMeter(double fromValue) {
// Using: mm = in * 25.4
var factor = 0.0254;
return fromValue * factor;
}
}
//
//Feet
//
extension ConvertFoot on UnitConverter {
double feetToFeet(double fromValue) {
// Using: ft = ft
return fromValue;
}
double feetToInch(double fromValue) {
// Using: in = ft * 12
var factor = 12.0;
return fromValue * factor;
}
double feetToYard(double fromValue) {
// Using: yrd = ft * 0.3333333333333333
var factor = 0.3333333333333333;
return fromValue * factor;
}
double feetToMilimeter(double fromValue) {
// Using: mm = ft * 304.8
var factor = 304.8;
return fromValue * factor;
}
double feetToCentimeter(double fromValue) {
// Using: cm = ft * 30.48
var factor = 30.48;
return fromValue * factor;
}
}
double feetToMeter(double fromValue) {
// Using: m = f * 0.3048
var factor = 0.3048;
return fromValue * factor;
}
//
// Yards
//
extension ConvertYard on UnitConverter {
double yardToYard(double fromValue) {
// Using: yrd = yrd
return fromValue;
}
double yardToInch(double fromValue) {
// Using: in = yrd * 36
var factor = 36;
return fromValue * factor;
}
double yardToFeet(double fromValue) {
// Using: ft = yard * 3
var factor = 3;
return fromValue * 3;
}
double yardToMeter(double fromValue) {
// Using: m = ft * 0.9144
var factor = 0.9144;
return fromValue * factor;
}
double yardToMilimeter(double fromValue) {
// Using: mm = yrd * 914.4
var factor = 914.4;
return fromValue * factor;
}
double yardToCentimeter(double fromValue) {
// Using cm = yrd * 91.44
var factor = 91.44;
return fromValue * factor;
}
}
//
// Meter
//
extension ConvertMeter on UnitConverter {
double meterToMeter(double fromValue) {
// Using: m = m
return fromValue;
}
double meterToInch(double fromValue) {
// Using: in = mm * 39.3701
var factor = 39.3701;
return fromValue * factor;
}
double meterToFeet(double fromValue) {
// Using: ft = mm * 3.28084
var factor = 3.28084;
return fromValue * factor;
}
double meterToYard(double fromValue) {
// Using: yrd = mm * 1.09361
var factor = 1.09361;
return fromValue * factor;
}
double meterToCentimeter(double fromValue) {
// Using: cm = m * 100
var factor = 100;
return fromValue * factor;
}
double meterToMilimeter(double fromValue) {
// Using:
var factor = 1000;
return fromValue * factor;
}
}

Sample Flutter App Below:

// File: main.dart
import 'package:flutter/material.dart';
import 'package:unit_converter2/units/unit_conversion.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Unit Converter'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
String? _fromUnit = 'Fehrenheit';
String? _toUnit = 'Celsius';
String? _fromValue = '0.0';
String? _toValue = '0.0';
String? _fromLabel = 'Fehrenheit';
String? _toLabel = 'Celsius';
var unitConverter = UnitConverter();
@override
Widget build(BuildContext context) {
var supportedUnits = unitConverter.supportedUnits;
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Convert',
style: TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
],
),
Row(
children: [
Text(
'From',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 22,
),
),
SizedBox(width: 8.0),
DropdownButton(
items: supportedUnits
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(color: Colors.black),
),
);
}).toList(),
hint: Text(
_fromLabel ?? 'Select Units',
style: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w500),
),
onChanged: (String? value) {
setState(() {
_fromLabel = value;
_fromUnit = value;
});
},
),
],
),
TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Value',
),
onChanged: (value) {
setState(() {
_fromValue = value;
});
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Text(
'To',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 22,
),
),
SizedBox(width: 8.0),
DropdownButton(
items: supportedUnits
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(color: Colors.black),
),
);
}).toList(),
hint: Text(
_toLabel ?? 'Select Units',
style: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w500),
),
onChanged: (String? value) {
setState(() {
_toLabel = value;
_toUnit = value;
});
},
),
SizedBox(width: 16.0),
Flexible(
child: Text(
(_toValue == null ? 'Answer' : _toValue)!,
style: TextStyle(
color: Colors.black,
fontSize: 22.0,
),
),
),
],
),
),
Spacer(flex: 1),
ElevatedButton(
onPressed: () {
print('Converting value $_fromValue $_fromUnit to $_toUnit');
try {
double fromValue = double.parse(_fromValue!);
double cvalue =
unitConverter.convert(_fromUnit!, fromValue, _toUnit!);
setState(() {
_toValue = cvalue.toString();
});
} on BadConversionValueException {
setState(() {
_toValue = 'Bad input value given!';
});
} on UnsupportedConversionException {
setState(() {
_toValue = 'Unsupported conversion attempted!';
});
} on FormatException {
setState(() {
_toValue = 'Invalid value entered!';
});
}
},
child: Text('Convert'),
),
Spacer(flex: 5),
],
),
),
),
);
}
}

Leave a Reply

Your email address will not be published. Required fields are marked *