dartmole_plugin 0.1.0
dartmole_plugin: ^0.1.0 copied to clipboard
Routes a Flutter app's HTTP through Dartmole, so it can be inspected and mocked.
example/lib/main.dart
import 'package:dartmole_plugin/dartmole_plugin.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'air_quality.dart';
import 'native_http.dart';
import 'pairing_store.dart';
import 'settings_screen.dart';
import 'weather.dart';
final Dio dio = Dio();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
_useDartmole();
// Dio's adapter creates its HttpClient once and keeps it. The weather loads
// at launch, possibly before the remembered pairing is back, so without a
// fresh adapter on every change the app would keep talking to the internet
// directly.
Dartmole().addListener(_useDartmole);
// Briefly, so the first weather call can already go through Dartmole.
await (await PairingStore.open()).restore();
runApp(const WeatherApp());
}
void _useDartmole() {
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: Dartmole().createHttpClient,
);
}
class WeatherApp extends StatelessWidget {
const WeatherApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Dartmole example',
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: const WeatherScreen(place: berlin),
);
}
}
class WeatherScreen extends StatefulWidget {
const WeatherScreen({super.key, required this.place});
final Place place;
@override
State<WeatherScreen> createState() => _WeatherScreenState();
}
class _WeatherScreenState extends State<WeatherScreen> {
final _api = WeatherApi(dio, apiKey: openWeatherApiKey);
final _airApi = const AirQualityApi(apiKey: openWeatherApiKey);
late Future<Weather>? _weather = _load();
late Future<AirQuality>? _airQuality = _loadAirQuality();
/// Null when there is no API key: nothing to call until there is one.
Future<Weather>? _load() =>
_api.apiKey.isEmpty ? null : _api.current(widget.place);
Future<AirQuality>? _loadAirQuality() =>
_airApi.apiKey.isEmpty ? null : _airApi.current(widget.place);
Future<void> _refresh() async {
final weather = _load();
final airQuality = _loadAirQuality();
// A block body: an arrow would return the assigned Future, and setState
// rejects callbacks that return one.
setState(() {
_weather = weather;
_airQuality = airQuality;
});
await Future.wait([
?weather?.then((_) {}, onError: (_) {}),
?airQuality?.then((_) {}, onError: (_) {}),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.place.name),
actions: [
// The weather on screen stays put while a refresh is in flight, so
// the button turns into a spinner to show the call went out.
FutureBuilder<Weather>(
future: _weather,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(14),
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
return IconButton(
tooltip: 'Refresh',
icon: const Icon(Icons.refresh),
onPressed: _weather == null ? null : _refresh,
);
},
),
IconButton(
tooltip: 'Dartmole settings',
icon: const Icon(Icons.settings),
onPressed: () => SettingsScreen.show(context),
),
],
),
body: RefreshIndicator(
onRefresh: _refresh,
child: FutureBuilder<Weather>(
future: _weather,
builder: (context, snapshot) {
return CustomScrollView(
// Pull-to-refresh needs something to scroll, even when the
// content fits.
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: switch (snapshot) {
_ when _weather == null => const _MissingKeyView(),
AsyncSnapshot(:final data?) => _WeatherView(
data,
airQuality: _airQuality,
),
AsyncSnapshot(:final error?) => _ErrorView(
error: error,
onRetry: _refresh,
),
_ => const CircularProgressIndicator(),
},
),
),
),
],
);
},
),
),
);
}
}
class _WeatherView extends StatelessWidget {
const _WeatherView(this.weather, {required this.airQuality});
final Weather weather;
final Future<AirQuality>? airQuality;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(weather.icon, size: 96, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(
'${weather.temperature.round()}°',
style: theme.textTheme.displayLarge,
),
Text(weather.description, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Feels like ${weather.feelsLike.round()}°',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_Detail(Icons.water_drop_outlined, '${weather.humidity}%'),
const SizedBox(width: 32),
_Detail(Icons.air, '${weather.windSpeed.round()} km/h'),
],
),
const SizedBox(height: 24),
Text(
'Updated at ${_twoDigits(weather.time.hour)}:'
'${_twoDigits(weather.time.minute)}',
style: theme.textTheme.bodySmall,
),
const _Via('Dart networking (Dio)'),
const SizedBox(height: 24),
const Divider(indent: 48, endIndent: 48),
const SizedBox(height: 16),
_AirQualityView(airQuality),
],
);
}
}
/// Fetched with the operating system's networking, which Dartmole only sees
/// once the device itself is set up for it.
class _AirQualityView extends StatelessWidget {
const _AirQualityView(this.airQuality);
final Future<AirQuality>? airQuality;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder<AirQuality>(
future: airQuality,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(:final data?) => _Detail(
Icons.masks_outlined,
'Air quality: ${data.label}',
),
AsyncSnapshot(:final error?) => Text(
'Could not load the air quality: $error',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
_ => const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
},
),
_Via('OS networking (${NativeHttp.stack})'),
],
);
}
}
/// Which networking fetched what is on screen above it.
class _Via extends StatelessWidget {
const _Via(this.stack);
final String stack;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Fetched with $stack',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}
String _twoDigits(int value) => value.toString().padLeft(2, '0');
class _Detail extends StatelessWidget {
const _Detail(this.icon, this.label);
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
children: [Icon(icon, size: 20), const SizedBox(width: 6), Text(label)],
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.error, required this.onRetry});
final Object error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 64),
const SizedBox(height: 16),
const Text('Could not load the weather.'),
const SizedBox(height: 8),
Text(
switch (error) {
// OpenWeather explains itself in the body, e.g. an invalid key.
DioException(
response: Response(data: {'message': final String m}),
) =>
m,
DioException(:final message?) => message,
_ => '$error',
},
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('Try again')),
],
);
}
}
class _MissingKeyView extends StatelessWidget {
const _MissingKeyView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.key_off, size: 64),
const SizedBox(height: 16),
Text('No OpenWeather API key', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
const Text(
'Run the example with '
'--dart-define=OPENWEATHER_API_KEY=<your key>, or set '
'OPENWEATHER_API_KEY in your environment before launching it from '
'VS Code.',
textAlign: TextAlign.center,
),
],
);
}
}