Recipe 1 — Flutter Weather
시나리오
BLoC 공식 튜토리얼 Flutter Weather를 Flutist로 구현합니다. 사용자가 도시를 검색하면 Open Meteo API에서 날씨 데이터를 가져오고, Celsius/Fahrenheit 전환 및 HydratedBloc으로 세션 간 상태를 유지합니다. Clean Architecture를 적용해 도메인 비즈니스 로직을 HTTP 클라이언트와 UI로부터 완전히 격리합니다.
1
프로젝트 초기화 및 패키지 등록
TERMINAL
$ mkdir flutter_weather && cd flutter_weather
$ flutist init
▶ Do you want to create a new Flutter project? (y/n): y
$ flutist pub add equatable bloc flutter_bloc hydrated_bloc json_annotation http google_fonts
2
weather 피처 모듈 생성
--options clean으로 domain · data · presentation 3개 레이어를 한 번에 생성합니다.
TERMINAL
$ flutist create --name weather --path features --options clean
✓ features/weather/weather_domain 생성
✓ features/weather/weather_data 생성
✓ features/weather/weather_presentation 생성
✓ project.dart 업데이트
✓ flutist_gen.dart 재생성
3
project.dart 의존성 선언
project.dart
final project = Project(
name: 'flutter_weather',
options: const ProjectOptions(compositionRoots: ['app']),
modules: [
// ── Domain: 순수 비즈니스 로직, 외부 의존 없음 ─────────
Module(
name: 'weather_domain',
dependencies: [package.dependencies.equatable],
devDependencies: [package.dependencies.test],
),
// ── Data: Open Meteo API 클라이언트 ────────────────────
Module(
name: 'weather_data',
dependencies: [
package.dependencies.http,
package.dependencies.jsonAnnotation,
],
devDependencies: [
package.dependencies.test,
package.dependencies.mocktail,
],
modules: [package.modules.weatherDomain],
),
// ── Presentation: WeatherCubit, ThemeCubit ─────────────
Module(
name: 'weather_presentation',
dependencies: [
package.dependencies.flutterBloc,
package.dependencies.hydratedBloc,
package.dependencies.googleFonts,
],
devDependencies: [
package.dependencies.blocTest,
package.dependencies.mocktail,
],
modules: [package.modules.weatherDomain], // domain만 의존
),
// ── App: composition root — data 구현체 주입 ───────────
Module(
name: 'app',
modules: [
package.modules.weatherPresentation,
package.modules.weatherData, // ✅ composition root만 구현체 참조 가능
],
),
],
);
4
Domain 레이어 — 모델 & Repository interface
weather_domain은 http·Flutter SDK를 import하지 않습니다. 순수 Dart로만 작성되며, data와 presentation이 따를 계약을 정의합니다.
features/weather/weather_domain/lib/src/weather.dart
import 'package:equatable/equatable.dart';
enum TemperatureUnits { fahrenheit, celsius }
class Temperature extends Equatable {
const Temperature({required this.value});
final double value;
@override
List<Object> get props => [value];
}
class Weather extends Equatable {
const Weather({
required this.condition,
required this.location,
required this.temperature,
required this.lastUpdated,
});
final WeatherCondition condition;
final String location;
final Temperature temperature;
final DateTime lastUpdated;
@override
List<Object> get props => [condition, location, temperature, lastUpdated];
}
enum WeatherCondition { clear, rainy, cloudy, snowy, unknown }
features/weather/weather_domain/lib/src/weather_repository.dart
import 'weather.dart';
abstract interface class WeatherRepository {
/// 도시명으로 날씨 조회. Data 레이어가 구현한다.
Future<Weather> getWeather(String city);
}
5
Data 레이어 — Open Meteo API 구현
weather_data는 Open Meteo Geocoding API와 Forecast API를 순서대로 호출합니다. WeatherRepository interface를 구현합니다.
features/weather/weather_data/lib/src/weather_repository_impl.dart
import 'package:http/http.dart' as http;
import 'package:weather_domain/weather_domain.dart';
import 'dart:convert';
class WeatherRepositoryImpl implements WeatherRepository {
const WeatherRepositoryImpl({http.Client? httpClient})
: _httpClient = httpClient ?? const http.Client();
final http.Client _httpClient;
@override
Future<Weather> getWeather(String city) async {
// 1. Geocoding: 도시명 → 위경도
final geoRes = await _httpClient.get(
Uri.https('geocoding-api.open-meteo.com', '/v1/search', {'name': city, 'count': '1'}),
);
final geoJson = jsonDecode(geoRes.body) as Map<String, dynamic>;
final results = geoJson['results'] as List?;
if (results == null || results.isEmpty) throw Exception('City not found');
final location = results[0];
final lat = location['latitude'] as double;
final lng = location['longitude'] as double;
// 2. Forecast: 위경도 → 현재 날씨
final wRes = await _httpClient.get(
Uri.https('api.open-meteo.com', '/v1/forecast', {
'latitude': '$lat', 'longitude': '$lng',
'current_weather': 'true',
}),
);
final wJson = jsonDecode(wRes.body) as Map<String, dynamic>;
final current = wJson['current_weather'] as Map<String, dynamic>;
return Weather(
location: location['name'] as String,
temperature: Temperature(value: (current['temperature'] as num).toDouble()),
condition: _codeToCondition(current['weathercode'] as int),
lastUpdated: DateTime.now(),
);
}
WeatherCondition _codeToCondition(int code) {
if (code == 0) return WeatherCondition.clear;
if (code <= 3) return WeatherCondition.cloudy;
if (code <= 67) return WeatherCondition.rainy;
if (code <= 77) return WeatherCondition.snowy;
return WeatherCondition.unknown;
}
}
6
Presentation 레이어 — WeatherCubit
weather_presentation은 WeatherRepository interface에만 의존합니다. WeatherRepositoryImpl이 무엇인지 모릅니다. HydratedCubit으로 세션 간 상태를 유지합니다.
features/weather/weather_presentation/lib/src/weather_cubit.dart
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:weather_domain/weather_domain.dart';
part 'weather_state.dart';
class WeatherCubit extends HydratedCubit<WeatherState> {
WeatherCubit(this._weatherRepository) : super(WeatherState());
final WeatherRepository _weatherRepository; // interface만 알고 있음
Future<void> fetchWeather(String city) async {
if (city.isEmpty) return;
emit(state.copyWith(status: WeatherStatus.loading));
try {
final weather = await _weatherRepository.getWeather(city);
emit(state.copyWith(status: WeatherStatus.success, weather: weather));
} catch (_) {
emit(state.copyWith(status: WeatherStatus.failure));
}
}
void toggleUnits() {
final units = state.temperatureUnits == TemperatureUnits.fahrenheit
? TemperatureUnits.celsius
: TemperatureUnits.fahrenheit;
emit(state.copyWith(temperatureUnits: units));
}
@override
WeatherState fromJson(Map<String, dynamic> json) =>
WeatherState.fromJson(json);
@override
Map<String, dynamic> toJson(WeatherState state) => state.toJson();
}
7
App — Composition Root에서 주입
app만 WeatherRepositoryImpl을 알고 있습니다. Presentation은 interface만 바라봅니다.
app/lib/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'package:weather_data/weather_data.dart'; // ✅ 구현체
import 'package:weather_presentation/weather_presentation.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return RepositoryProvider(
create: (_) => WeatherRepositoryImpl(), // 구현체 주입
child: BlocProvider(
create: (ctx) => WeatherCubit(ctx.read<WeatherRepositoryImpl>()),
child: const WeatherAppView(),
),
);
}
}
8
전체 동기화 및 아키텍처 검증
TERMINAL
$ flutist generate
✓ [Clean] weather_data → weather_domain: OK (data → domain)
✓ [Clean] weather_presentation → weather_domain: OK (presentation → domain)
✗ weather_domain → weather_data: 역방향 의존성 — generate 중단!
→ weather_domain에서 http import를 제거하세요
$ flutist generate # 수정 후
✓ 모든 아키텍처 규칙 통과
✓ pubspec.yaml 4개 동기화 완료
최종 구조
flutter_weather/
app/ # Composition Root
lib/app.dart # WeatherRepositoryImpl 주입
features/
weather/
weather_domain/ # Weather, Temperature, WeatherCondition
lib/src/weather.dart # TemperatureUnits enum
lib/src/weather_repository.dart # abstract interface
weather_data/ # Open Meteo API 구현
lib/src/weather_repository_impl.dart
weather_presentation/ # WeatherCubit, ThemeCubit, WeatherPage
lib/src/weather_cubit.dart
lib/src/weather_state.dart
lib/src/weather_page.dart
flutist/
flutist_gen.dart
# 의존성 방향:
# weather_data → weather_domain ✅ (data → domain)
# weather_presentation → weather_domain ✅ (presentation → domain)
# app → weather_data + weather_presentation ✅ (composition root)
# weather_domain → (아무것도 없음) ✅ 핵심 원칙