s_metar 1.0.2 copy "s_metar: ^1.0.2" to clipboard
s_metar: ^1.0.2 copied to clipboard

Parse/decode METAR/TAF aeronautical weather reports (wind, visibility, weather, clouds...). Fetch live/past METAR/TAF data from aviationweather.gov API.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:s_metar/s_metar.dart';

void main() => runApp(const SMetarExampleApp());

// ---------------------------------------------------------------------------
// Sample reports used throughout the demo
// ---------------------------------------------------------------------------

/// A real-world-style METAR used as the default example.
const _defaultMetar =
    'METAR EGLL 181220Z 25015G28KT 220V280 9999 FEW020 BKN080 15/08 Q1012 NOSIG';

/// A US-format METAR with statute-mile visibility and altimeter in inHg.
const _usMetar = 'METAR KJFK 181220Z 27010KT 10SM FEW020 BKN100 18/10 A2992';

/// A wintry METAR with compound precipitation (RASN) and low visibility.
const _winterMetar =
    'METAR EGLL 181220Z 05010KT 2500 -RASN BKN008 OVC015 03/01 Q0992';

/// A CAVOK METAR with calm wind.
const _cavokMetar = 'METAR LFPG 181220Z 00000KT CAVOK 22/12 Q1025';

/// A TAF covering several change periods.
const _defaultTaf = 'TAF EGLL 181100Z 1812/1918 25015KT 9999 FEW025 '
    'TEMPO 1812/1816 5000 RASN BKN012 '
    'BECMG 1901/1903 30008KT '
    'PROB40 TEMPO 1906/1910 3000 DZ BKN006';

// ---------------------------------------------------------------------------
// App root
// ---------------------------------------------------------------------------

/// The example application for the `s_metar` package.
class SMetarExampleApp extends StatelessWidget {
  /// Creates the app.
  const SMetarExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 's_metar Example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1565C0)),
        useMaterial3: true,
      ),
      home: const _HomePage(),
    );
  }
}

// ---------------------------------------------------------------------------
// Home page — tab switcher between METAR and TAF demos
// ---------------------------------------------------------------------------

class _HomePage extends StatefulWidget {
  const _HomePage();

