Menu
CoddyTech

날씨 앱: 도시 선택하기

Coddy Flutter 여정의 데이터와 네트워크 섹션에 포함된 레슨. 27개 중 27번째.

마지막 기능은 맨 위에 리스본, 도쿄, 오슬로 도시 버튼을 나란히 표시하는 것입니다. 버튼을 누르면 해당 도시의 날씨를 불러옵니다.

도시를 선택하면 경로를 바꿔 다시 불러옵니다. 선택한 city를 상태에 저장하고, loadWeather에 매개변수를 전달한 다음, 그 값으로 경로를 만드세요.

Future<Weather> loadWeather(String city) async {
  final body = await fetch('/weather/${city.toLowerCase()}');
  return Weather.fromJson(jsonDecode(body));
}

버튼에는 Tokyo라고 표시되지만, 서버 경로는 /weather/tokyo입니다. toLowerCase()는 전자를 후자로 바꿉니다. 대부분의 서버에서는 경로의 대문자와 소문자를 구분하므로 /weather/Tokyo는 Not found입니다.

버튼 행을 FutureBuilder 바깥에 두면 도시를 불러오는 동안에도 화면에 계속 표시됩니다. 맨 위에 버튼 행을 두고 그 아래 Expanded 안에 FutureBuilder를 넣은 Column을 사용하면 됩니다.

challenge icon

챌린지

어려움

도시 선택기를 추가하세요:

  • 상태에 String city = 'Lisbon';를 두고, loadWeather(String city)가 /weather/에 소문자로 변환한 도시 이름을 붙여 가져오도록 하세요
  • 본문 상단에 Lisbon, Tokyo, Oslo 세 개의 TextButton으로 이루어진 가운데 정렬된 Row를 배치하세요
  • 하나를 탭하면 setState에서 city와 새 _weather를 설정하세요
  • 행 아래의 Expanded 안에 FutureBuilder를 배치하고, 다시 시도를 누르면 선택한 도시의 날씨를 다시 불러오도록 하세요

직접 해보기

import 'dart:convert';

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

import 'api.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: WeatherScreen(),
    );
  }
}

class WeatherScreen extends StatefulWidget {
  const WeatherScreen({super.key});

  @override
  State<WeatherScreen> createState() => _WeatherScreenState();
}

class _WeatherScreenState extends State<WeatherScreen> {
  late Future<Weather> _weather;

  @override
  void initState() {
    super.initState();
    _weather = loadWeather();
  }

  Future<Weather> loadWeather() async {
    final body = await fetch('/weather/lisbon');
    return Weather.fromJson(jsonDecode(body));
  }

  void tryAgain() {
    setState(() {
      _weather = loadWeather();
    });
  }

  IconData iconFor(String condition) {
    return switch (condition) {
      'Sunny' => Icons.wb_sunny,
      'Cloudy' => Icons.cloud,
      'Rainy' => Icons.umbrella,
      'Snowy' => Icons.ac_unit,
      _ => Icons.help_outline,
    };
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Weather')),
      body: Center(
        child: FutureBuilder<Weather>(
          future: _weather,
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const CircularProgressIndicator();
            }
            if (snapshot.hasError) {
              return buildError(snapshot.error!);
            }
            return buildWeather(snapshot.data!);
          },
        ),
      ),
    );
  }

  Widget buildWeather(Weather weather) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text(weather.city, style: const TextStyle(fontSize: 32)),
        Icon(iconFor(weather.condition), size: 96, color: Colors.orange),
        Text(
          '${weather.temperature}°',
          style: const TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
        ),
        Text(weather.condition, style: const TextStyle(fontSize: 24)),
        const SizedBox(height: 32),
        for (final day in weather.forecast)
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 8),
            child: Row(
              children: [
                Icon(iconFor(day.condition)),
                const SizedBox(width: 16),
                Expanded(
                  child: Text(
                    DateFormat('EEEE').format(day.date),
                    style: const TextStyle(fontSize: 18),
                  ),
                ),
                Text('${day.high}° / ${day.low}°', style: const TextStyle(fontSize: 18)),
              ],
            ),
          ),
      ],
    );
  }

  Widget buildError(Object error) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const Icon(Icons.cloud_off, size: 64),
        const SizedBox(height: 16),
        const Text('Could not load the weather', style: TextStyle(fontSize: 20)),
        Text('$error'),
        const SizedBox(height: 16),
        ElevatedButton(onPressed: tryAgain, child: const Text('Try again')),
      ],
    );
  }
}

class Weather {
  Weather({
    required this.city,
    required this.temperature,
    required this.condition,
    required this.forecast,
  });

  final String city;
  final int temperature;
  final String condition;
  final List<DayForecast> forecast;

  factory Weather.fromJson(Map<String, dynamic> json) {
    final days = json['forecast'] as List<dynamic>;
    return Weather(
      city: json['city'] as String,
      temperature: json['temperature'] as int,
      condition: json['condition'] as String,
      forecast: days
          .map((day) => DayForecast.fromJson(day as Map<String, dynamic>))
          .toList(),
    );
  }
}

class DayForecast {
  DayForecast({
    required this.date,
    required this.condition,
    required this.high,
    required this.low,
  });

  final DateTime date;
  final String condition;
  final int high;
  final int low;

  factory DayForecast.fromJson(Map<String, dynamic> json) {
    return DayForecast(
      date: DateTime.parse(json['date'] as String),
      condition: json['condition'] as String,
      high: json['high'] as int,
      low: json['low'] as int,
    );
  }
}
quiz icon실력 점검

이 레슨에는 짧은 퀴즈가 포함되어 있습니다. 레슨을 시작해 문제를 풀고 진행 상황을 기록하세요.

데이터와 네트워크의 모든 레슨

직접 연습해 보세요: 온라인 Flutter 컴파일러