dartmole_plugin 0.1.0 copy "dartmole_plugin: ^0.1.0" to clipboard
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,
        ),
      ],
    );
  }
}
0
likes
150
points
--
downloads

Documentation

API reference

Publisher

verified publisherskystoneapps.com

Routes a Flutter app's HTTP through Dartmole, so it can be inspected and mocked.

Homepage

Topics

#http #network #debugging #proxy #mock

License

MIT (license)

Dependencies

flutter, mobile_scanner, url_launcher

More

Packages that depend on dartmole_plugin

Packages that implement dartmole_plugin