  @override
  State<_HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<_HomePage>
    with SingleTickerProviderStateMixin {
  late final TabController _tabs;

  @override
  void initState() {
    super.initState();
    _tabs = TabController(length: 3, vsync: this);
  }

  @override
  void dispose() {
    _tabs.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('s_metar Interactive Demo'),
        backgroundColor: Theme.of(context).colorScheme.primary,
        foregroundColor: Colors.white,
        bottom: TabBar(
          controller: _tabs,
          labelColor: Colors.white,
          unselectedLabelColor: Colors.white70,
          indicatorColor: Colors.white,
          tabs: const [
            Tab(icon: Icon(Icons.cloud), text: 'METAR'),
            Tab(icon: Icon(Icons.air), text: 'TAF'),
            Tab(icon: Icon(Icons.download), text: 'Live Fetch'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabs,
        children: const [_MetarPage(), _TafPage(), _LiveFetchPage()],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------

/// Section header inside a card.
class _SectionHeader extends StatelessWidget {
  final String title;
  final IconData icon;
  final Color? color;

  const _SectionHeader(
    this.title, {
    this.icon = Icons.info_outline,
    this.color,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Row(
        children: [
          Icon(
            icon,
            size: 18,
            color: color ?? Theme.of(context).colorScheme.primary,
          ),
          const SizedBox(width: 6),
          Text(
            title,
            style: TextStyle(
              fontWeight: FontWeight.bold,
              fontSize: 14,
              color: color ?? Theme.of(context).colorScheme.primary,
            ),
          ),
        ],
      ),
    );
  }
}

/// Key/value row.
Widget _kv(String key, String? value, {Color? valueColor}) {
  return Padding(
    padding: const EdgeInsets.symmetric(vertical: 2),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SizedBox(
          width: 200,
          child: Text(
            '$key:',
            style: const TextStyle(fontSize: 12, color: Colors.black54),
          ),
        ),
        Expanded(
          child: Text(
            value ?? '—',
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w500,
              color: valueColor,
            ),
          ),
        ),
      ],
    ),
  );
}

String _fmt(double? v, {int decimals = 2, String unit = ''}) {
  if (v == null) return '—';
  return '${v.toStringAsFixed(decimals)}$unit';
}

Widget _divider() => const Divider(height: 16, thickness: 0.5);

/// Expandable card section.
class _ExpandableCard extends StatefulWidget {
  final String title;
  final IconData icon;
  final Widget child;
  final bool initiallyExpanded;

  const _ExpandableCard({
    required this.title,
    required this.icon,
    required this.child,
    this.initiallyExpanded = true,
  });

  @override
  State<_ExpandableCard> createState() => _ExpandableCardState();
}

class _ExpandableCardState extends State<_ExpandableCard> {
  late bool _expanded;

  @override
  void initState() {
    super.initState();
    _expanded = widget.initiallyExpanded;
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
      child: InkWell(
        onTap: () => setState(() => _expanded = !_expanded),
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  Icon(
                    widget.icon,
                    size: 20,
                    color: Theme.of(context).colorScheme.primary,
                  ),
                  const SizedBox(width: 8),
                  Text(
                    widget.title,
                    style: const TextStyle(
                      fontWeight: FontWeight.bold,
                      fontSize: 15,
                    ),
                  ),
                  const Spacer(),
                  Icon(_expanded ? Icons.expand_less : Icons.expand_more),
                ],
              ),
              if (_expanded) ...[_divider(), widget.child],
            ],
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// METAR page
// ---------------------------------------------------------------------------

class _MetarPage extends StatefulWidget {
  const _MetarPage();

  @override
  State<_MetarPage> createState() => _MetarPageState();
}

class _MetarPageState extends State<_MetarPage> {
  /// Currently selected sample code.
  String _selectedCode = _defaultMetar;

  /// Custom text entered by the user (when editing).
  late final TextEditingController _controller;

  /// Whether the user is typing a custom METAR.
  bool _isCustom = false;

  Metar? _metar;
  String? _parseError;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController(text: _selectedCode);
    _parse(_selectedCode);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _parse(String code) {
    try {
      final m = Metar(code, year: 2026, month: 2);
      setState(() {
        _metar = m;
        _parseError = null;
      });
    } catch (e) {
      setState(() {
        _metar = null;
        _parseError = e.toString();
      });
    }
  }

  void _selectPreset(String code) {
    setState(() {
      _selectedCode = code;
      _isCustom = false;
      _controller.text = code;
    });
    _parse(code);
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        // ── Input section ──────────────────────────────────────────────────
        Padding(
          padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text(
                'Sample METARs',
                style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
              ),
              const SizedBox(height: 6),
              Wrap(
                spacing: 8,
                runSpacing: 4,
                children: [
                  _PresetChip(
                    label: 'EGLL standard',
                    selected: _selectedCode == _defaultMetar && !_isCustom,
                    onTap: () => _selectPreset(_defaultMetar),
                  ),
                  _PresetChip(
                    label: 'KJFK (US)',
                    selected: _selectedCode == _usMetar && !_isCustom,
                    onTap: () => _selectPreset(_usMetar),
                  ),
                  _PresetChip(
                    label: 'Winter / RASN',
                    selected: _selectedCode == _winterMetar && !_isCustom,
                    onTap: () => _selectPreset(_winterMetar),
                  ),
                  _PresetChip(
                    label: 'LFPG CAVOK',
                    selected: _selectedCode == _cavokMetar && !_isCustom,
                    onTap: () => _selectPreset(_cavokMetar),
                  ),
                ],
              ),
              const SizedBox(height: 10),
              TextField(
                controller: _controller,
                maxLines: 2,
                style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
                decoration: InputDecoration(
                  labelText: 'METAR code',
                  border: const OutlineInputBorder(),
                  suffixIcon: IconButton(
                    icon: const Icon(Icons.play_arrow),
                    tooltip: 'Parse',
                    onPressed: () {
                      setState(() => _isCustom = true);
                      _parse(_controller.text.trim());
                    },
                  ),
                ),
                onSubmitted: (v) {
                  setState(() => _isCustom = true);
                  _parse(v.trim());
                },
              ),
            ],
          ),
        ),

        // ── Error banner ───────────────────────────────────────────────────
        if (_parseError != null)
          Container(
            margin: const EdgeInsets.all(12),
            padding: const EdgeInsets.all(10),
            decoration: BoxDecoration(
              color: Colors.red.shade50,
              border: Border.all(color: Colors.red.shade200),
              borderRadius: BorderRadius.circular(8),
            ),
            child: Row(
              children: [
                const Icon(Icons.error_outline, color: Colors.red),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    _parseError!,
                    style: const TextStyle(color: Colors.red, fontSize: 12),
                  ),
                ),
              ],
            ),
          ),

        // ── Parsed data sections ───────────────────────────────────────────
        if (_metar != null) ...[
          _MetarOverviewCard(metar: _metar!),
          _MetarWindCard(metar: _metar!),
          _MetarVisibilityCard(metar: _metar!),
          _MetarWeatherCard(metar: _metar!),
          _MetarCloudsCard(metar: _metar!),
          _MetarTemperaturesCard(metar: _metar!),
          _MetarPressureCard(metar: _metar!),
          if (_metar!.runwayRanges.length > 0) _MetarRvrCard(metar: _metar!),
          if (_metar!.recentWeather.code != null)
            _MetarRecentWeatherCard(metar: _metar!),
          if (_metar!.windshears.length > 0)
            _MetarWindshearCard(metar: _metar!),
          if (_metar!.seaState.code != null) _MetarSeaStateCard(metar: _metar!),
          if (_metar!.weatherTrends.length > 0)
            _MetarWeatherTrendsCard(metar: _metar!),
          const SizedBox(height: 24),
        ],
      ],
    );
  }
}

