Add fixed datetime parser/formatter
diff --git a/lib/convert.dart b/lib/convert.dart
index 53a68d8..ce10f7f 100644
--- a/lib/convert.dart
+++ b/lib/convert.dart
@@ -7,6 +7,7 @@
 export 'src/accumulator_sink.dart';
 export 'src/byte_accumulator_sink.dart';
 export 'src/codepage.dart';
+export 'src/fixed_datetime_parser.dart';
 export 'src/hex.dart';
 export 'src/identity_codec.dart';
 export 'src/percent.dart';
diff --git a/lib/src/fixed_datetime_parser.dart b/lib/src/fixed_datetime_parser.dart
new file mode 100644
index 0000000..4e3cb40
--- /dev/null
+++ b/lib/src/fixed_datetime_parser.dart
@@ -0,0 +1,152 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// A class for parsing dates for a fixed format string. For example, calling
+/// `DateParser('YYYYMMDDhhmmss').parseToLocal('19960425050322')` has the same
+/// result as calling `DateTime(1996, 4, 25, 5, 3, 22)`.
+///
+/// The allowed characters are
+/// * Y	a digit used in the time scale component “calendar year”
+/// * M	a digit used in the time scale component “calendar month”
+/// * D	a digit used in the time scale component “calendar day”
+/// * E	a digit used in the time scale component “decade”
+/// * C	a digit used in the time scale component “century”
+/// * h	a digit used in the time scale component “clock hour”
+/// * m	a digit used in the time scale component “clock minute”
+/// * s	a digit used in the time scale component “clock second”
+/// as specified in the ISO 8601 standard.
+///
+/// Non-allowed characters in the format _pattern are ignored, therefore
+/// `YYYY kiwi MM` is the same format string as `YYYY------MM`.
+///
+/// Note: this class differs from [DateFormat] in that here, the characters are
+/// treated literally, i.e. the format string `YYY` matching `996` would result in
+/// the same as calling `DateTime(996)`. [DateFormat] on the other hand uses the
+/// specification in https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table,
+/// the format string (or "skeleton") `YYY` specifies only the padding, so
+/// `1996` would be a valid match. This limits is use to format strings
+/// containing delimiters, as the parser would not know how many digits to take
+/// otherwise.
+///
+/// Also, this parser does not know about locales and parses to the current
+/// locale only.
+class FixedDateTimeParser {
+  static const _validChars = ['Y', 'M', 'D', 'E', 'C', 'h', 'm', 's'];
+
+  final String _pattern;
+  final _occurences = <String, _Range>{};
+
+  FixedDateTimeParser(this._pattern) {
+    String current = '';
+    for (var i = 0; i < _pattern.length; i++) {
+      var char = _pattern[i];
+      if (!_validChars.contains(char)) {
+        current = '';
+        continue;
+      }
+      var newChar = current != char;
+      if (newChar) {
+        var hasNotSeenBefore = !_occurences.containsKey(char);
+        if (hasNotSeenBefore) {
+          _occurences[char] = _Range(i, i);
+        } else {
+          throw Exception(
+              'The _pattern string "$_pattern" contains multiple instances of the formatting char block $char, both at position ${_occurences[char]!.from} and $i');
+        }
+      }
+      current = char;
+      _occurences.update(current, (value) => _Range(value.from, value.to + 1));
+    }
+  }
+
+  /// Convert a datetime to a string exactly as specified by the [_pattern].
+  String format(DateTime dt) {
+    var startingPoints =
+        _occurences.map((key, value) => MapEntry(value.from, key));
+    var sb = StringBuffer();
+    for (var i = 0; i < _pattern.length; i++) {
+      if (startingPoints.containsKey(i)) {
+        var key = startingPoints[i];
+        var length = _occurences[key]!.length;
+        var number = _extractNumFromDateTime(key, dt);
+        var numAsString = number.toString();
+        if (numAsString.length > length) {
+          throw Exception(
+              "The datetime $dt cannot be parsed as $number is longer than the _pattern $key allows");
+        } else {
+          sb.write(numAsString.padLeft(length, '0'));
+        }
+        i += length - 1;
+      } else {
+        sb.write(_pattern[i]);
+      }
+    }
+    return sb.toString();
+  }
+
+  int _extractNumFromDateTime(String? key, DateTime dt) {
+    switch (key) {
+      case 'Y':
+        return dt.year;
+      case 'C':
+        return (dt.year / 100).floor();
+      case 'E':
+        return (dt.year / 10).floor();
+      case 'M':
+        return dt.month;
+      case 'D':
+        return dt.day;
+      case 'h':
+        return dt.hour;
+      case 'm':
+        return dt.minute;
+      case 's':
+        return dt.second;
+    }
+    throw ArgumentError.value(key);
+  }
+
+  /// Parse a string [dateTimeStr] to a local [DateTime] as specified in the
+  /// [_pattern]. Throws an exception on failure.
+  DateTime parseToLocal(String dateTimeStr) {
+    int? year = _extractDateTimeFromStr(dateTimeStr, 'Y') ?? 1;
+    int? century = _extractDateTimeFromStr(dateTimeStr, 'C') ?? 0;
+    int? decade = _extractDateTimeFromStr(dateTimeStr, 'E') ?? 0;
+    var totalYear = year + 100 * century + 10 * decade;
+    int? month = _extractDateTimeFromStr(dateTimeStr, 'M') ?? 1;
+    int? day = _extractDateTimeFromStr(dateTimeStr, 'D') ?? 1;
+    int? hour = _extractDateTimeFromStr(dateTimeStr, 'h') ?? 0;
+    int? minute = _extractDateTimeFromStr(dateTimeStr, 'm') ?? 0;
+    int? second = _extractDateTimeFromStr(dateTimeStr, 's') ?? 0;
+    return DateTime(totalYear, month, day, hour, minute, second, 0, 0);
+  }
+
+  /// Same as [parseToLocal], but returns null if the string could not be
+  /// parsed.
+  DateTime? tryParseToLocal(String dateTimeStr) {
+    try {
+      return parseToLocal(dateTimeStr);
+    } catch (_) {
+      return null;
+    }
+  }
+
+  int? _extractDateTimeFromStr(String s, String id) {
+    var pos = _occurences[id];
+    if (pos != null) {
+      return int.parse(s.substring(pos.from, pos.to));
+    } else {
+      return null;
+    }
+  }
+}
+
+class _Range {
+  final int from;
+  final int to;
+
+  _Range(this.from, this.to);
+
+  int get length => to - from;
+}
diff --git a/test/fixed_datetime_parser_test.dart b/test/fixed_datetime_parser_test.dart
new file mode 100644
index 0000000..8b29aa6
--- /dev/null
+++ b/test/fixed_datetime_parser_test.dart
@@ -0,0 +1,51 @@
+import 'package:convert/src/fixed_datetime_parser.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('Parse only year', () {
+    var time = FixedDateTimeParser('YYYY').parseToLocal('1996');
+    expect(time, DateTime(1996));
+  });
+  test('Escaped chars are ignored', () {
+    var time = FixedDateTimeParser('YYYY kiwi MM').parseToLocal('1996 rnad 01');
+    expect(time, DateTime(1996, 1));
+  });
+  test('Parse two years throws', () {
+    expect(() => FixedDateTimeParser('YYYY YYYY'), throwsException);
+  });
+  test('Parse year and century', () {
+    var time = FixedDateTimeParser('CCYY').parseToLocal('1996');
+    expect(time, DateTime(1996));
+  });
+  test('Parse year, decade and century', () {
+    var time = FixedDateTimeParser('CCEY').parseToLocal('1996');
+    expect(time, DateTime(1996));
+  });
+  test('Parse year, century, month', () {
+    var time = FixedDateTimeParser('CCYY MM').parseToLocal('1996 04');
+    expect(time, DateTime(1996, 4));
+  });
+  test('Parse year, century, month, day', () {
+    var time = FixedDateTimeParser('CCYY MM-DD').parseToLocal('1996 04-25');
+    expect(time, DateTime(1996, 4, 25));
+  });
+  test('Parse year, century, month, day, hour, minute, second', () {
+    var time = FixedDateTimeParser('CCYY MM-DD hh:mm:ss')
+        .parseToLocal('1996 04-25 05:03:22');
+    expect(time, DateTime(1996, 4, 25, 5, 3, 22));
+  });
+  test('Parse YYYYMMDDhhmmss', () {
+    var time =
+        FixedDateTimeParser('YYYYMMDDhhmmss').parseToLocal('19960425050322');
+    expect(time, DateTime(1996, 4, 25, 5, 3, 22));
+  });
+  test('Format simple', () {
+    var time = DateTime(1996, 1);
+    expect('1996 kiwi 01', FixedDateTimeParser('YYYY kiwi MM').format(time));
+  });
+  test('Format YYYYMMDDhhmmss', () {
+    var str = FixedDateTimeParser('YYYYMMDDhhmmss')
+        .format(DateTime(1996, 4, 25, 5, 3, 22));
+    expect('19960425050322', str);
+  });
+}