feat: Enhance TV support and Daily Weather Forecast (w/ Air Quality)

- **Frontend (Flutter)**
    - Optimize for Google TV: Force Landscape mode, disable touchscreen requirement, and set TV Dashboard as default home.
    - Weather Widget:
        - Add Daily Max/Min temperature ('최고/최저').
        - Add Air Quality Index (AQI) with visual indicator ('미세먼지').
        - Increase font sizes and adjust layout to match Digital Clock style.
        - Implement 1-hour auto-refresh timer.
    - Model: Update WeatherInfo to support tempMin, tempMax, and aqi.

- **Backend (Node.js)**
    - Weather API (/api/weather):
        - Implement daily forecast aggregation logic (Seoul Time KST) to calculate accurate daily High/Low.
        - Integrate OpenWeatherMap Air Pollution API to fetch AQI.
This commit is contained in:
kihong.kim
2026-01-31 21:19:13 +09:00
parent 6fb00fec5d
commit e2f00c6e21
6 changed files with 217 additions and 33 deletions

View File

@@ -25,9 +25,56 @@ router.get("/", async (req, res) => {
params.q = q || city; params.q = q || city;
} }
const response = await axios.get(`${baseUrl}/weather`, { params }); // 1. Fetch Current Weather (to get lat/lon and timezone)
res.json(response.data); const currentResponse = await axios.get(`${baseUrl}/weather`, { params });
const currentData = currentResponse.data;
const { coord } = currentData;
// 2. Fetch Forecast (for daily min/max) & Air Quality
const [forecastResponse, airResponse] = await Promise.all([
axios.get(`${baseUrl}/forecast`, {
params: { ...params, lat: coord.lat, lon: coord.lon },
}),
axios.get(`${baseUrl}/air_pollution`, {
params: { lat: coord.lat, lon: coord.lon, appid: apiKey },
}),
]);
// 3. Process Forecast for Today's Min/Max (Seoul Time: UTC+9)
// OpenWeatherMap returns timestamps in UTC.
// Seoul is UTC+9.
const SeoulOffset = 9 * 60 * 60 * 1000;
const nowKST = new Date(Date.now() + SeoulOffset);
const todayStr = nowKST.toISOString().split("T")[0]; // YYYY-MM-DD in KST
const todayItems = forecastResponse.data.list.filter((item) => {
const itemDateKST = new Date(item.dt * 1000 + SeoulOffset);
const itemDateStr = itemDateKST.toISOString().split("T")[0];
return itemDateStr === todayStr;
});
if (todayItems.length > 0) {
// Find min and max from the 3-hour segments
const minTemp = Math.min(...todayItems.map((item) => item.main.temp_min));
const maxTemp = Math.max(...todayItems.map((item) => item.main.temp_max));
// Update the response structure
currentData.main.temp_min = minTemp;
currentData.main.temp_max = maxTemp;
}
// 4. Attach Air Quality (AQI)
// OpenWeatherMap AQI: 1 (Good) ... 5 (Very Poor)
if (airResponse.data.list && airResponse.data.list.length > 0) {
currentData.aqi = airResponse.data.list[0].main.aqi;
}
res.json(currentData);
} catch (error) { } catch (error) {
console.error("Weather API Error:", error.message);
if (error.response) {
console.error("Data:", error.response.data);
}
res.status(500).json({ message: "Failed to fetch weather" }); res.status(500).json({ message: "Failed to fetch weather" });
} }
}); });

View File

@@ -3,6 +3,9 @@
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application <application
android:label="google_tv_dashboard" android:label="google_tv_dashboard"
android:name="${applicationName}" android:name="${applicationName}"
@@ -16,6 +19,7 @@
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user

View File

@@ -22,6 +22,10 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Hide status bar for TV immersive experience // Hide status bar for TV immersive experience
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
GoogleFonts.config.allowRuntimeFetching = false; GoogleFonts.config.allowRuntimeFetching = false;
await initializeDateFormatting(); await initializeDateFormatting();
@@ -136,19 +140,7 @@ class AdaptiveHome extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const forceTv = String.fromEnvironment('FORCE_TV', defaultValue: 'false'); // Forcing TV Dashboard for Google TV deployment
if (forceTv.toLowerCase() == 'true') {
return const TvDashboardScreen();
}
final size = MediaQuery.of(context).size;
final shortestSide = size.shortestSide;
final isMobile = shortestSide < 600;
if (isMobile) {
return const MobileHomeScreen();
}
return const TvDashboardScreen(); return const TvDashboardScreen();
} }
} }

View File