/// Small selectable chip for preset METARs.
class _PresetChip extends StatelessWidget {
  final String label;
  final bool selected;
  final VoidCallback onTap;

  const _PresetChip({
    required this.label,
    required this.selected,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return FilterChip(
      label: Text(label, style: const TextStyle(fontSize: 12)),
      selected: selected,
      onSelected: (_) => onTap(),
    );
  }
}

// ---------------------------------------------------------------------------
// METAR detail cards
// ---------------------------------------------------------------------------

/// Overview — station, time, type, flight rules, CAVOK.
class _MetarOverviewCard extends StatelessWidget {
  final Metar metar;

  const _MetarOverviewCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    final flightColor = metar.flightRules == 'VFR'
        ? Colors.green.shade700
        : metar.flightRules == 'MVFR'
            ? Colors.blue.shade700
            : metar.flightRules == 'IFR'
                ? Colors.red.shade700
                : Colors.purple.shade700;

    return _ExpandableCard(
      title: 'Overview',
      icon: Icons.summarize,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Station', metar.station.code),
          _kv('Time (UTC)', metar.time.toString()),
          _kv('Type', metar.type_.code),
          _kv('Modifier', metar.modifier.code),
          _kv('Flight rules', metar.flightRules, valueColor: flightColor),
          _kv('Should be CAVOK', metar.shouldBeCavok().toString()),
          if (metar.remark.isNotEmpty) _kv('Remark', metar.remark),
        ],
      ),
    );
  }
}

/// Wind card — direction, speed, gusts, Beaufort, isCalm, variation.
class _MetarWindCard extends StatelessWidget {
  final Metar metar;

  const _MetarWindCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    final wind = metar.wind;

    return _ExpandableCard(
      title: 'Wind',
      icon: Icons.air,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Raw code', wind.code),
          _kv(
            'Direction',
            '${_fmt(wind.directionInDegrees, decimals: 0)}°'
                ' (${wind.cardinalDirection ?? "—"})',
          ),
          _kv('Variable', wind.variable.toString()),
          _kv('Is calm', wind.isCalm.toString()),
          _divider(),
          _SectionHeader('Speed'),
          _kv('Knots', _fmt(wind.speedInKnot, decimals: 1, unit: ' kt')),
          _kv('m/s', _fmt(wind.speedInMps, decimals: 1, unit: ' m/s')),
          _kv('km/h', _fmt(wind.speedInKph, decimals: 1, unit: ' km/h')),
          _kv('mph', _fmt(wind.speedInMiph, decimals: 1, unit: ' mph')),
          _divider(),
          _SectionHeader('Gust'),
          _kv('Gusts (kt)', _fmt(wind.gustInKnot, decimals: 1, unit: ' kt')),
          _kv('Gusts (km/h)', _fmt(wind.gustInKph, decimals: 1, unit: ' km/h')),
          if (metar.windVariation.code != null) ...[
            _divider(),
            _SectionHeader('Wind variation'),
            _kv('From', '${metar.windVariation.fromInDegrees}°'),
            _kv('To', '${metar.windVariation.toInDegrees}°'),
          ],
        ],
      ),
    );
  }
}

/// Visibility card.
class _MetarVisibilityCard extends StatelessWidget {
  final Metar metar;

  const _MetarVisibilityCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    final vis = metar.prevailingVisibility;

    return _ExpandableCard(
      title: 'Visibility',
      icon: Icons.visibility,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Raw code', vis.code),
          _kv('CAVOK', vis.cavok.toString()),
          _kv(
            'Is maximum (≥10 km)',
            (vis.inMeters != null && vis.inMeters! >= 10000).toString(),
          ),
          _kv('Meters', _fmt(vis.inMeters, decimals: 0, unit: ' m')),
          _kv('Kilometers', _fmt(vis.inKilometers, decimals: 2, unit: ' km')),
          _kv('Sea miles', _fmt(vis.inSeaMiles, decimals: 2, unit: ' NM')),
          _kv('Feet', _fmt(vis.inFeet, decimals: 0, unit: ' ft')),
          if (metar.minimumVisibility.code != null) ...[
            _divider(),
            _SectionHeader('Minimum visibility'),
            _kv(
              'Minimum (m)',
              _fmt(metar.minimumVisibility.inMeters, decimals: 0, unit: ' m'),
            ),
          ],
        ],
      ),
    );
  }
}

/// Weather phenomena card.
class _MetarWeatherCard extends StatelessWidget {
  final Metar metar;

  const _MetarWeatherCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    if (metar.weathers.length == 0) {
      return _ExpandableCard(
        title: 'Weather',
        icon: Icons.thunderstorm,
        initiallyExpanded: false,
        child: const Text(
          'No significant weather reported.',
          style: TextStyle(fontSize: 12, color: Colors.black54),
        ),
      );
    }

