import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; import 'package:flutter/widgets.dart'; import 'package:info_tren/hidden_webview.dart'; import 'package:info_tren/utils/webview_invoke.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:webview_flutter/webview_flutter.dart'; part 'train_data.g.dart'; enum TrainLookupResult { FOUND, NOT_FOUND, OTHER } class OnDemandInvalidatedException implements Exception { final String propertyName; final OnDemand onDemandClass; OnDemandInvalidatedException({this.propertyName, this.onDemandClass}); @override String toString() { return "OnDemandInvalidatedException: An attempt to get $propertyName from ${onDemandClass.runtimeType} failed because the source was invalidated."; } } class OnDemand { bool valid; final Function onInvalidation; void invalidate() { if (valid) { valid = false; if (onInvalidation != null) onInvalidation(); } } OnDemand(this.onInvalidation): valid = true; } class OnDemandTrainData extends OnDemand { final WebViewController _controller; OnDemandTrainData({ WebViewController controller, Function onInvalidation }) : _controller = controller, _route = OnDemandTrainRoute(controller: controller), _lastInfo = OnDemandLastInfo(controller: controller), _destination = OnDemandDestination(controller: controller), _nextStop = OnDemandNextStop(controller: controller), _stations = OnDemandStations(controller: controller), super(onInvalidation); @override invalidate() { super.invalidate(); route.invalidate(); lastInfo.invalidate(); destination.invalidate(); nextStop.invalidate(); stations.invalidate(); } Future get _originalDepartureDate async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "_originalDepartureDate"); final tempRes = await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let field = table.querySelector("caption"); return field.textContent.trim(); })() """, isFunctionAlready: true, ); return tempRes.split(" ").last; } Future get departureDate async { final str = await _originalDepartureDate; final parts = str.split(".").map((str) => int.parse(str)).toList(); return DateTime(parts[2], parts[1], parts[0]); } Future get rang async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "rang"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[0]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ); } Future get trainNumber async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "trainNumber"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[1]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ); } Future get operator async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "operator"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[2]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ); } final OnDemandTrainRoute _route; OnDemandTrainRoute get route => _route; Future get state async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "state"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[4]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ); } final OnDemandLastInfo _lastInfo; OnDemandLastInfo get lastInfo => _lastInfo; final OnDemandDestination _destination; OnDemandDestination get destination => _destination; final OnDemandNextStop _nextStop; OnDemandNextStop get nextStop => _nextStop; Future get routeDistance async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "routeDistance"); final result = (await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[12]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, )).trim(); return takeWhile(result, (char) => char != ' '.codeUnitAt(0)); } Future get _routeDuration async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "_routeDuration"); var result = (await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[13]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, )).trim(); if (result[result.length - 1] == '.') result = result.substring(0, result.length - 1); return result; } Future get routeDuration async { final input = await _routeDuration; var result = Duration(); StringBuffer buffer = StringBuffer(); for (var i = 0; i < input.length; i++) { if ('0'.codeUnitAt(0) <= input.codeUnitAt(i) && input.codeUnitAt(i) <= '9'.codeUnitAt(0)) { buffer.writeCharCode(input.codeUnitAt(i)); } else if (input.startsWith("min", i)) { result += Duration(minutes: int.parse(buffer.toString())); buffer = StringBuffer(); i += 2; } else if (input.startsWith("h", i)) { result += Duration(hours: int.parse(buffer.toString())); buffer = StringBuffer(); } else throw FormatException("Unrecognised!"); } return result; } final OnDemandStations _stations; OnDemandStations get stations => _stations; } class OnDemandTrainRoute extends OnDemand { final WebViewController _controller; OnDemandTrainRoute({ WebViewController controller, Function onInvalidation }) : _controller = controller, super(onInvalidation); Future get original async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "original"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[3]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ); } Future get from async { final original = await this.original; return original.split("-")[0]; } Future get to async { final original = await this.original; return original.split("-")[1]; } } class OnDemandLastInfo extends OnDemand { final WebViewController _controller; OnDemandLastInfo({ WebViewController controller, Function onInvalidation }) : _controller = controller, super(onInvalidation); Future get _lastInfoOriginal async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "_lastInfoOriginal"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[5]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ); } Future get station async { final original = await _lastInfoOriginal; return original .split("[")[0] .trim(); } Future get event async { final original = await _lastInfoOriginal; return original .split("[")[1] .split("]")[0] .trim(); } Future get originalDateAndTime async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "originalDateAndTime"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[6]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ); } Future get dateAndTime async => parseCFRDateTime(await originalDateAndTime); Future get delay async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "delay"); return int.parse( await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[7]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, ) ); } } class OnDemandDestination extends OnDemand { final WebViewController _controller; OnDemandDestination({ WebViewController controller, Function onInvalidation }) : _controller = controller, super(onInvalidation); Future get stationName async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "destinationStation"); final result = (await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[8]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, )).trim(); if (result.isEmpty) return null; else return result; } Future get _originalDestinationArrival async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "_originalDestinationArrival"); final result = (await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[9]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, )).trim(); if (result.isEmpty) return null; else return result; } Future get arrival => _originalDestinationArrival.then((value) => parseCFRDateTime(value)); } class OnDemandNextStop extends OnDemand { final WebViewController _controller; OnDemandNextStop({ WebViewController controller, Function onInvalidation }) : _controller = controller, super(onInvalidation); Future get stationName async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "destinationStation"); final result = (await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[10]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, )).trim(); if (result.isEmpty) return null; else return result; } Future get _originalNextStopArrival async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "_originalDestinationArrival"); final result = (await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); let rows = table.querySelectorAll("tr"); let currentRow = rows[11]; let currentDataCell = currentRow.querySelectorAll("td")[1]; return currentDataCell.textContent; })() """, isFunctionAlready: true, )).trim(); if (result.isEmpty) return null; else return result; } Future get arrival => _originalNextStopArrival.then((value) => parseCFRDateTime(value)); } class OnDemandStations extends OnDemand { final WebViewController _controller; List issuedOnDemands = List(); @override void invalidate() { issuedOnDemands.map((od) => od.invalidate()); super.invalidate(); } OnDemandStations({ @required WebViewController controller, Function onInvalidation }) : _controller = controller, super(onInvalidation); Future get _stationsLoaded async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "_stationsLoaded"); final result = await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => JSON.stringify(document.querySelector("#GridView1") == null))() """, isFunctionAlready: true, ); final decoder = JsonDecoder(); return !(decoder.convert(result) as bool); } Stream call({@required Future pageLoadFuture}) async* { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "call"); if (!await _stationsLoaded) { await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const button = document.querySelector("#Button2"); button.click(); })() """, isFunctionAlready: true, ); await pageLoadFuture; } final count = int.parse(await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const count = rowsArray.length - 1; return String(count); })() """, isFunctionAlready: true, )); for (int i = 1; i <= count; i++) { final ods = OnDemandStation( controller: _controller, index: i, ); issuedOnDemands.add(ods); yield ods; } } } class OnDemandStation extends OnDemand { final WebViewController _controller; final int index; OnDemandStation({ @required WebViewController controller, @required this.index, Function onInvalidation }) : _controller = controller, super(onInvalidation); Future get km async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "km"); return int.parse(await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[0]; return kmCell.textContent; })() """, isFunctionAlready: true, )); } Future get stationName async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "stationName"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[1]; return kmCell.textContent; })() """, isFunctionAlready: true, ); } Future get arrivalTime async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "arrivalTime"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[2]; return kmCell.textContent.trim(); })() """, isFunctionAlready: true, ); } Future get stopsFor async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "stopsFor"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[3]; return kmCell.textContent.trim(); })() """, isFunctionAlready: true, ); } Future get departureTime async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "departureTime"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[4]; return kmCell.textContent.trim(); })() """, isFunctionAlready: true, ); } Future get realOrEstimate async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "realOrEstimate"); final value = await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[5]; return kmCell.textContent.trim(); })() """, isFunctionAlready: true, ); if (value == "Real") return RealOrEstimate.real; else if (value == "Estimat") return RealOrEstimate.estimate; else return RealOrEstimate.UNKNOWN; } Future get delay async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "delay"); final value = await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[6]; return kmCell.textContent.trim(); })() """, isFunctionAlready: true, ); if (value.isEmpty) return 0; else return int.parse(value); } Future get observations async { if (!valid) throw OnDemandInvalidatedException(onDemandClass: this, propertyName: "observations"); return await wInvoke( webViewController: _controller, jsFunctionContent: """ (() => { const table = document.querySelector("#GridView1"); const rows = table.querySelectorAll("tr"); const rowsArray = Array.from(rows); const row = rowsArray[$index]; const columns = row.querySelectorAll("td"); const kmCell = columns[7]; return kmCell.textContent.trim(); })() """, isFunctionAlready: true, ); } } enum RealOrEstimate { real, estimate, UNKNOWN } class TrainDataWebViewAdapter extends StatefulWidget { final WidgetBuilder builder; TrainDataWebViewAdapter({@required this.builder}); @override State createState() { return _TrainDataWebViewAdapterState(); } static _TrainDataWebViewAdapterState of(BuildContext context) => (context.inheritFromWidgetOfExactType(_TrainDataWebViewAdapterInheritedWidget) as _TrainDataWebViewAdapterInheritedWidget) .state; } class ProgressReport { final int current; final int total; final String description; ProgressReport({@required this.current, @required this.total, this.description}); @override String toString() { return description == null ? "ProgressReport($current/$total)" : "ProgressReport($current/$total: $description)"; } } class _TrainDataWebViewAdapterState extends State { Completer _webViewControllerCompleter = Completer(); Future get webViewController => _webViewControllerCompleter.future; StreamController _pageLoadController; Stream pageLoadStream; Future get nextLoadFuture => pageLoadStream.take(1).first; StreamController _progressController; Stream progressStream; Future loadTrain(int trainNo) async { currentDatas.removeWhere((ondemand) { ondemand.invalidate(); return true; }); final controller = await webViewController; var nlf; nlf = nextLoadFuture; await controller.loadUrl("https://appiris.infofer.ro/MytrainRO.aspx"); await nlf; _reportStatus( current: 2, description: "Loaded Informatica Feroviară webpage" ); nlf = nextLoadFuture; await controller.evaluateJavascript(""" ( () => { let inputField = document.querySelector("#TextTrnNo"); inputField.value = $trainNo; let submitButton = document.querySelector("#Button1"); submitButton.click(); } ) () """); await nlf; _reportStatus( current: 3, description: "Loaded train information" ); var result = await wInvoke( webViewController: controller, jsFunctionContent: """ (() => { let errorMessage = document.querySelector("#Lblx"); return errorMessage.textContent; })() """, isFunctionAlready: true, ); if (result.isNotEmpty) { return TrainLookupResult.NOT_FOUND; } final jsonDecoder = JsonDecoder(); final foundTable = jsonDecoder.convert(await wInvoke( webViewController: controller, jsFunctionContent: """ (() => { let table = document.querySelector("#DetailsView1"); return JSON.stringify(table !== null); })() """, isFunctionAlready: true, )) as bool; if (foundTable) { return TrainLookupResult.FOUND; } /// Should not happen, report error in this case return TrainLookupResult.OTHER; } List currentDatas = List(); Future trainData({Function onInvalidation}) async { final controller = await webViewController; final result = OnDemandTrainData( controller: controller, onInvalidation: onInvalidation ); currentDatas.add(result); return result; } int lastStatusReported; _reportStatus({@required int current, String description}) { lastStatusReported = current; _progressController.add(ProgressReport( current: current, total: TOTAL_PROGRESS, description: description )); } recallLastReport() { _progressController.add(ProgressReport(current: lastStatusReported, total: TOTAL_PROGRESS)); } restartProgressReport() { lastStatusReported = 0; webViewController.then((_) { _reportStatus(current: 1, description: "WebView created"); }); } @override void initState() { _pageLoadController = StreamController(); pageLoadStream = _pageLoadController.stream.asBroadcastStream(); _progressController = StreamController(); progressStream = _progressController.stream.asBroadcastStream(); lastStatusReported = 0; super.initState(); } @override void dispose() { _pageLoadController.close(); _progressController.close(); super.dispose(); } static const int TOTAL_PROGRESS = 3; @override Widget build(BuildContext context) { return HiddenWebView( webView: WebView( javascriptMode: JavascriptMode.unrestricted, onWebViewCreated: (controller) { _webViewControllerCompleter.complete(controller); _reportStatus( current: 1, description: "WebView created" ); }, onPageFinished: (url) { _pageLoadController.add(url); }, ), child: _TrainDataWebViewAdapterInheritedWidget( child: Builder( builder: widget.builder, ), state: this, ), ); } } class _TrainDataWebViewAdapterInheritedWidget extends InheritedWidget { final _TrainDataWebViewAdapterState state; _TrainDataWebViewAdapterInheritedWidget({this.state, Widget child, Key key}) :super(key: key, child: child); @override bool updateShouldNotify(InheritedWidget oldWidget) { return true; } } @JsonSerializable() class TrainData { final String rang; @JsonKey(name: "tren") final String trainNumber; final String operator; @JsonKey(name: "relatia") final String route; @JsonKey(name: "stare") final String state; @JsonKey(name: "ultima_informatie") final LastInfo lastInfo; @JsonKey(name: "destinatie") final StopInfo destination; @JsonKey(name: "urmatoarea_oprire") final StopInfo nextStop; @JsonKey(name: "durata_calatoriei") final String tripLength; @JsonKey(name: "distanta") final String distance; @JsonKey(name: "stations") List stations; TrainData({this.rang, this.trainNumber, this.operator, this.lastInfo, this.state, this.route, this.tripLength, this.stations, this.nextStop, this.distance, this.destination}); factory TrainData.fromJson(Map json) { var result = _$TrainDataFromJson(json); var foundEstimat = false; result.stations = result.stations.map((station) { if (station.realOrEstimate == "Estimat") { foundEstimat = true; } station.realOrEstimate = foundEstimat ? "Estimat" : "Real"; return station; }).toList(); return result; } Map toJson() => _$TrainDataToJson(this); } @JsonSerializable() class LastInfo { @JsonKey(name: "statia") final String station; @JsonKey(name: "eveniment") final String event; @JsonKey(name: "data_si_ora") final String dateAndTime; DateTime get formattedDateAndTime { return parseCFRDateTime(dateAndTime); } @JsonKey(name: "intarziere") final int delay; LastInfo({this.dateAndTime, this.delay, this.event, this.station}); factory LastInfo.fromJson(Map json) => _$LastInfoFromJson(json); Map toJson() => _$LastInfoToJson(this); } @JsonSerializable() class StopInfo { @JsonKey(name: "statia") final String station; @JsonKey(name: "data_si_ora") final String dateAndTime; DateTime get formattedDateAndTime { return parseCFRDateTime(dateAndTime); } StopInfo({this.station, this.dateAndTime}); factory StopInfo.fromJson(Map json) => _$StopInfoFromJson(json); Map toJson() => _$StopInfoToJson(this); } @JsonSerializable() class StationEntry { final String km; @JsonKey(name: "statia") final String name; @JsonKey(name: "sosire") final String arrivalTime; @JsonKey(name: "stationeaza_pentru") final String waitTime; @JsonKey(name: "plecare") final String departureTime; @JsonKey(name: "real/estimat") String realOrEstimate; bool get real { return realOrEstimate == "Real"; } @JsonKey(name: "intarziere") final int delay; @JsonKey(name: "observatii") final String observations; StationEntry({this.name, this.delay, this.realOrEstimate, this.arrivalTime, this.departureTime, this.km, this.observations, this.waitTime}); factory StationEntry.fromJson(Map json) => _$StationEntryFromJson(json); Map toJson() => _$StationEntryToJson(this); } DateTime parseCFRDateTime(String dateAndTime) { if (dateAndTime == null || dateAndTime.isEmpty) return null; final parts = dateAndTime.split(" "); final dateParts = parts[0].split("."); final day = int.parse(dateParts[0]); final month = int.parse(dateParts[1]); final year = int.parse(dateParts[2]); final timeParts = parts[1].split(":"); final hour = int.parse(timeParts[0]); final minute = int.parse(timeParts[1]); return DateTime(year, month, day, hour, minute); } String takeWhile(String input, Function charValidator) { StringBuffer output = StringBuffer(); for (final char in input.codeUnits) { if (charValidator(char)) output.writeCharCode(char); else break; } return output.toString(); }