| 1 | import 'dart:math'; |
| 2 | |
| 3 | import 'package:flutter/material.dart'; |
| 4 | import 'package:gap/gap.dart'; |
| 5 | import 'package:material_symbols_icons/symbols.dart'; |
| 6 | import 'package:openapi/openapi.dart'; |
| 7 | import 'package:aero/constants/main.dart'; |
| 8 | |
| 9 | /// Bottom sheet for a tapped airport marker. Renders the basic info we already |
| 10 | /// have (name/ident/IATA/location) immediately, then fetches live detail |
| 11 | /// (delays + fully decoded METAR/TAF) from the backend in the background. |
| 12 | class AirportDetailsSheet extends StatefulWidget |
| 13 | final AirportEntity airport; |
| 14 | |
| 15 | const AirportDetailsSheet({super |
| 16 | |
| 17 | @override |
| 18 | State<AirportDetailsSheet> createState |
| 19 | } |
| 20 | |
| 21 | class _AirportDetailsSheetState extends State |
| 22 | final _airportsApi = openapi.getAirportsApi |
| 23 | |
| 24 | bool _loading = true; |
| 25 | bool _failed = false; |
| 26 | AirportDetailsEntity? _details; |
| 27 | |
| 28 | @override |
| 29 | void initState() { |
| 30 | super.initState(); |
| 31 | _loadDetails(); |
| 32 | } |
| 33 | |
| 34 | Future<void> _loadDetails |
| 35 | try { |
| 36 | final res = await _airportsApi. |
| 37 | ident: widget.airport.ident, |
| 38 | ); |
| 39 | if (!mounted) return; |
| 40 | setState(() { |
| 41 | _details = res.data; |
| 42 | _loading = false; |
| 43 | }); |
| 44 | } catch (_) { |
| 45 | if (!mounted) return; |
| 46 | setState(() { |
| 47 | _failed = true; |
| 48 | _loading = false; |
| 49 | }); |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // --------------------------------------------------------------------------- |
| 54 | // JsonObject helpers (decoded payloads are free-form JSON) |
| 55 | // --------------------------------------------------------------------------- |
| 56 | |
| 57 | Map<dynamic, dynamic |
| 58 | List<dynamic> _asList |
| 59 | |
| 60 | @override |
| 61 | Widget build(BuildContext |
| 62 | final size = MediaQuery. |
| 63 | return Container( |
| 64 | constraints: BoxConstraints(maxHeight: size. |
| 65 | child: SafeArea( |
| 66 | top: false, |
| 67 | child: SingleChildScrollView( |
| 68 | padding: const EdgeInsets. |
| 69 | child: Column( |
| 70 | mainAxisSize: MainAxisSize.min, |
| 71 | crossAxisAlignment: CrossAxisAlignment.start, |
| 72 | children: [ |
| 73 | Center( |
| 74 | child: Container( |
| 75 | width: 40, |
| 76 | height: 4, |
| 77 | decoration: BoxDecoration( |
| 78 | color: Colors.grey.shade300 |
| 79 | borderRadius: BorderRadius.circular( |
| 80 | ), |
| 81 | ), |
| 82 | ), |
| 83 | const Gap(14) |
| 84 | _buildHeader(context), |
| 85 | const Gap(10) |
| 86 | _buildAirportFacts(context), |
| 87 | const Gap(12) |
| 88 | if (_loading) |
| 89 | _buildLoading(context) |
| 90 | else if (_failed) |
| 91 | _buildUnavailable('Live information is unavailable right now.') |
| 92 | else |
| 93 | _buildDetails(context), |
| 94 | ], |
| 95 | ), |
| 96 | ), |
| 97 | ), |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | // --------------------------------------------------------------------------- |
| 102 | // Header + static airport facts (available immediately) |
| 103 | // --------------------------------------------------------------------------- |
| 104 | |
| 105 | Widget _buildHeader(BuildContext |
| 106 | final airport = widget.airport; |
| 107 | final iata = airport.iataCode; |
| 108 | final subtitle = [ |
| 109 | airport.ident, |
| 110 | if (iata != null && iata. |
| 111 | if (airport.municipality != null && |
| 112 | airport.municipality!, |
| 113 | ].join(' • '); |
| 114 | |
| 115 | return Row( |
| 116 | crossAxisAlignment: CrossAxisAlignment.start, |
| 117 | children: [ |
| 118 | Container( |
| 119 | width: 44, |
| 120 | height: 44, |
| 121 | decoration: BoxDecoration( |
| 122 | color: Colors.blueGrey.shade700 |
| 123 | borderRadius: BorderRadius.circular( |
| 124 | ), |
| 125 | child: const Icon( |
| 126 | ), |
| 127 | const Gap(12) |
| 128 | Expanded( |
| 129 | child: Column( |
| 130 | crossAxisAlignment: CrossAxisAlignment.start, |
| 131 | children: [ |
| 132 | Text( |
| 133 | airport.name, |
| 134 | style: const TextStyle( |
| 135 | fontSize: 18, |
| 136 | fontWeight: FontWeight.bold, |
| 137 | ), |
| 138 | ), |
| 139 | const Gap(2) |
| 140 | Text( |
| 141 | subtitle, |
| 142 | style: TextStyle(fontSize: 13 |
| 143 | ), |
| 144 | ], |
| 145 | ), |
| 146 | ), |
| 147 | _typeBadge(context, airport.type), |
| 148 | ], |
| 149 | ); |
| 150 | } |
| 151 | |
| 152 | Widget _typeBadge(BuildContext |
| 153 | final label = _formatEnum(type |
| 154 | return Container( |
| 155 | padding: const EdgeInsets. |
| 156 | decoration: BoxDecoration( |
| 157 | color: Theme.of(context) |
| 158 | borderRadius: BorderRadius.circular( |
| 159 | ), |
| 160 | child: Text( |
| 161 | label, |
| 162 | style: TextStyle( |
| 163 | fontSize: 11, |
| 164 | fontWeight: FontWeight.w700, |
| 165 | color: Theme.of(context) |
| 166 | ), |
| 167 | ), |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | Widget _buildAirportFacts(BuildContext |
| 172 | final a = widget.airport; |
| 173 | return Wrap( |
| 174 | spacing: 8, |
| 175 | runSpacing: 8, |
| 176 | children: [ |
| 177 | if (a.elevation != null && |
| 178 | _statChip(context, 'ELEV', |
| 179 | _statChip(context, 'COUNTRY', a |
| 180 | _statChip(context, 'REGION', a |
| 181 | _statChip(context, 'CONTINENT', a |
| 182 | if (a.gpsCode != null && |
| 183 | _statChip(context, 'GPS', a |
| 184 | _statChip( |
| 185 | context, |
| 186 | 'COORDS', |
| 187 | '${_fmtCoord(a. |
| 188 | ), |
| 189 | ], |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | // --------------------------------------------------------------------------- |
| 194 | // Live detail |
| 195 | // --------------------------------------------------------------------------- |
| 196 | |
| 197 | Widget _buildDetails(BuildContext |
| 198 | final details = _details; |
| 199 | if (details == null) { |
| 200 | return _buildUnavailable('Live information is unavailable right now.') |
| 201 | } |
| 202 | |
| 203 | final delays = _asMap(details |
| 204 | final metar = details.metar; |
| 205 | final taf = details.taf; |
| 206 | final metarDecoded = _asMap(metar |
| 207 | final tafDecoded = _asMap(taf |
| 208 | |
| 209 | final hasAnything = delays != null |
| 210 | (metar?.raw != null) || |
| 211 | (taf?.raw != null); |
| 212 | |
| 213 | if (!hasAnything) { |
| 214 | return _buildUnavailable( |
| 215 | 'No live weather or delay information is published for this airport.', |
| 216 | ); |
| 217 | } |
| 218 | |
| 219 | return Column( |
| 220 | crossAxisAlignment: CrossAxisAlignment.start, |
| 221 | children: [ |
| 222 | if (delays != null) ...[ |
| 223 | _sectionTitle('Delays'), |
| 224 | const Gap(4) |
| 225 | _buildDelayCard(context, delays), |
| 226 | const Gap(12) |
| 227 | ], |
| 228 | if (metar != null && metar. |
| 229 | _sectionTitle('METAR'), |
| 230 | const Gap(4) |
| 231 | _buildMetarCard(context, metar.raw! |
| 232 | const Gap(12) |
| 233 | ], |
| 234 | if (taf != null && taf. |
| 235 | _sectionTitle('TAF'), |
| 236 | const Gap(4) |
| 237 | _buildTafCard(context, taf.raw! |
| 238 | ], |
| 239 | ], |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | // ----- Delays ----- |
| 244 | |
| 245 | Widget _buildDelayCard(BuildContext |
| 246 | final theme = Theme. |
| 247 | final color = _delayColor(delays[ |
| 248 | final category = delays['category']? |
| 249 | final delaySecs = |
| 250 | (delays['delay_secs'] is num) ? |
| 251 | final reasons = _asList(delays[ |
| 252 | .map((r) => _asMap |
| 253 | .where((s) => s.isNotEmpty) |
| 254 | .toList(); |
| 255 | |
| 256 | return _card( |
| 257 | context, |
| 258 | child: Row( |
| 259 | crossAxisAlignment: CrossAxisAlignment.start, |
| 260 | children: [ |
| 261 | _iconTile(Symbols.timer, |
| 262 | const Gap(12) |
| 263 | Expanded( |
| 264 | child: Column( |
| 265 | crossAxisAlignment: CrossAxisAlignment.start, |
| 266 | children: [ |
| 267 | Row( |
| 268 | children: [ |
| 269 | Text( |
| 270 | category == 'none' |
| 271 | ? 'No delays reported' |
| 272 | : _formatEnum(category), |
| 273 | style: const TextStyle( |
| 274 | fontWeight: FontWeight.w700, |
| 275 | fontSize: 13, |
| 276 | ), |
| 277 | ), |
| 278 | const Spacer(), |
| 279 | _pill( |
| 280 | delaySecs > 0 |
| 281 | ? _fmtSeconds(delaySecs.toInt |
| 282 | : 'On time', |
| 283 | color, |
| 284 | ), |
| 285 | ], |
| 286 | ), |
| 287 | if (reasons.isNotEmpty) |
| 288 | Padding( |
| 289 | padding: const EdgeInsets. |
| 290 | child: Text( |
| 291 | reasons.join(' • '), |
| 292 | style: TextStyle( |
| 293 | fontSize: 12, |
| 294 | color: theme.textTheme.bodySmall?.color |
| 295 | ?.withValues(alpha: 0.75 |
| 296 | ), |
| 297 | ), |
| 298 | ), |
| 299 | ], |
| 300 | ), |
| 301 | ), |
| 302 | ], |
| 303 | ), |
| 304 | ); |
| 305 | } |
| 306 | |
| 307 | // ----- METAR ----- |
| 308 | |
| 309 | Widget _buildMetarCard( |
| 310 | BuildContext context, |
| 311 | String raw, |
| 312 | Map<dynamic, dynamic |
| 313 | ) { |
| 314 | final theme = Theme. |
| 315 | final flightCategory = d?['flightCategory' |
| 316 | final obs = _asMap(d |
| 317 | final temp = _numOrNull(d |
| 318 | final dew = _numOrNull(d |
| 319 | final altimeter = _asMap(d |
| 320 | final hPa = _numOrNull(altimeter |
| 321 | final inHg = _numOrNull(altimeter |
| 322 | |
| 323 | final flags = <String |
| 324 | if (d?['isCavok'] == true |
| 325 | if (d?['isNoSignificantChange'] == true |
| 326 | if (d?['isAutomated'] == true |
| 327 | if (d?['isCorrected'] == true |
| 328 | ]; |
| 329 | |
| 330 | final weather = _formatWeatherList( |
| 331 | final clouds = _formatSky( |
| 332 | |
| 333 | return _card( |
| 334 | context, |
| 335 | child: Column( |
| 336 | crossAxisAlignment: CrossAxisAlignment.start, |
| 337 | children: [ |
| 338 | Row( |
| 339 | children: [ |
| 340 | _iconTile(Symbols.partly_cloudy_day, |
| 341 | const Gap(10) |
| 342 | Expanded( |
| 343 | child: Text( |
| 344 | obs != null |
| 345 | ? 'Observed ${_fmtDhm(obs |
| 346 | : 'Current observation', |
| 347 | style: const TextStyle( |
| 348 | fontWeight: FontWeight.w700, |
| 349 | fontSize: 13, |
| 350 | ), |
| 351 | ), |
| 352 | ), |
| 353 | if (flightCategory != null) |
| 354 | _pill(flightCategory, _flightCatColor(flightCategory)) |
| 355 | ], |
| 356 | ), |
| 357 | const Gap(10) |
| 358 | Wrap( |
| 359 | spacing: 8, |
| 360 | runSpacing: 8, |
| 361 | children: [ |
| 362 | _statChip(context, 'WIND', |
| 363 | _statChip( |
| 364 | context, |
| 365 | 'VISIBILITY', |
| 366 | _formatVisibility(_asMap(d?[ |
| 367 | ), |
| 368 | if (temp != null) |
| 369 | _statChip(context, 'TEMP', |
| 370 | if (dew != null) |
| 371 | _statChip(context, 'DEWPOINT', |
| 372 | if (temp != null && dew != |
| 373 | _statChip(context, 'HUMIDITY', |
| 374 | if (hPa != null || inHg != |
| 375 | _statChip( |
| 376 | context, |
| 377 | 'QNH', |
| 378 | [ |
| 379 | if (hPa != null) '${_fmtNum |
| 380 | if (inHg != null) '${inHg |
| 381 | ].join(' '), |
| 382 | ), |
| 383 | ], |
| 384 | ), |
| 385 | if (weather != null) ...[ |
| 386 | const Gap(10) |
| 387 | _labelledLine(context, Symbols. |
| 388 | ], |
| 389 | if (clouds != null) ...[ |
| 390 | const Gap(10) |
| 391 | _labelledLine(context, Symbols. |
| 392 | ], |
| 393 | if (flags.isNotEmpty) ...[ |
| 394 | const Gap(10) |
| 395 | Wrap( |
| 396 | spacing: 6, |
| 397 | runSpacing: 6, |
| 398 | children: flags.map((f) => |
| 399 | ), |
| 400 | ], |
| 401 | const Gap(12) |
| 402 | _rawBox(context, raw), |
| 403 | ], |
| 404 | ), |
| 405 | ); |
| 406 | } |
| 407 | |
| 408 | // ----- TAF ----- |
| 409 | |
| 410 | Widget _buildTafCard( |
| 411 | BuildContext context, |
| 412 | String raw, |
| 413 | Map<dynamic, dynamic |
| 414 | ) { |
| 415 | final theme = Theme. |
| 416 | final issued = _asMap(d |
| 417 | final from = _asMap(d |
| 418 | final to = _asMap(d |
| 419 | final periods = _asList(d |
| 420 | |
| 421 | final headerBits = <String |
| 422 | if (issued != null) 'Issued ${_fmtDhm |
| 423 | if (from != null && to != |
| 424 | 'Valid ${_fmtDhm(from)} |
| 425 | ]; |
| 426 | |
| 427 | final amendFlags = <String |
| 428 | if (d?['isAmended'] == true |
| 429 | if (d?['isCorrected'] == true |
| 430 | if (d?['isCancelled'] == true |
| 431 | ]; |
| 432 | |
| 433 | return _card( |
| 434 | context, |
| 435 | child: Column( |
| 436 | crossAxisAlignment: CrossAxisAlignment.start, |
| 437 | children: [ |
| 438 | Row( |
| 439 | children: [ |
| 440 | _iconTile(Symbols.schedule, |
| 441 | const Gap(10) |
| 442 | Expanded( |
| 443 | child: Column( |
| 444 | crossAxisAlignment: CrossAxisAlignment.start, |
| 445 | children: [ |
| 446 | const Text( |
| 447 | 'Forecast', |
| 448 | style: TextStyle( |
| 449 | fontWeight: FontWeight.w700, |
| 450 | fontSize: 13, |
| 451 | ), |
| 452 | ), |
| 453 | if (headerBits.isNotEmpty) |
| 454 | Text( |
| 455 | headerBits.join(' • '), |
| 456 | style: TextStyle( |
| 457 | fontSize: 11, |
| 458 | color: theme.textTheme.bodySmall?.color |
| 459 | ?.withValues(alpha: 0.75 |
| 460 | ), |
| 461 | ), |
| 462 | ], |
| 463 | ), |
| 464 | ), |
| 465 | for (final f in amendFlags) ... |
| 466 | const Gap(4) |
| 467 | _pill(f, Colors. |
| 468 | ], |
| 469 | ], |
| 470 | ), |
| 471 | const Gap(10) |
| 472 | ...periods.asMap().entries |
| 473 | final isFirst = e.key == |
| 474 | return Padding( |
| 475 | padding: EdgeInsets.only(top |
| 476 | child: _buildTafPeriod(context, _asMap |
| 477 | ); |
| 478 | }), |
| 479 | const Gap(12) |
| 480 | _rawBox(context, raw), |
| 481 | ], |
| 482 | ), |
| 483 | ); |
| 484 | } |
| 485 | |
| 486 | Widget _buildTafPeriod( |
| 487 | BuildContext context, |
| 488 | Map<dynamic, dynamic |
| 489 | bool isBase, |
| 490 | ) { |
| 491 | if (p == null) return |
| 492 | final theme = Theme. |
| 493 | final changeType = p['changeType']? |
| 494 | final prob = _numOrNull(p[ |
| 495 | final start = _asMap(p[ |
| 496 | final end = _asMap(p[ |
| 497 | |
| 498 | final label = isBase |
| 499 | ? 'BASE' |
| 500 | : [ |
| 501 | if (prob != null) 'PROB${_fmtNum |
| 502 | changeType ?? 'FM', |
| 503 | ].join(' '); |
| 504 | final labelColor = _changeColor(isBase |
| 505 | |
| 506 | final timeRange = (start != null |
| 507 | ? '${_fmtDhm(start |
| 508 | : (start != null ? 'From |
| 509 | |
| 510 | final weather = _formatWeatherList( |
| 511 | final clouds = _formatSky( |
| 512 | final extraFlags = <String |
| 513 | if (p['isCavok'] == true) 'CAVOK' |
| 514 | if (p['isNoSignificantWeather'] == true) 'NSW' |
| 515 | ]; |
| 516 | |
| 517 | return Container( |
| 518 | padding: const EdgeInsets. |
| 519 | decoration: BoxDecoration( |
| 520 | color: theme.colorScheme.surfaceContainerHighest.withValues |
| 521 | borderRadius: BorderRadius.circular( |
| 522 | ), |
| 523 | child: Column( |
| 524 | crossAxisAlignment: CrossAxisAlignment.start, |
| 525 | children: [ |
| 526 | Row( |
| 527 | children: [ |
| 528 | _pill(label, labelColor), |
| 529 | const Gap(8) |
| 530 | if (timeRange != null) |
| 531 | Expanded( |
| 532 | child: Text( |
| 533 | timeRange, |
| 534 | style: TextStyle( |
| 535 | fontSize: 11, |
| 536 | fontWeight: FontWeight.w600, |
| 537 | color: theme.textTheme.bodySmall?.color |
| 538 | ?.withValues(alpha: 0.8 |
| 539 | ), |
| 540 | ), |
| 541 | ), |
| 542 | ], |
| 543 | ), |
| 544 | const Gap(8) |
| 545 | Wrap( |
| 546 | spacing: 8, |
| 547 | runSpacing: 8, |
| 548 | children: [ |
| 549 | _statChip(context, 'WIND', |
| 550 | _statChip( |
| 551 | context, |
| 552 | 'VISIBILITY', |
| 553 | _formatVisibility(_asMap(p['visibility'])) |
| 554 | ), |
| 555 | ], |
| 556 | ), |
| 557 | if (weather != null) ...[ |
| 558 | const Gap(8) |
| 559 | _labelledLine(context, Symbols. |
| 560 | ], |
| 561 | if (clouds != null) ...[ |
| 562 | const Gap(8) |
| 563 | _labelledLine(context, Symbols. |
| 564 | ], |
| 565 | if (extraFlags.isNotEmpty) ...[ |
| 566 | const Gap(8) |
| 567 | Wrap( |
| 568 | spacing: 6, |
| 569 | runSpacing: 6, |
| 570 | children: extraFlags.map((f) => |
| 571 | ), |
| 572 | ], |
| 573 | ], |
| 574 | ), |
| 575 | ); |
| 576 | } |
| 577 | |
| 578 | // --------------------------------------------------------------------------- |
| 579 | // Shared small widgets |
| 580 | // --------------------------------------------------------------------------- |
| 581 | |
| 582 | Widget _sectionTitle(String |
| 583 | padding: const EdgeInsets. |
| 584 | child: Text( |
| 585 | text, |
| 586 | style: const TextStyle(fontSize |
| 587 | ), |
| 588 | ); |
| 589 | |
| 590 | Widget _card(BuildContext |
| 591 | return Container( |
| 592 | width: double.infinity, |
| 593 | padding: const EdgeInsets. |
| 594 | decoration: BoxDecoration( |
| 595 | borderRadius: BorderRadius.circular( |
| 596 | border: Border.all( |
| 597 | color: Theme.of(context) |
| 598 | ), |
| 599 | ), |
| 600 | child: child, |
| 601 | ); |
| 602 | } |
| 603 | |
| 604 | Widget _iconTile(IconData |
| 605 | return Container( |
| 606 | width: 32, |
| 607 | height: 32, |
| 608 | decoration: BoxDecoration( |
| 609 | color: color.withValues(alpha: |
| 610 | borderRadius: BorderRadius.circular( |
| 611 | ), |
| 612 | child: Icon(icon, size: |
| 613 | ); |
| 614 | } |
| 615 | |
| 616 | Widget _statChip(BuildContext |
| 617 | final theme = Theme. |
| 618 | return Container( |
| 619 | padding: const EdgeInsets. |
| 620 | decoration: BoxDecoration( |
| 621 | color: theme.colorScheme.surfaceContainerHighest.withValues |
| 622 | borderRadius: BorderRadius.circular( |
| 623 | ), |
| 624 | child: Column( |
| 625 | crossAxisAlignment: CrossAxisAlignment.start, |
| 626 | mainAxisSize: MainAxisSize.min, |
| 627 | children: [ |
| 628 | Text( |
| 629 | label, |
| 630 | style: TextStyle( |
| 631 | fontSize: 9.5, |
| 632 | fontWeight: FontWeight.w700, |
| 633 | letterSpacing: 0.4, |
| 634 | color: theme.textTheme.bodySmall?.color |
| 635 | ), |
| 636 | ), |
| 637 | const Gap(1) |
| 638 | Text( |
| 639 | value, |
| 640 | style: const TextStyle(fontSize |
| 641 | ), |
| 642 | ], |
| 643 | ), |
| 644 | ); |
| 645 | } |
| 646 | |
| 647 | Widget _pill(String |
| 648 | return Container( |
| 649 | padding: const EdgeInsets. |
| 650 | decoration: BoxDecoration( |
| 651 | color: color.withValues(alpha: |
| 652 | borderRadius: BorderRadius.circular( |
| 653 | ), |
| 654 | child: Text( |
| 655 | text, |
| 656 | style: TextStyle( |
| 657 | color: color, |
| 658 | fontSize: 11, |
| 659 | fontWeight: FontWeight.w700, |
| 660 | ), |
| 661 | ), |
| 662 | ); |
| 663 | } |
| 664 | |
| 665 | Widget _flag(BuildContext |
| 666 | final theme = Theme. |
| 667 | return Container( |
| 668 | padding: const EdgeInsets. |
| 669 | decoration: BoxDecoration( |
| 670 | color: theme.colorScheme.surfaceContainerHighest.withValues |
| 671 | borderRadius: BorderRadius.circular( |
| 672 | ), |
| 673 | child: Text( |
| 674 | text, |
| 675 | style: TextStyle( |
| 676 | fontSize: 10, |
| 677 | fontWeight: FontWeight.w700, |
| 678 | letterSpacing: 0.3, |
| 679 | color: theme.textTheme.bodySmall?.color |
| 680 | ), |
| 681 | ), |
| 682 | ); |
| 683 | } |
| 684 | |
| 685 | Widget _labelledLine( |
| 686 | BuildContext context, |
| 687 | IconData icon, |
| 688 | String label, |
| 689 | String value, |
| 690 | ) { |
| 691 | final theme = Theme. |
| 692 | return Row( |
| 693 | crossAxisAlignment: CrossAxisAlignment.start, |
| 694 | children: [ |
| 695 | Icon(icon, size: 16 |
| 696 | const Gap(8) |
| 697 | Expanded( |
| 698 | child: RichText( |
| 699 | text: TextSpan( |
| 700 | style: TextStyle( |
| 701 | fontSize: 12.5, |
| 702 | color: theme.textTheme.bodyMedium?.color |
| 703 | ), |
| 704 | children: [ |
| 705 | TextSpan( |
| 706 | text: '$label: ', |
| 707 | style: const TextStyle(fontWeight |
| 708 | ), |
| 709 | TextSpan(text: value), |
| 710 | ], |
| 711 | ), |
| 712 | ), |
| 713 | ), |
| 714 | ], |
| 715 | ); |
| 716 | } |
| 717 | |
| 718 | Widget _rawBox(BuildContext |
| 719 | final theme = Theme. |
| 720 | return Container( |
| 721 | width: double.infinity, |
| 722 | padding: const EdgeInsets. |
| 723 | decoration: BoxDecoration( |
| 724 | color: theme.colorScheme.surfaceContainerHighest.withValues |
| 725 | borderRadius: BorderRadius.circular( |
| 726 | ), |
| 727 | child: SelectableText( |
| 728 | raw, |
| 729 | style: const TextStyle( |
| 730 | fontFamily: 'monospace', |
| 731 | fontSize: 12, |
| 732 | height: 1.45, |
| 733 | ), |
| 734 | ), |
| 735 | ); |
| 736 | } |
| 737 | |
| 738 | Widget _buildUnavailable(String |
| 739 | return Padding( |
| 740 | padding: const EdgeInsets. |
| 741 | child: Row( |
| 742 | children: [ |
| 743 | Icon(Symbols.cloud_off, |
| 744 | const Gap(8) |
| 745 | Expanded( |
| 746 | child: Text( |
| 747 | message, |
| 748 | style: TextStyle(color: Colors |
| 749 | ), |
| 750 | ), |
| 751 | ], |
| 752 | ), |
| 753 | ); |
| 754 | } |
| 755 | |
| 756 | Widget _buildLoading(BuildContext |
| 757 | return Column( |
| 758 | crossAxisAlignment: CrossAxisAlignment.start, |
| 759 | children: List.generate( |
| 760 | return Container( |
| 761 | margin: const EdgeInsets. |
| 762 | height: 120, |
| 763 | decoration: BoxDecoration( |
| 764 | color: Theme.of(context) |
| 765 | .colorScheme |
| 766 | .surfaceContainerHighest |
| 767 | .withValues(alpha: 0.4) |
| 768 | borderRadius: BorderRadius.circular( |
| 769 | ), |
| 770 | ); |
| 771 | }), |
| 772 | ); |
| 773 | } |
| 774 | |
| 775 | // --------------------------------------------------------------------------- |
| 776 | // Formatting helpers |
| 777 | // --------------------------------------------------------------------------- |
| 778 | |
| 779 | num? _numOrNull(Object |
| 780 | |
| 781 | String _fmtNum(num |
| 782 | n == n.roundToDouble() ? n |
| 783 | |
| 784 | String _fmtCoord(String |
| 785 | final d = double. |
| 786 | return d == null ? |
| 787 | } |
| 788 | |
| 789 | String _fmtDhm(Map |
| 790 | final h = _numOrNull(t[ |
| 791 | final m = _numOrNull(t[ |
| 792 | return '${h. |
| 793 | } |
| 794 | |
| 795 | String _fmtSeconds(int |
| 796 | final mins = (seconds / 60 |
| 797 | if (mins < 60) return |
| 798 | final h = mins ~/ |
| 799 | final r = mins % 60 |
| 800 | return r == 0 ? |
| 801 | } |
| 802 | |
| 803 | int _relHumidity(num |
| 804 | final num1 = exp(( |
| 805 | final num2 = exp(( |
| 806 | return (100 * (num1 / |
| 807 | } |
| 808 | |
| 809 | String _formatWind(Map |
| 810 | if (wind == null) return |
| 811 | if (wind['isCalm'] == true) return |
| 812 | final speed = _numOrNull(wind[ |
| 813 | final gust = _numOrNull(wind[ |
| 814 | final dir = _numOrNull(wind[ |
| 815 | final variable = wind['isVariable'] == |
| 816 | |
| 817 | final dirStr = variable |
| 818 | ? 'VRB' |
| 819 | : (dir != null ? ' |
| 820 | final spdStr = speed != null |
| 821 | final gustStr = gust != null |
| 822 | return '$dirStr |
| 823 | } |
| 824 | |
| 825 | String _cardinal(num |
| 826 | const dirs = [ |
| 827 | 'N', 'NNE', 'NE', |
| 828 | 'S', 'SSW', 'SW', |
| 829 | ]; |
| 830 | return dirs[((deg % 360) / |
| 831 | } |
| 832 | |
| 833 | String _formatVisibility(Map |
| 834 | if (vis == null) return |
| 835 | final m = _numOrNull(vis[ |
| 836 | final sm = _numOrNull(vis[ |
| 837 | final prefix = vis['isLessThan'] == |
| 838 | ? '<' |
| 839 | : (vis['isMoreThan'] == true ? |
| 840 | if (m != null) { |
| 841 | final smCalc = (m / 1609.344 |
| 842 | final smStr = smCalc >= 1 |
| 843 | ? smCalc.toStringAsFixed(1) |
| 844 | : smCalc.toStringAsFixed(2) |
| 845 | return '$prefix${ |
| 846 | } |
| 847 | if (sm != null) return |
| 848 | return '—'; |
| 849 | } |
| 850 | |
| 851 | String? _formatWeatherList(List |
| 852 | if (list.isEmpty) return null |
| 853 | final parts = list |
| 854 | .map((w) => _formatWeather |
| 855 | .where((s) => s != |
| 856 | .cast<String>() |
| 857 | .toList(); |
| 858 | return parts.isEmpty ? null |
| 859 | } |
| 860 | |
| 861 | String? _formatWeather(Map |
| 862 | if (w == null) return |
| 863 | final intensity = w['intensity']? |
| 864 | final isVicinity = w['isVicinity'] == |
| 865 | final descriptor = w['descriptor']? |
| 866 | final phenomena = _asList(w[ |
| 867 | |
| 868 | final bits = <String |
| 869 | if (intensity == 'LIGHT') bits.add |
| 870 | if (intensity == 'HEAVY') bits.add |
| 871 | if (isVicinity) bits.add('Nearby') |
| 872 | if (descriptor != null) bits.add |
| 873 | bits.addAll(phenomena.map(_wxCode)) |
| 874 | final out = bits.where |
| 875 | return out.isEmpty ? (w['raw' |
| 876 | } |
| 877 | |
| 878 | String? _formatSky(Map |
| 879 | if (sky == null) return |
| 880 | final layers = _asList(sky[ |
| 881 | if (layers.isEmpty) { |
| 882 | if (sky['verticalVisibilityFt'] != null) { |
| 883 | return 'Vertical visibility ${_fmtNum( |
| 884 | } |
| 885 | return null; |
| 886 | } |
| 887 | return layers |
| 888 | .map((l) => _formatLayer |
| 889 | .where((s) => s != |
| 890 | .cast<String>() |
| 891 | .join(', '); |
| 892 | } |
| 893 | |
| 894 | String? _formatLayer(Map |
| 895 | if (l == null) return |
| 896 | final coverage = _coverageName(l[ |
| 897 | final alt = _numOrNull(l[ |
| 898 | final type = l['type']? |
| 899 | final typeStr = (type != null |
| 900 | ? ' ${_cloudType(type |
| 901 | : ''; |
| 902 | if (alt == null) return |
| 903 | return '$coverage @ |
| 904 | } |
| 905 | |
| 906 | // ----- code dictionaries ----- |
| 907 | |
| 908 | String _coverageName(String |
| 909 | switch (c) { |
| 910 | case 'FEW': |
| 911 | return 'Few'; |
| 912 | case 'SCT': |
| 913 | return 'Scattered'; |
| 914 | case 'BKN': |
| 915 | return 'Broken'; |
| 916 | case 'OVC': |
| 917 | return 'Overcast'; |
| 918 | case 'NSC': |
| 919 | return 'No significant cloud'; |
| 920 | case 'NCD': |
| 921 | return 'No cloud detected'; |
| 922 | case 'SKC': |
| 923 | case 'CLR': |
| 924 | return 'Clear'; |
| 925 | case 'VV': |
| 926 | return 'Vertical vis'; |
| 927 | default: |
| 928 | return c ?? '—'; |
| 929 | } |
| 930 | } |
| 931 | |
| 932 | String _cloudType(String |
| 933 | switch (t.toUpperCase()) { |
| 934 | case 'CB': |
| 935 | return '(Cumulonimbus)'; |
| 936 | case 'TCU': |
| 937 | return '(Towering cumulus)'; |
| 938 | default: |
| 939 | return '($t)' |
| 940 | } |
| 941 | } |
| 942 | |
| 943 | String _wxCode(String |
| 944 | const map = { |
| 945 | 'MI': 'Shallow', 'PR': |
| 946 | 'BL': 'Blowing', 'SH': |
| 947 | 'DZ': 'Drizzle', 'RA': |
| 948 | 'IC': 'Ice crystals', 'PL': |
| 949 | 'GS': 'Small hail', 'UP': |
| 950 | 'FU': 'Smoke', 'VA': |
| 951 | 'HZ': 'Haze', 'PY': |
| 952 | 'FC': 'Funnel cloud', 'SS': |
| 953 | }; |
| 954 | return map[code.toUpperCase()] ?? |
| 955 | } |
| 956 | |
| 957 | Color _delayColor(String |
| 958 | switch (c) { |
| 959 | case 'red': |
| 960 | return Colors.red; |
| 961 | case 'yellow': |
| 962 | return Colors.orange; |
| 963 | case 'green': |
| 964 | return Colors.green; |
| 965 | default: |
| 966 | return Colors.grey; |
| 967 | } |
| 968 | } |
| 969 | |
| 970 | Color _flightCatColor(String |
| 971 | switch (category.toUpperCase()) { |
| 972 | case 'VFR': |
| 973 | return Colors.green; |
| 974 | case 'MVFR': |
| 975 | return Colors.blue; |
| 976 | case 'IFR': |
| 977 | return Colors.red; |
| 978 | case 'LIFR': |
| 979 | return Colors.purple; |
| 980 | default: |
| 981 | return Colors.grey; |
| 982 | } |
| 983 | } |
| 984 | |
| 985 | Color _changeColor(String |
| 986 | switch (type.toUpperCase()) { |
| 987 | case 'BASE': |
| 988 | return Colors.blueGrey; |
| 989 | case 'BECMG': |
| 990 | return Colors.indigo; |
| 991 | case 'TEMPO': |
| 992 | return Colors.orange; |
| 993 | case 'FM': |
| 994 | return Colors.teal; |
| 995 | default: |
| 996 | return Colors.blueGrey; |
| 997 | } |
| 998 | } |
| 999 | |
| 1000 | String _formatEnum(String |
| 1001 | return name |
| 1002 | .split(RegExp(r'[_\s]' |
| 1003 | .where((w) => w.isNotEmpty) |
| 1004 | .map((w) => '${ |
| 1005 | .join(' '); |
| 1006 | } |
| 1007 | } |