    return _ExpandableCard(
      title: 'Weather (${metar.weathers.length} group(s))',
      icon: Icons.thunderstorm,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (final (i, w) in metar.weathers.items.indexed) ...[
            if (i > 0) _divider(),
            _kv('Raw code', w.code),
            _kv('Intensity', w.intensity),
            _kv('Description', w.description),
            _kv('Precipitation', w.precipitation),
            _kv(
              'Prec. codes (ICAO)',
              w.precipitationCodes.isEmpty
                  ? null
                  : w.precipitationCodes.join(', '),
            ),
            _kv('Obscuration', w.obscuration),
            _kv('Other', w.other),
          ],
        ],
      ),
    );
  }
}

/// Cloud layers card.
class _MetarCloudsCard extends StatelessWidget {
  final Metar metar;

  const _MetarCloudsCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    if (metar.clouds.length == 0) {
      return _ExpandableCard(
        title: 'Clouds',
        icon: Icons.cloud_outlined,
        initiallyExpanded: false,
        child: const Text(
          'No cloud layers reported.',
          style: TextStyle(fontSize: 12, color: Colors.black54),
        ),
      );
    }

    return _ExpandableCard(
      title: 'Clouds (${metar.clouds.length} layer(s))',
      icon: Icons.cloud,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Ceiling (≤1500 ft BKN/OVC)', metar.clouds.ceiling.toString()),
          for (final (i, c) in metar.clouds.items.indexed) ...[
            _divider(),
            _SectionHeader('Layer ${i + 1}'),
            _kv('Raw code', c.code),
            _kv('Cover code (ICAO)', c.coverCode),
            _kv('Cover', c.cover),
            _kv('Oktas', c.oktas),
            _kv('Height (ft)', _fmt(c.heightInFeet, decimals: 0, unit: ' ft')),
            _kv('Height (m)', _fmt(c.heightInMeters, decimals: 0, unit: ' m')),
            _kv('Cloud type code', c.cloudTypeCode),
            _kv('Cloud type', c.cloudType),
          ],
        ],
      ),
    );
  }
}

/// Temperatures card — includes derived quantities.
class _MetarTemperaturesCard extends StatelessWidget {
  final Metar metar;

  const _MetarTemperaturesCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    final t = metar.temperatures;

    return _ExpandableCard(
      title: 'Temperatures',
      icon: Icons.thermostat,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _SectionHeader('Temperature (OAT)'),
          _kv('°C', _fmt(t.temperatureInCelsius, decimals: 1, unit: ' °C')),
          _kv('°F', _fmt(t.temperatureInFahrenheit, decimals: 1, unit: ' °F')),
          _kv('K', _fmt(t.temperatureInKelvin, decimals: 2, unit: ' K')),
          _divider(),
          _SectionHeader('Dewpoint'),
          _kv('°C', _fmt(t.dewpointInCelsius, decimals: 1, unit: ' °C')),
          _kv('°F', _fmt(t.dewpointInFahrenheit, decimals: 1, unit: ' °F')),
          _divider(),
          _SectionHeader('Derived quantities', icon: Icons.calculate),
          _kv(
            'Dewpoint spread',
            _fmt(t.dewpointSpread, decimals: 1, unit: ' °C') +
                (t.dewpointSpread != null && t.dewpointSpread! < 3.0
                    ? ' ⚠ fog risk'
                    : ''),
          ),
          _kv(
            'Relative humidity',
            _fmt(t.relativeHumidity, decimals: 1, unit: ' %'),
          ),
          _kv(
            'Heat index',
            t.heatIndex != null
                ? _fmt(t.heatIndex, decimals: 1, unit: ' °C')
                : '— (not applicable)',
          ),
          _kv(
            'Wind chill',
            t.windChill(metar.wind.speedInKph) != null
                ? _fmt(
                    t.windChill(metar.wind.speedInKph),
                    decimals: 1,
                    unit: ' °C',
                  )
                : '— (not applicable)',
          ),
        ],
      ),
    );
  }
}

/// Pressure card.
class _MetarPressureCard extends StatelessWidget {
  final Metar metar;

  const _MetarPressureCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    final p = metar.pressure;

    return _ExpandableCard(
      title: 'Pressure',
      icon: Icons.compress,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Raw code', p.code),
          _kv('hPa', _fmt(p.inHPa, decimals: 1, unit: ' hPa')),
          _kv('inHg', _fmt(p.inInHg, decimals: 2, unit: ' inHg')),
          _kv('mbar', _fmt(p.inMbar, decimals: 1, unit: ' mbar')),
          _kv('Pa', _fmt(p.inPa, decimals: 0, unit: ' Pa')),
          _kv('kPa', _fmt(p.inKPa, decimals: 2, unit: ' kPa')),
          _kv('bar', _fmt(p.inBar, decimals: 4, unit: ' bar')),
          _kv('atm', _fmt(p.inAtm, decimals: 4, unit: ' atm')),
        ],
      ),
    );
  }
}

/// RVR card.
class _MetarRvrCard extends StatelessWidget {
  final Metar metar;