@@ -1,12 +1,18 @@
class WeatherInfo { class WeatherInfo {
final String description; final String description;
final double temperature; final double temperature;
final double tempMin;
final double tempMax;
final int aqi;
final String icon; final String icon;
final String city; final String city;
const WeatherInfo({ const WeatherInfo({
required this.description, required this.description,
required this.temperature, required this.temperature,
required this.tempMin,
required this.tempMax,
required this.aqi,
required this.icon, required this.icon,
required this.city, required this.city,
}); });
@@ -18,6 +24,9 @@ class WeatherInfo {
return WeatherInfo( return WeatherInfo(
description: weather["description"] as String? ?? "", description: weather["description"] as String? ?? "",
temperature: (json["main"]?["temp"] as num?)?.toDouble() ?? 0, temperature: (json["main"]?["temp"] as num?)?.toDouble() ?? 0,
tempMin: (json["main"]?["temp_min"] as num?)?.toDouble() ?? 0,
tempMax: (json["main"]?["temp_max"] as num?)?.toDouble() ?? 0,
aqi: json["aqi"] as int? ?? 0,
icon: weather["icon"] as String? ?? "", icon: weather["icon"] as String? ?? "",
city: json["name"] as String? ?? "", city: json["name"] as String? ?? "",
); );

View File

@@ -135,6 +135,9 @@ class MockDataStore {
static WeatherInfo weather = const WeatherInfo( static WeatherInfo weather = const WeatherInfo(
description: "clear sky", description: "clear sky",
temperature: 12, temperature: 12,
tempMin: 8,
tempMax: 15,
aqi: 2,
icon: "01d", icon: "01d",
city: "Seoul", city: "Seoul",
); );

View File

@@ -5,9 +5,92 @@ import '../config/api_config.dart';
import '../models/weather_info.dart'; import '../models/weather_info.dart';
import '../services/weather_service.dart'; import '../services/weather_service.dart';
class WeatherWidget extends StatelessWidget { class WeatherWidget extends StatefulWidget {
const WeatherWidget({super.key}); const WeatherWidget({super.key});
@override
State<WeatherWidget> createState() => _WeatherWidgetState();
}
class _WeatherWidgetState extends State<WeatherWidget> {
Timer? _refreshTimer;
@override
void initState() {
super.initState();
// Refresh weather every 1 hour
_refreshTimer = Timer.periodic(const Duration(hours: 1), (timer) {
if (mounted) {
setState(() {}); // Rebuild to trigger FutureBuilder
}
});
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
Widget _buildAqiIcon(int aqi) {
Color color;
IconData icon;
String label;
switch (aqi) {
case 1: // Good
color = Colors.blue;
icon = Icons.sentiment_very_satisfied;
label = "좋음";
break;
case 2: // Fair
color = Colors.green;
icon = Icons.sentiment_satisfied;
label = "보통";
break;
case 3: // Moderate
color = Colors.yellow[700]!;
icon = Icons.sentiment_neutral;
label = "주의";
break;
case 4: // Poor
color = Colors.orange;
icon = Icons.sentiment_dissatisfied;
label = "나쁨";
break;
case 5: // Very Poor
color = Colors.red;
icon = Icons.sentiment_very_dissatisfied;
label = "매우 나쁨";
break;
default:
return const SizedBox.shrink();
}
return Column(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder<WeatherInfo>( return FutureBuilder<WeatherInfo>(
@@ -27,6 +110,7 @@ class WeatherWidget extends StatelessWidget {
} }
if (snapshot.hasError) { if (snapshot.hasError) {
debugPrint('Weather Error: ${snapshot.error}');
return const Text( return const Text(
'Weather Unavailable', 'Weather Unavailable',
style: TextStyle(color: Colors.white54), style: TextStyle(color: Colors.white54),
@@ -42,43 +126,88 @@ class WeatherWidget extends StatelessWidget {
final iconUrl = (ApiConfig.useMockData || kIsWeb) final iconUrl = (ApiConfig.useMockData || kIsWeb)
? null ? null
: (weather.icon.isNotEmpty : (weather.icon.isNotEmpty
? "http://openweathermap.org/img/wn/${weather.icon}@2x.png" ? "https://openweathermap.org/img/wn/${weather.icon}@2x.png"
: null); : null);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Weather Icon
if (iconUrl != null) if (iconUrl != null)
Image.network( Image.network(
iconUrl, iconUrl,
width: 50, width: 72,
height: 50, height: 72,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
const Icon(Icons.wb_sunny, color: Colors.amber), const Icon(Icons.wb_sunny, color: Colors.amber),
) )
else else
const Icon(Icons.wb_sunny, color: Colors.amber, size: 40), const Icon(Icons.wb_sunny, color: Colors.amber, size: 60),
const SizedBox(width: 12), const SizedBox(width: 16),
// Temperature & City info
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [ children: [
Text( Text(
'${weather.temperature.round()}°C', '${weather.temperature.round()}°',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white, fontSize: 42, // Slightly larger to stand out
color: Colors.yellowAccent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.0,
), ),
), ),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
'${weather.city} · ${weather.description}', '${weather.city} · ${weather.description}',
style: Theme.of( style: Theme.of(context).textTheme.titleLarge?.copyWith(
context, color: Colors.white70,
).textTheme.bodyMedium?.copyWith(color: Colors.white70), fontWeight: FontWeight.w500,
fontSize: 20
),
),
const SizedBox(height: 4),
Text(
'최고:${weather.tempMax.round()}° 최저:${weather.tempMin.round()}°',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold
),
), ),
], ],
), ),
], ],
),
],
),
// AQI Indicator (Right side)
if (weather.aqi > 0) ...[
const SizedBox(width: 24),
Container(
height: 50,
width: 2,
color: Colors.white24,
),
const SizedBox(width: 24),
// Scaled up AQI
Transform.scale(
scale: 1.2,
child: _buildAqiIcon(weather.aqi),
),
],
],
); );
}, },
); );