  const _MetarRvrCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    return _ExpandableCard(
      title: 'Runway Visual Range (${metar.runwayRanges.length})',
      icon: Icons.flight_land,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (final (i, r) in metar.runwayRanges.items.indexed) ...[
            if (i > 0) _divider(),
            _kv('Runway', r.name),
            _kv('Low (m)', _fmt(r.lowInMeters, decimals: 0, unit: ' m')),
            if (r.highInMeters != null)
              _kv('High (m)', _fmt(r.highInMeters, decimals: 0, unit: ' m')),
            _kv('Trend', r.trend),
          ],
        ],
      ),
    );
  }
}

/// Recent weather card.
class _MetarRecentWeatherCard extends StatelessWidget {
  final Metar metar;

  const _MetarRecentWeatherCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    final rw = metar.recentWeather;

    return _ExpandableCard(
      title: 'Recent Weather',
      icon: Icons.history,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Raw code', rw.code),
          _kv('Description', rw.description),
          _kv('Precipitation', rw.precipitation),
          _kv('Obscuration', rw.obscuration),
          _kv('Other', rw.other),
        ],
      ),
    );
  }
}

/// Windshear card.
class _MetarWindshearCard extends StatelessWidget {
  final Metar metar;

  const _MetarWindshearCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    return _ExpandableCard(
      title: 'Windshear (${metar.windshears.length})',
      icon: Icons.swap_vert,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (final (i, ws) in metar.windshears.items.indexed) ...[
            if (i > 0) _divider(),
            _kv('Raw code', ws.code),
            _kv('Runway', ws.name),
          ],
        ],
      ),
    );
  }
}

/// Sea state card.
class _MetarSeaStateCard extends StatelessWidget {
  final Metar metar;

  const _MetarSeaStateCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    final ss = metar.seaState;

    return _ExpandableCard(
      title: 'Sea State',
      icon: Icons.waves,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Raw code', ss.code),
          _kv(
            'Sea temperature (°C)',
            _fmt(ss.temperatureInCelsius, decimals: 1, unit: ' °C'),
          ),
          _kv('State', ss.state),
          _kv('Wave height (m)',
              _fmt(ss.heightInMeters, decimals: 1, unit: ' m')),
        ],
      ),
    );
  }
}

/// Weather trends (TEMPO / BECMG) card.
class _MetarWeatherTrendsCard extends StatelessWidget {
  final Metar metar;

  const _MetarWeatherTrendsCard({required this.metar});

  @override
  Widget build(BuildContext context) {
    return _ExpandableCard(
      title: 'Weather Trends (${metar.weatherTrends.length})',
      icon: Icons.trending_up,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (final (i, wt) in metar.weatherTrends.items.indexed) ...[
            if (i > 0) _divider(),
            _kv('Change indicator', wt.trendIndicator.code),
            if (wt.wind.code != null) _kv('Wind', wt.wind.toString()),
            if (wt.prevailingVisibility.code != null)
              _kv(
                'Visibility (m)',
                _fmt(wt.prevailingVisibility.inMeters, decimals: 0, unit: ' m'),
              ),
            if (wt.weathers.length > 0)
              _kv(
                'Weather',
                wt.weathers.items.map((w) => w.toString()).join(', '),
              ),
            if (wt.clouds.length > 0)
              _kv(
                'Clouds',
                wt.clouds.items.map((c) => c.toString()).join(', '),
              ),
          ],
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// TAF page
// ---------------------------------------------------------------------------

class _TafPage extends StatefulWidget {
  const _TafPage();

  @override
  State<_TafPage> createState() => _TafPageState();
}

class _TafPageState extends State<_TafPage> {
  late final TextEditingController _controller;
  Taf? _taf;
  String? _parseError;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController(text: _defaultTaf);
    _parse(_defaultTaf);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _parse(String code) {
    try {
      final t = Taf(code, year: 2026, month: 2);
      setState(() {
        _taf = t;
        _parseError = null;
      });
    } catch (e) {
      setState(() {
        _taf = null;
        _parseError = e.toString();
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        // ── Input ──────────────────────────────────────────────────────────
        Padding(
          padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
          child: TextField(
            controller: _controller,
            maxLines: 4,
            style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
            decoration: InputDecoration(
              labelText: 'TAF code',
              border: const OutlineInputBorder(),
              suffixIcon: IconButton(
                icon: const Icon(Icons.play_arrow),
                tooltip: 'Parse',
                onPressed: () => _parse(_controller.text.trim()),
              ),
            ),
            onSubmitted: (v) => _parse(v.trim()),
          ),
        ),

        // ── Error ──────────────────────────────────────────────────────────
        if (_parseError != null)
          Container(
            margin: const EdgeInsets.all(12),
            padding: const EdgeInsets.all(10),
            decoration: BoxDecoration(
              color: Colors.red.shade50,
              border: Border.all(color: Colors.red.shade200),
              borderRadius: BorderRadius.circular(8),
            ),
            child: Row(
              children: [
                const Icon(Icons.error_outline, color: Colors.red),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    _parseError!,
                    style: const TextStyle(color: Colors.red, fontSize: 12),
                  ),
                ),
              ],
            ),
          ),

        // ── Parsed TAF sections ─────────────────────────────────────────────
        if (_taf != null) ...[
          _TafOverviewCard(taf: _taf!),
          _TafBaseConditionsCard(taf: _taf!),
          if (_taf!.maxTemperatures.length > 0 ||
              _taf!.minTemperatures.length > 0)
            _TafTemperaturesCard(taf: _taf!),
          if (_taf!.changesForecasted.length > 0) _TafChangesCard(taf: _taf!),
          const SizedBox(height: 24),
        ],
      ],
    );
  }
}

/// TAF overview.
class _TafOverviewCard extends StatelessWidget {
  final Taf taf;

  const _TafOverviewCard({required this.taf});

  @override
  Widget build(BuildContext context) {
    return _ExpandableCard(
      title: 'TAF Overview',
      icon: Icons.summarize,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Station', taf.station.code),
          _kv('Issue time (UTC)', taf.time.toString()),
          _kv('Valid from', taf.valid.periodFrom.toString()),
          _kv('Valid until', taf.valid.periodUntil.toString()),
          _kv('Modifier', taf.modifier.code),
          if (taf.missing.code != null) _kv('Missing', taf.missing.code),
          if (taf.cancelled.code != null) _kv('Cancelled', taf.cancelled.code),
        ],
      ),
    );
  }
}

/// TAF base conditions — wind, visibility, weather, clouds.
class _TafBaseConditionsCard extends StatelessWidget {
  final Taf taf;

  const _TafBaseConditionsCard({required this.taf});

  @override
  Widget build(BuildContext context) {
    return _ExpandableCard(
      title: 'Base Conditions',
      icon: Icons.wb_sunny_outlined,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _kv('Flight rules', taf.flightRules),
          _divider(),
          _SectionHeader('Wind', icon: Icons.air),
          _kv(
            'Direction',
            '${_fmt(taf.wind.directionInDegrees, decimals: 0)}°'
                ' (${taf.wind.cardinalDirection ?? "—"})',
          ),
          _kv(
            'Speed (kt)',
            _fmt(taf.wind.speedInKnot, decimals: 1, unit: ' kt'),
          ),
          _kv('Gust (kt)', _fmt(taf.wind.gustInKnot, decimals: 1, unit: ' kt')),
          _divider(),
          _SectionHeader('Visibility', icon: Icons.visibility),
          _kv('CAVOK', taf.prevailingVisibility.cavok.toString()),
          _kv(
            'Visibility (m)',
            _fmt(taf.prevailingVisibility.inMeters, decimals: 0, unit: ' m'),
          ),
          _kv(
            'Is maximum',
            (taf.prevailingVisibility.inMeters != null &&
                    taf.prevailingVisibility.inMeters! >= 10000)
                .toString(),
          ),
          if (taf.weathers.length > 0) ...[
            _divider(),
            _SectionHeader('Weather', icon: Icons.thunderstorm),
            for (final w in taf.weathers.items) _kv('', w.toString()),
          ],
          if (taf.clouds.length > 0) ...[
            _divider(),
            _SectionHeader('Clouds', icon: Icons.cloud),
            for (final c in taf.clouds.items)
              _kv(c.coverCode ?? '', c.toString()),
          ],
        ],
      ),
    );
  }
}

/// TAF temperature forecasts.
class _TafTemperaturesCard extends StatelessWidget {
  final Taf taf;

  const _TafTemperaturesCard({required this.taf});

  @override
  Widget build(BuildContext context) {
    return _ExpandableCard(
      title: 'Temperature Forecasts',
      icon: Icons.thermostat,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          if (taf.maxTemperatures.length > 0) ...[
            _SectionHeader(
              'Maximum temperatures',
              icon: Icons.arrow_upward,
              color: Colors.red,
            ),
            for (final t in taf.maxTemperatures.items)
              _kv(
                t.time.toString(),
                _fmt(t.inCelsius, decimals: 1, unit: ' °C'),
              ),
            _divider(),
          ],
          if (taf.minTemperatures.length > 0) ...[
            _SectionHeader(
              'Minimum temperatures',
              icon: Icons.arrow_downward,
              color: Colors.blue,
            ),
            for (final t in taf.minTemperatures.items)
              _kv(
                t.time.toString(),
                _fmt(t.inCelsius, decimals: 1, unit: ' °C'),
              ),
          ],
        ],
      ),
    );
  }
}

/// TAF change periods.
class _TafChangesCard extends StatelessWidget {
  final Taf taf;

  const _TafChangesCard({required this.taf});

  @override
  Widget build(BuildContext context) {
    final changes = taf.changesForecasted.items;

    return _ExpandableCard(
      title: 'Change Periods (${changes.length})',
      icon: Icons.compare_arrows,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (final (i, cp) in changes.indexed) ...[
            if (i > 0) _divider(),
            Row(
              children: [
                Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 8,
                    vertical: 3,
                  ),
                  decoration: BoxDecoration(
                    color: _changeColor(cp.changeIndicator.code),
                    borderRadius: BorderRadius.circular(4),
                  ),
                  child: Text(
                    cp.changeIndicator.code ?? '?',
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 11,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    cp.changeIndicator.translation ?? '',
                    style: const TextStyle(fontSize: 12),
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 6),
            if (cp.wind.code != null)
              _kv(
                'Wind',
                '${_fmt(cp.wind.directionInDegrees, decimals: 0)}° '
                    '${_fmt(cp.wind.speedInKnot, decimals: 0)} kt',
              ),
            if (cp.prevailingVisibility.code != null)
              _kv(
                'Visibility',
                _fmt(cp.prevailingVisibility.inMeters, decimals: 0, unit: ' m'),
              ),
            if (cp.weathers.length > 0)
              _kv(
                'Weather',
                cp.weathers.items.map((w) => w.toString()).join(', '),
              ),
            if (cp.clouds.length > 0)
              _kv(
                'Clouds',
                cp.clouds.items.map((c) => c.toString()).join(', '),
              ),
            if (cp.flightRules != null) _kv('Flight rules', cp.flightRules),
          ],
        ],
      ),
    );
  }

  Color _changeColor(String? code) {
    if (code == null) return Colors.grey;
    if (code.startsWith('TEMPO')) return Colors.orange.shade700;
    if (code.startsWith('BECMG')) return Colors.blue.shade700;
    if (code.startsWith('FM')) return Colors.green.shade700;
    if (code.startsWith('PROB')) return Colors.purple.shade700;
    return Colors.grey.shade700;
  }
}

// ---------------------------------------------------------------------------
// Live Fetch page
// ---------------------------------------------------------------------------

class _LiveFetchPage extends StatefulWidget {
  const _LiveFetchPage();

  @override
  State<_LiveFetchPage> createState() => _LiveFetchPageState();
}

class _LiveFetchPageState extends State<_LiveFetchPage> {
  final TextEditingController _icaoController = TextEditingController(
    text: 'EGLL',
  );
  DateTime _selectedDateTime = DateTime.now().toUtc();
  bool _isLoading = false;

  MetarTafResult? _result;
  String? _fetchError;

  @override
  void dispose() {
    _icaoController.dispose();
    super.dispose();
  }

  Future<void> _pickDateTime() async {
    final date = await showDatePicker(
      context: context,
      initialDate: _selectedDateTime,
      firstDate: DateTime.utc(2020),
      lastDate: DateTime.now().toUtc().add(const Duration(days: 1)),
    );
    if (date == null || !mounted) return;

    final time = await showTimePicker(
      context: context,
      initialTime: TimeOfDay.fromDateTime(_selectedDateTime),
    );
    if (!mounted) return;

    setState(() {
      _selectedDateTime = DateTime.utc(
        date.year,
        date.month,
        date.day,
        time?.hour ?? _selectedDateTime.hour,
        time?.minute ?? _selectedDateTime.minute,
      );
    });
  }

  Future<void> _fetch() async {
    final icao = _icaoController.text.trim();
    if (icao.isEmpty) return;

    setState(() {
      _isLoading = true;
      _result = null;
      _fetchError = null;
    });

    final result = await MetarTafFetcher.fetch(
      icao: icao,
      dateTime: _selectedDateTime,
    );

    if (!mounted) return;

    setState(() {
      _isLoading = false;
      if (result.isSuccess) {
        _result = result;
        _fetchError = null;
        // debugPrint(
        //     'Fetch SUCCESS: ${result.error}, code: ${result.metar}, taf: ${result.taf}, raw METAR: ${result.rawMetar}, raw TAF: ${result.rawTaf}');
      } else {
        // debugPrint(
        //     'Fetch ERROR: ${result.error}, code: ${result.metar}, taf: ${result.taf}, raw METAR: ${result.rawMetar}, raw TAF: ${result.rawTaf}');
        _result = result;
        _fetchError = result.error ?? 'Unknown error';
      }
    });
  }

  String _formatDateTime(DateTime dt) {
    final y = dt.year.toString();
    final mo = dt.month.toString().padLeft(2, '0');
    final d = dt.day.toString().padLeft(2, '0');
    final h = dt.hour.toString().padLeft(2, '0');
    final mi = dt.minute.toString().padLeft(2, '0');
    return '$y-$mo-$d  $h:${mi}Z';
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        // ── Controls ───────────────────────────────────────────────────────
        Padding(
          padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text(
                'Fetch live METAR & TAF',
                style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
              ),
              const SizedBox(height: 10),
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _icaoController,
                      textCapitalization: TextCapitalization.characters,
                      maxLength: 4,
                      style: const TextStyle(
                        fontFamily: 'monospace',
                        fontSize: 14,
                        fontWeight: FontWeight.bold,
                        letterSpacing: 2,
                      ),
                      decoration: const InputDecoration(
                        labelText: 'ICAO code',
                        hintText: 'e.g. EGLL',
                        border: OutlineInputBorder(),
                        counterText: '',
                      ),
                    ),
                  ),
                  const SizedBox(width: 10),
                  OutlinedButton.icon(
                    icon: const Icon(Icons.calendar_today, size: 16),
                    label: Text(
                      _formatDateTime(_selectedDateTime),
                      style: const TextStyle(fontSize: 12),
                    ),
                    onPressed: _pickDateTime,
                  ),
                ],
              ),
              const SizedBox(height: 10),
              SizedBox(
                width: double.infinity,
                child: FilledButton.icon(
                  icon: _isLoading
                      ? const SizedBox(
                          width: 16,
                          height: 16,
                          child: CircularProgressIndicator(
                            strokeWidth: 2,
                            color: Colors.white,
                          ),
                        )
                      : const Icon(Icons.download),
                  label: Text(_isLoading ? 'Fetching…' : 'Fetch'),
                  onPressed: _isLoading ? null : _fetch,
                ),
              ),
            ],
          ),
        ),

        // ── Error banner ───────────────────────────────────────────────────
        if (_fetchError != null)
          Container(
            margin: const EdgeInsets.all(12),
            padding: const EdgeInsets.all(10),
            decoration: BoxDecoration(
              color: Colors.red.shade50,
              border: Border.all(color: Colors.red.shade200),
              borderRadius: BorderRadius.circular(8),
            ),
            child: Row(
              children: [
                const Icon(Icons.error_outline, color: Colors.red),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    _fetchError!,
                    style: const TextStyle(color: Colors.red, fontSize: 12),
                  ),
                ),
              ],
            ),
          ),

        // ── Raw strings ────────────────────────────────────────────────────
        if (_result != null && _result!.isSuccess) ...[
          if (_result!.rawMetar != null)
            _ExpandableCard(
              title: 'Raw METAR',
              icon: Icons.code,
              child: Text(
                _result!.rawMetar!,
                style: const TextStyle(
                  fontFamily: 'monospace',
                  fontSize: 11,
                ),
              ),
            ),
          if (_result!.rawTaf != null)
            _ExpandableCard(
              title: 'Raw TAF',
              icon: Icons.code,
              initiallyExpanded: false,
              child: Text(
                _result!.rawTaf!,
                style: const TextStyle(
                  fontFamily: 'monospace',
                  fontSize: 11,
                ),
              ),
            ),
        ],

        // ── Parsed METAR sections ──────────────────────────────────────────
        if (_result?.metar != null) ...[
          Padding(
            padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
            child: Text(
              'Parsed METAR',
              style: TextStyle(
                fontWeight: FontWeight.bold,
                fontSize: 13,
                color: Theme.of(context).colorScheme.primary,
              ),
            ),
          ),
          _MetarOverviewCard(metar: _result!.metar!),
          _MetarWindCard(metar: _result!.metar!),
          _MetarVisibilityCard(metar: _result!.metar!),
          _MetarWeatherCard(metar: _result!.metar!),
          _MetarCloudsCard(metar: _result!.metar!),
          _MetarTemperaturesCard(metar: _result!.metar!),
          _MetarPressureCard(metar: _result!.metar!),
          if (_result!.metar!.runwayRanges.length > 0)
            _MetarRvrCard(metar: _result!.metar!),
          if (_result!.metar!.recentWeather.code != null)
            _MetarRecentWeatherCard(metar: _result!.metar!),
          if (_result!.metar!.windshears.length > 0)
            _MetarWindshearCard(metar: _result!.metar!),
          if (_result!.metar!.seaState.code != null)
            _MetarSeaStateCard(metar: _result!.metar!),
          if (_result!.metar!.weatherTrends.length > 0)
            _MetarWeatherTrendsCard(metar: _result!.metar!),
        ],

        // ── Parsed TAF sections ────────────────────────────────────────────
        if (_result?.taf != null) ...[
          Padding(
            padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
            child: Text(
              'Parsed TAF',
              style: TextStyle(
                fontWeight: FontWeight.bold,
                fontSize: 13,
                color: Theme.of(context).colorScheme.primary,
              ),
            ),
          ),
          _TafOverviewCard(taf: _result!.taf!),
          _TafBaseConditionsCard(taf: _result!.taf!),
          if (_result!.taf!.maxTemperatures.length > 0 ||
              _result!.taf!.minTemperatures.length > 0)
            _TafTemperaturesCard(taf: _result!.taf!),
          if (_result!.taf!.changesForecasted.length > 0)
            _TafChangesCard(taf: _result!.taf!),
        ],

        const SizedBox(height: 24),
      ],
    );
  }
}
0
likes
150
points
79
downloads

Publisher

unverified uploader

Weekly Downloads

Parse/decode METAR/TAF aeronautical weather reports (wind, visibility, weather, clouds...). Fetch live/past METAR/TAF data from aviationweather.gov API.

Repository (GitHub)
View/report issues

Topics

#aviation #weather #metar #taf #meteorology

Documentation

API reference

License

MIT (license)

Dependencies

flutter, s_packages

More

Packages that depend on s_metar