From d6c9d8cef3d466865e4494ab9c71efbdf9b4ee2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E5=A4=9C?= Date: Tue, 30 Dec 2025 04:37:07 +0800 Subject: [PATCH 1/6] feat: Drag-and-drop-homepage-style --- lib/app/home/page.dart | 845 ++++++++++++++++++++++++++++-- lib/app/settings/layout/page.dart | 182 ++++--- lib/models/settings/ui.dart | 27 +- 3 files changed, 944 insertions(+), 110 deletions(-) diff --git a/lib/app/home/page.dart b/lib/app/home/page.dart index 262fe4c2d..651674807 100644 --- a/lib/app/home/page.dart +++ b/lib/app/home/page.dart @@ -312,7 +312,7 @@ class _HomePageState extends State with WidgetsBindingObserver { _wasVisible = isVisible; final homeSections = context - .select>( + .select>( (model) => model.homeSections, ); @@ -409,54 +409,825 @@ class _HomePageState extends State with WidgetsBindingObserver { ); } - Widget _buildContentSection(Set homeSections) { - return Column( - children: [ - // 拖曳指示器 - Container( - margin: const EdgeInsets.only(top: 12, bottom: 8), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(2), + Widget _buildContentSection(List homeSections) { + final children = [ + // 拖曳指示器 + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(2), + ), + ), + // 實時警報 + if (!_isLoading) ..._buildRealtimeInfo(), + ]; + + for (final section in homeSections) { + switch (section) { + case HomeDisplaySection.radar: + children.add(_buildRadarMap()); + break; + case HomeDisplaySection.forecast: + children.add(_buildForecast()); + if (!_isLoading && _weather != null) children.add(_buildWindCard()); + break; + case HomeDisplaySection.history: + children.add(_buildHistoryTimeline()); + break; + } + } + + if (homeSections.isEmpty && GlobalProviders.location.code != null) { + children.add( + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + '您還沒有啟用首頁區塊,請到設定選擇要顯示的內容。'.i18n, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () => SettingsLayoutRoute().push(context), + child: Text('前往設定'.i18n), + ), + ], ), ), - // 實時警報 - if (!_isLoading) ..._buildRealtimeInfo(), - // 其他區塊 - if (homeSections.isNotEmpty) ...[ - if (homeSections.contains(HomeDisplaySection.radar)) _buildRadarMap(), - if (homeSections.contains(HomeDisplaySection.forecast)) - _buildForecast(), - if (!_isLoading && _weather != null) _buildWindCard(), - _buildCommunityCards(), - if (homeSections.contains(HomeDisplaySection.history)) - _buildHistoryTimeline(), - ] else if (GlobalProviders.location.code != null) - Padding( - padding: const EdgeInsets.all(16), - child: Column( + ); + } + // 社群區塊 (放在最後) + children.add(_buildCommunityCards()); + + // 底部安全區域 + children.add( + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ); + + return Column(children: children); + } + + Widget _buildCommunityCards() { + return ResponsiveContainer( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 標題 + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.group_rounded, + color: Theme.of(context).colorScheme.primary, + size: 18, + ), + ), + const SizedBox(width: 10), + Text( + '社群'.i18n, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + // 社群卡片 + Row( + children: [ + Expanded( + child: _buildSocialCard( + icon: SimpleIcons.discord, + label: 'Discord', + color: const Color(0xFF5865F2), + onTap: () => + launchUrl(Uri.parse('https://exptech.com.tw/dc')), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildSocialCard( + icon: SimpleIcons.threads, + label: 'Threads', + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black, + onTap: () => launchUrl( + Uri.parse('https://www.threads.net/@dpip.tw'), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildSocialCard( + icon: SimpleIcons.youtube, + label: 'YouTube', + color: const Color(0xFFFF0000), + onTap: () => launchUrl( + Uri.parse('https://www.youtube.com/@exptechtw/live'), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildDonateCard(), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSocialCard({ + required IconData icon, + required String label, + required Color color, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Ink( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), Text( - '您還沒有啟用首頁區塊,請到設定選擇要顯示的內容。'.i18n, - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildDonateCard() { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => context.push(SettingsDonatePage.route), + borderRadius: BorderRadius.circular(12), + child: Ink( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primaryContainer, + Theme.of(context).colorScheme.tertiaryContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Symbols.favorite_rounded, + size: 18, + color: Theme.of(context).colorScheme.onPrimaryContainer, ), - const SizedBox(height: 12), - FilledButton( - onPressed: () => SettingsLayoutRoute().push(context), - child: Text('前往設定'.i18n), + const SizedBox(width: 8), + Text( + '贊助我們'.i18n, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), ), ], ), ), - // 底部安全區域 - SizedBox(height: MediaQuery.of(context).padding.bottom + 16), - ], + ), + ), + ); + } + + List _buildRealtimeInfo() { + return [ + if (GlobalProviders.data.eew.isNotEmpty) + ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemCount: GlobalProviders.data.eew.length, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.all(16), + child: EewCard(GlobalProviders.data.eew[index]), + ), + ), + if (_thunderstorm != null) + Padding( + padding: const EdgeInsets.all(16), + child: ThunderstormCard(_thunderstorm!), + ), + ]; + } + + Widget _buildWindCard() { + if (_weather == null) return const SizedBox.shrink(); + return WindCard(_weather!); + } + + Widget _buildRadarMap() { + return Padding( + padding: const EdgeInsets.all(16), + child: RadarMapCard(key: _mapKey), ); } + Widget _buildForecast() { + if (_forecast == null) return const SizedBox.shrink(); + return ForecastCard(_forecast!); + } + + Widget _buildHistoryTimeline() { + return ResponsiveContainer( + child: Builder( + builder: (context) { + final history = _history; + + if (history == null || history.isEmpty) { + return Column( + children: [ + DateTimelineItem( + TZDateTime.now(UTC).toLocaleFullDateString(context), + first: true, + last: true, + mode: _currentMode, + onModeChanged: _onModeChanged, + isOutOfService: _isOutOfService, + ), + ], + ); + } + + final grouped = groupBy( + history, + (e) => e.time.send.toLocaleFullDateString(context), + ); + + return Column( + children: grouped.entries + .sorted((a, b) => b.key.compareTo(a.key)) + .mapIndexed( + (index, entry) => _buildHistoryGroup(entry, index, history), + ) + .toList(), + ); + }, + ), + ); + } + + Widget _buildHistoryGroup( + MapEntry> entry, + int index, + List allHistory, + ) { + final historyGroup = entry.value.sorted( + (a, b) => b.time.send.compareTo(a.time.send), + ); + + return Column( + children: [ + DateTimelineItem( + entry.key, + first: index == 0, + mode: index == 0 ? _currentMode : null, + onModeChanged: index == 0 ? _onModeChanged : null, + isOutOfService: _isOutOfService, + ), + ...historyGroup.map((item) { + return HistoryTimelineItem( + expired: item.isExpired, + history: item, + last: item == allHistory.last, + ); + }), + ], + ); + } +} +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:go_router/go_router.dart'; +import 'package:i18n_extension/i18n_extension.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import 'package:simple_icons/simple_icons.dart'; +import 'package:timezone/timezone.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:dpip/api/exptech.dart'; +import 'package:dpip/api/model/history/history.dart'; +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/app/changelog/page.dart'; +import 'package:dpip/app/home/_widgets/date_timeline_item.dart'; +import 'package:dpip/app/home/_widgets/eew_card.dart'; +import 'package:dpip/app/home/_widgets/forecast_card.dart'; +import 'package:dpip/app/home/_widgets/hero_weather.dart'; +import 'package:dpip/app/home/_widgets/history_timeline_item.dart'; +import 'package:dpip/app/home/_widgets/location_button.dart'; +import 'package:dpip/app/home/_widgets/location_not_set_card.dart'; +import 'package:dpip/app/home/_widgets/location_out_of_service.dart'; +import 'package:dpip/app/home/_widgets/mode_toggle_button.dart'; +import 'package:dpip/app/home/_widgets/radar_card.dart'; +import 'package:dpip/app/home/_widgets/thunderstorm_card.dart'; +import 'package:dpip/app/home/_widgets/wind_card.dart'; +import 'package:dpip/app/settings/donate/page.dart'; +import 'package:dpip/app/settings/layout/page.dart'; +import 'package:dpip/core/gps_location.dart'; +import 'package:dpip/core/i18n.dart'; +import 'package:dpip/core/preference.dart'; +import 'package:dpip/core/providers.dart'; +import 'package:dpip/global.dart'; +import 'package:dpip/models/settings/ui.dart'; +import 'package:dpip/router.dart'; +import 'package:dpip/utils/constants.dart'; +import 'package:dpip/utils/extensions/build_context.dart'; +import 'package:dpip/utils/extensions/datetime.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:dpip/widgets/rain_shader_background.dart'; +import 'package:dpip/widgets/responsive/responsive_container.dart'; + +import 'home_display_mode.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + static const route = '/home'; + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State with WidgetsBindingObserver { + final _refreshIndicatorKey = GlobalKey(); + final _locationButtonKey = GlobalKey(); + final _scrollController = ScrollController(); + + Key _mapKey = UniqueKey(); + bool _isLoading = false; + bool _isOutOfService = false; + bool _wasVisible = true; + double? _locationButtonHeight; + + RealtimeWeather? _weather; + Map? _forecast; + List? _history; + List? _realtimeRegion; + HomeMode _currentMode = HomeMode.localActive; + + String? _lastRefreshCode; + bool _isFirstRefresh = true; + + History? get _thunderstorm => _realtimeRegion + ?.where((e) => e.type == HistoryType.thunderstorm) + .sorted((a, b) => b.time.send.compareTo(a.time.send)) + .firstOrNull; + + /// 是否正在下雨(用於決定是否顯示雨滴效果) + bool get _isRaining { + // TODO: 測試完成後移除強制啟用 + return true; + // if (_weather == null) return false; + // final code = _weather!.data.weatherCode; + // // 雨天代碼範圍:15-35(包含雨、大雨、雷雨) + // return code >= 15 && code <= 35; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkVersion()); + GlobalProviders.location.$code.addListener(_refresh); + _refresh(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + GlobalProviders.location.$code.removeListener(_refresh); + _scrollController.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) _refresh(); + } + + void _checkVersion() { + Preference.version ??= Global.packageInfo.version; + if (Global.packageInfo.version == Preference.version) return; + + Preference.version = Global.packageInfo.version; + context.scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + '已更新至 {version}'.i18n.args({ + 'version': 'v${Global.packageInfo.version}', + }), + ), + action: SnackBarAction( + label: '更新日誌'.i18n, + onPressed: () => ChangelogRoute().push(context), + ), + duration: kPersistSnackBar, + ), + ); + } + + Future _refresh() async { + if (_isLoading) return; + + await _reloadLocationData(); + + final code = GlobalProviders.location.code; + + final isOutOfService = _checkIfOutOfService(code); + + if (isOutOfService && !_currentMode.isNational) { + _currentMode = _currentMode.isActive + ? HomeMode.nationalActive + : HomeMode.nationalHistory; + } + + setState(() { + _isLoading = true; + _isOutOfService = isOutOfService; + if (!_isFirstRefresh && _lastRefreshCode != code) { + _mapKey = Key('${DateTime.now().millisecondsSinceEpoch}'); + _weather = null; + _forecast = null; + } + _isFirstRefresh = false; + }); + + _refreshIndicatorKey.currentState?.show(); + + final homeSections = context + .read() + .homeSections; + + final futures = [ + _fetchWeather(code), + _fetchRealtimeRegion(code), + ]; + + if (homeSections.contains(HomeDisplaySection.history)) { + futures.add(_fetchHistory(code, isOutOfService)); + } else { + if (mounted) { + setState(() { + _history = null; + }); + } + } + + await Future.wait(futures); + + if (mounted) { + setState(() => _isLoading = false); + _lastRefreshCode = code; + } + } + + Future _reloadLocationData() async { + if (GlobalProviders.location.auto) { + await updateLocationFromGPS(); + } else { + await Preference.reload(); + final code = Preference.locationCode; + if (code != null) { + final location = Global.location[code]; + if (location != null) { + Preference.locationLatitude = location.lat; + Preference.locationLongitude = location.lng; + } + } + GlobalProviders.location.refresh(); + } + } + + bool _checkIfOutOfService(String? code) { + if (code == null) return true; + + final auto = GlobalProviders.location.auto; + final location = Global.location[code]; + + return auto && location == null; + } + + Future _fetchWeather(String? code) async { + if (code == null) { + if (mounted) + setState(() { + _weather = null; + _forecast = null; + }); + return; + } + + try { + LatLng? coords; + if (Preference.locationLatitude != null && + Preference.locationLongitude != null) { + coords = LatLng( + Preference.locationLatitude!, + Preference.locationLongitude!, + ); + } else { + coords = GlobalProviders.location.coordinates; + } + + if (coords != null) { + final weather = await ExpTech().getWeatherRealtimeByCoords( + coords.latitude, + coords.longitude, + ); + if (mounted) setState(() => _weather = weather); + } else { + if (mounted) setState(() => _weather = null); + } + + final forecast = await ExpTech().getWeatherForecast(code); + if (mounted) setState(() => _forecast = forecast); + } catch (e, s) { + if (!mounted) return; + TalkerManager.instance.error('_HomePageState._fetchWeather', e, s); + context.scaffoldMessenger.showSnackBar( + SnackBar(content: Text('取得天氣異常'.i18n)), + ); + } + } + + Future _fetchRealtimeRegion(String? code) async { + if (code == null) { + if (mounted) setState(() => _realtimeRegion = null); + return; + } + + try { + final realtime = await ExpTech().getRealtimeRegion(code); + if (mounted) setState(() => _realtimeRegion = realtime); + } catch (e, s) { + if (!mounted) return; + TalkerManager.instance.error('_HomePageState._fetchRealtimeRegion', e, s); + if (mounted) setState(() => _realtimeRegion = null); + } + } + + Future _fetchHistory(String? code, bool isOutOfService) async { + try { + final shouldUseNational = + _currentMode.isNational || isOutOfService || code == null; + final List history; + + if (shouldUseNational) { + history = _currentMode.isActive + ? await ExpTech().getRealtime() + : await ExpTech().getHistory(); + } else { + history = _currentMode.isActive + ? await ExpTech().getRealtimeRegion(code) + : await ExpTech().getHistoryRegion(code); + } + + if (mounted) setState(() => _history = history); + } catch (e, s) { + if (!mounted) return; + TalkerManager.instance.error('_HomePageState._fetchHistory', e, s); + context.scaffoldMessenger.showSnackBar( + SnackBar(content: Text('取得歷史資訊異常'.i18n)), + ); + } + } + + void _onModeChanged(HomeMode mode) { + setState(() => _currentMode = mode); + _refresh(); + } + + @override + Widget build(BuildContext context) { + final isVisible = ModalRoute.of(context)?.isCurrent ?? false; + if (!_wasVisible && isVisible) { + WidgetsBinding.instance.addPostFrameCallback((_) => _refresh()); + } + _wasVisible = isVisible; + + final homeSections = context + .select>( + (model) => model.homeSections, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _locationButtonKey.currentContext != null) { + final RenderBox? box = + _locationButtonKey.currentContext!.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + final newHeight = box.size.height; + if (_locationButtonHeight != newHeight) { + setState(() { + _locationButtonHeight = newHeight; + }); + } + } + } + }); + + return Stack( + children: [ + // 雨滴背景(全螢幕,持續顯示) + Positioned.fill( + child: RainShaderBackground( + animated: _isRaining, + ), + ), + // 主內容 + ExpressiveRefreshIndicator.contained( + key: _refreshIndicatorKey, + edgeOffset: context.padding.top + kToolbarHeight, + backgroundColor: context.colors.primaryContainer, + onRefresh: _refresh, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + // 英雄區塊 - 第一屏簡潔顯示 + SliverToBoxAdapter( + child: _buildHeroSection(), + ), + // 詳細內容區塊 + SliverToBoxAdapter( + child: _buildContentSection(homeSections), + ), + ], + ), + ), + // 位置按鈕 + Positioned( + top: 24, + left: 0, + right: 0, + child: SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: LocationButton(key: _locationButtonKey), + ), + ), + ), + ], + ); + } + + Widget _buildHeroSection() { + final code = GlobalProviders.location.code; + + // 如果沒有設定位置或服務區域外,顯示提示 + if (code == null) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.65, + child: const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: LocationNotSetCard(), + ), + ), + ); + } + + if (_isOutOfService) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.65, + child: const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: LocationOutOfServiceCard(), + ), + ), + ); + } + + return HeroWeather( + weather: _weather, + isLoading: _isLoading, + ); + } + + Widget _buildContentSection(List homeSections) { + final children = [ + // 拖曳指示器 + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(2), + ), + ), + // 實時警報 + if (!_isLoading) ..._buildRealtimeInfo(), + ]; + + for (final section in homeSections) { + switch (section) { + case HomeDisplaySection.radar: + children.add(_buildRadarMap()); + break; + case HomeDisplaySection.forecast: + children.add(_buildForecast()); + if (!_isLoading && _weather != null) children.add(_buildWindCard()); + break; + case HomeDisplaySection.history: + children.add(_buildHistoryTimeline()); + break; + } + } + + if (homeSections.isEmpty && GlobalProviders.location.code != null) { + children.add( + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + '您還沒有啟用首頁區塊,請到設定選擇要顯示的內容。'.i18n, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () => SettingsLayoutRoute().push(context), + child: Text('前往設定'.i18n), + ), + ], + ), + ), + ); + } + // 社群區塊 (放在最後) + children.add(_buildCommunityCards()); + + // 底部安全區域 + children.add( + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ); + + return Column(children: children); + } + Widget _buildCommunityCards() { return ResponsiveContainer( child: Container( diff --git a/lib/app/settings/layout/page.dart b/lib/app/settings/layout/page.dart index fc0cf534a..b9308ba9f 100644 --- a/lib/app/settings/layout/page.dart +++ b/lib/app/settings/layout/page.dart @@ -15,81 +15,117 @@ class SettingsLayoutPage extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - padding: EdgeInsets.only( - top: 16, - bottom: MediaQuery.of(context).padding.bottom + 16, - ), - children: [ - _buildHeader(context), - const SizedBox(height: 16), - Section( + return Consumer( + builder: (context, model, child) { + final enabledSections = model.homeSections; + final disabledSections = HomeDisplaySection.values + .where((s) => !enabledSections.contains(s)) + .toList(); + + return ListView( + padding: EdgeInsets.only( + top: 16, + bottom: MediaQuery.of(context).padding.bottom + 16, + ), children: [ - Selector( - selector: (context, model) => model.isEnabled(.radar), - builder: (context, isEnabled, child) { - return SectionListTile( - isFirst: true, - leading: ContainedIcon( - Symbols.radar_rounded, - color: Colors.blueAccent, - ), - title: Text('雷達回波'.i18n), - subtitle: Text('顯示即時雷達回波圖'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.radar, value); - }, - ), - ); - }, - ), - Selector( - selector: (context, model) => model.isEnabled(.forecast), - builder: (context, isEnabled, child) { - return SectionListTile( - leading: ContainedIcon( - Symbols.radar_rounded, - color: Colors.orangeAccent, - ), - title: Text('天氣預報'.i18n), - subtitle: Text('顯示未來 24 小時的天氣預報'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.forecast, value); - }, - ), - ); - }, - ), - Selector( - selector: (context, model) => model.isEnabled(.history), - builder: (context, isEnabled, child) { - return SectionListTile( - isLast: true, - leading: ContainedIcon( - Symbols.history_rounded, - color: Colors.greenAccent, + _buildHeader(context), + const SizedBox(height: 16), + if (enabledSections.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + '顯示中'.i18n, + style: context.texts.labelLarge?.copyWith( + color: context.colors.primary, ), - title: Text('歷史事件'.i18n), - subtitle: Text('顯示地震與災害歷史紀錄'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.history, value); - }, + ), + ), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: enabledSections.length, + onReorder: model.reorderSection, + proxyDecorator: (child, index, animation) { + return Material( + color: Colors.transparent, + child: child, + ); + }, + itemBuilder: (context, index) { + final section = enabledSections[index]; + final details = _getSectionDetails(section); + return _buildSectionCard( + context, + key: ValueKey(section), + icon: details.icon, + iconColor: details.color, + title: details.title, + subtitle: details.subtitle, + value: true, + onChanged: (v) => model.toggleSection(section, v), + isReorderable: true, + ); + }, + ), + ], + if (disabledSections.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + '已隱藏'.i18n, + style: context.texts.labelLarge?.copyWith( + color: context.colors.onSurfaceVariant, ), + ), + ), + ...disabledSections.map((section) { + final details = _getSectionDetails(section); + return _buildSectionCard( + context, + key: ValueKey(section), + icon: details.icon, + iconColor: details.color, + title: details.title, + subtitle: details.subtitle, + value: false, + onChanged: (v) => model.toggleSection(section, v), + isReorderable: false, ); - }, - ), + }), + ], ], - ), - ], + ); + }, ); } + ({IconData icon, Color color, String title, String subtitle}) + _getSectionDetails(HomeDisplaySection section) { + switch (section) { + case HomeDisplaySection.radar: + return ( + icon: Symbols.radar_rounded, + color: Colors.blue, + title: '雷達回波'.i18n, + subtitle: '顯示即時雷達回波圖'.i18n, + ); + case HomeDisplaySection.forecast: + return ( + icon: Symbols.partly_cloudy_day_rounded, + color: Colors.orange, + title: '天氣預報(24h)'.i18n, + subtitle: '顯示未來 24 小時天氣預報'.i18n, + ); + case HomeDisplaySection.history: + return ( + icon: Symbols.history_rounded, + color: Colors.green, + title: '歷史事件'.i18n, + subtitle: '顯示地震與災害歷史紀錄'.i18n, + ); + } + } + Widget _buildHeader(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -122,7 +158,7 @@ class SettingsLayoutPage extends StatelessWidget { ), ), Text( - '自訂首頁顯示的區塊'.i18n, + '長按可拖曳排序顯示順序'.i18n, style: context.texts.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -139,14 +175,17 @@ class SettingsLayoutPage extends StatelessWidget { Widget _buildSectionCard( BuildContext context, { + required Key key, required IconData icon, required Color iconColor, required String title, required String subtitle, required bool value, required ValueChanged onChanged, + required bool isReorderable, }) { return Container( + key: key, margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( color: context.colors.surfaceContainerLow, @@ -161,6 +200,13 @@ class SettingsLayoutPage extends StatelessWidget { padding: const EdgeInsets.all(16), child: Row( children: [ + if (isReorderable) ...[ + Icon( + Symbols.drag_handle_rounded, + color: context.colors.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(width: 12), + ], Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( diff --git a/lib/models/settings/ui.dart b/lib/models/settings/ui.dart index e3d112ce6..998b27508 100644 --- a/lib/models/settings/ui.dart +++ b/lib/models/settings/ui.dart @@ -16,7 +16,7 @@ class SettingsUserInterfaceModel extends ChangeNotifier { int? get _themeColor => Preference.themeColor; Locale? get _locale => Preference.locale?.asLocale; bool get _useFahrenheit => Preference.useFahrenheit ?? false; - late Set homeSections; + late List homeSections; final savedList = Preference.homeDisplaySections; ThemeMode get themeMode => ThemeMode.values.byName(_themeMode); @@ -59,7 +59,7 @@ class SettingsUserInterfaceModel extends ChangeNotifier { SettingsUserInterfaceModel() { if (savedList.isEmpty) { // 預設全部啟用 - homeSections = HomeDisplaySection.values.toSet(); + homeSections = HomeDisplaySection.values.toList(); } else { final saved = savedList .map( @@ -68,7 +68,7 @@ class SettingsUserInterfaceModel extends ChangeNotifier { .firstWhere((e) => e?.name == s, orElse: () => null), ) .whereType() - .toSet(); + .toList(); homeSections = saved; } } @@ -76,11 +76,28 @@ class SettingsUserInterfaceModel extends ChangeNotifier { bool isEnabled(HomeDisplaySection section) => homeSections.contains(section); void toggleSection(HomeDisplaySection section, bool enabled) { + final newList = List.from(homeSections); if (enabled) { - homeSections.add(section); + if (!newList.contains(section)) { + newList.add(section); + } } else { - homeSections.remove(section); + newList.remove(section); } + homeSections = newList; + Preference.homeDisplaySections = homeSections.map((e) => e.name).toList(); + notifyListeners(); + } + + void reorderSection(int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final newList = List.from(homeSections); + final item = newList.removeAt(oldIndex); + newList.insert(newIndex, item); + homeSections = newList; + Preference.homeDisplaySections = homeSections.map((e) => e.name).toList(); notifyListeners(); } From 22c646b898fa0a19eba5746a8a922b963a676ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E5=A4=9C?= Date: Tue, 30 Dec 2025 04:42:30 +0800 Subject: [PATCH 2/6] fix: Drag-and-drop-homepage-style --- lib/app/home/page.dart | 756 ----------------------------------------- 1 file changed, 756 deletions(-) diff --git a/lib/app/home/page.dart b/lib/app/home/page.dart index 651674807..73dad4717 100644 --- a/lib/app/home/page.dart +++ b/lib/app/home/page.dart @@ -754,759 +754,3 @@ class _HomePageState extends State with WidgetsBindingObserver { ); } } -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:go_router/go_router.dart'; -import 'package:i18n_extension/i18n_extension.dart'; -import 'package:m3e_collection/m3e_collection.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:provider/provider.dart'; -import 'package:simple_icons/simple_icons.dart'; -import 'package:timezone/timezone.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'package:dpip/api/exptech.dart'; -import 'package:dpip/api/model/history/history.dart'; -import 'package:dpip/api/model/weather_schema.dart'; -import 'package:dpip/app/changelog/page.dart'; -import 'package:dpip/app/home/_widgets/date_timeline_item.dart'; -import 'package:dpip/app/home/_widgets/eew_card.dart'; -import 'package:dpip/app/home/_widgets/forecast_card.dart'; -import 'package:dpip/app/home/_widgets/hero_weather.dart'; -import 'package:dpip/app/home/_widgets/history_timeline_item.dart'; -import 'package:dpip/app/home/_widgets/location_button.dart'; -import 'package:dpip/app/home/_widgets/location_not_set_card.dart'; -import 'package:dpip/app/home/_widgets/location_out_of_service.dart'; -import 'package:dpip/app/home/_widgets/mode_toggle_button.dart'; -import 'package:dpip/app/home/_widgets/radar_card.dart'; -import 'package:dpip/app/home/_widgets/thunderstorm_card.dart'; -import 'package:dpip/app/home/_widgets/wind_card.dart'; -import 'package:dpip/app/settings/donate/page.dart'; -import 'package:dpip/app/settings/layout/page.dart'; -import 'package:dpip/core/gps_location.dart'; -import 'package:dpip/core/i18n.dart'; -import 'package:dpip/core/preference.dart'; -import 'package:dpip/core/providers.dart'; -import 'package:dpip/global.dart'; -import 'package:dpip/models/settings/ui.dart'; -import 'package:dpip/router.dart'; -import 'package:dpip/utils/constants.dart'; -import 'package:dpip/utils/extensions/build_context.dart'; -import 'package:dpip/utils/extensions/datetime.dart'; -import 'package:dpip/utils/log.dart'; -import 'package:dpip/widgets/rain_shader_background.dart'; -import 'package:dpip/widgets/responsive/responsive_container.dart'; - -import 'home_display_mode.dart'; - -class HomePage extends StatefulWidget { - const HomePage({super.key}); - - static const route = '/home'; - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State with WidgetsBindingObserver { - final _refreshIndicatorKey = GlobalKey(); - final _locationButtonKey = GlobalKey(); - final _scrollController = ScrollController(); - - Key _mapKey = UniqueKey(); - bool _isLoading = false; - bool _isOutOfService = false; - bool _wasVisible = true; - double? _locationButtonHeight; - - RealtimeWeather? _weather; - Map? _forecast; - List? _history; - List? _realtimeRegion; - HomeMode _currentMode = HomeMode.localActive; - - String? _lastRefreshCode; - bool _isFirstRefresh = true; - - History? get _thunderstorm => _realtimeRegion - ?.where((e) => e.type == HistoryType.thunderstorm) - .sorted((a, b) => b.time.send.compareTo(a.time.send)) - .firstOrNull; - - /// 是否正在下雨(用於決定是否顯示雨滴效果) - bool get _isRaining { - // TODO: 測試完成後移除強制啟用 - return true; - // if (_weather == null) return false; - // final code = _weather!.data.weatherCode; - // // 雨天代碼範圍:15-35(包含雨、大雨、雷雨) - // return code >= 15 && code <= 35; - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((_) => _checkVersion()); - GlobalProviders.location.$code.addListener(_refresh); - _refresh(); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - GlobalProviders.location.$code.removeListener(_refresh); - _scrollController.dispose(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) _refresh(); - } - - void _checkVersion() { - Preference.version ??= Global.packageInfo.version; - if (Global.packageInfo.version == Preference.version) return; - - Preference.version = Global.packageInfo.version; - context.scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - '已更新至 {version}'.i18n.args({ - 'version': 'v${Global.packageInfo.version}', - }), - ), - action: SnackBarAction( - label: '更新日誌'.i18n, - onPressed: () => ChangelogRoute().push(context), - ), - duration: kPersistSnackBar, - ), - ); - } - - Future _refresh() async { - if (_isLoading) return; - - await _reloadLocationData(); - - final code = GlobalProviders.location.code; - - final isOutOfService = _checkIfOutOfService(code); - - if (isOutOfService && !_currentMode.isNational) { - _currentMode = _currentMode.isActive - ? HomeMode.nationalActive - : HomeMode.nationalHistory; - } - - setState(() { - _isLoading = true; - _isOutOfService = isOutOfService; - if (!_isFirstRefresh && _lastRefreshCode != code) { - _mapKey = Key('${DateTime.now().millisecondsSinceEpoch}'); - _weather = null; - _forecast = null; - } - _isFirstRefresh = false; - }); - - _refreshIndicatorKey.currentState?.show(); - - final homeSections = context - .read() - .homeSections; - - final futures = [ - _fetchWeather(code), - _fetchRealtimeRegion(code), - ]; - - if (homeSections.contains(HomeDisplaySection.history)) { - futures.add(_fetchHistory(code, isOutOfService)); - } else { - if (mounted) { - setState(() { - _history = null; - }); - } - } - - await Future.wait(futures); - - if (mounted) { - setState(() => _isLoading = false); - _lastRefreshCode = code; - } - } - - Future _reloadLocationData() async { - if (GlobalProviders.location.auto) { - await updateLocationFromGPS(); - } else { - await Preference.reload(); - final code = Preference.locationCode; - if (code != null) { - final location = Global.location[code]; - if (location != null) { - Preference.locationLatitude = location.lat; - Preference.locationLongitude = location.lng; - } - } - GlobalProviders.location.refresh(); - } - } - - bool _checkIfOutOfService(String? code) { - if (code == null) return true; - - final auto = GlobalProviders.location.auto; - final location = Global.location[code]; - - return auto && location == null; - } - - Future _fetchWeather(String? code) async { - if (code == null) { - if (mounted) - setState(() { - _weather = null; - _forecast = null; - }); - return; - } - - try { - LatLng? coords; - if (Preference.locationLatitude != null && - Preference.locationLongitude != null) { - coords = LatLng( - Preference.locationLatitude!, - Preference.locationLongitude!, - ); - } else { - coords = GlobalProviders.location.coordinates; - } - - if (coords != null) { - final weather = await ExpTech().getWeatherRealtimeByCoords( - coords.latitude, - coords.longitude, - ); - if (mounted) setState(() => _weather = weather); - } else { - if (mounted) setState(() => _weather = null); - } - - final forecast = await ExpTech().getWeatherForecast(code); - if (mounted) setState(() => _forecast = forecast); - } catch (e, s) { - if (!mounted) return; - TalkerManager.instance.error('_HomePageState._fetchWeather', e, s); - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text('取得天氣異常'.i18n)), - ); - } - } - - Future _fetchRealtimeRegion(String? code) async { - if (code == null) { - if (mounted) setState(() => _realtimeRegion = null); - return; - } - - try { - final realtime = await ExpTech().getRealtimeRegion(code); - if (mounted) setState(() => _realtimeRegion = realtime); - } catch (e, s) { - if (!mounted) return; - TalkerManager.instance.error('_HomePageState._fetchRealtimeRegion', e, s); - if (mounted) setState(() => _realtimeRegion = null); - } - } - - Future _fetchHistory(String? code, bool isOutOfService) async { - try { - final shouldUseNational = - _currentMode.isNational || isOutOfService || code == null; - final List history; - - if (shouldUseNational) { - history = _currentMode.isActive - ? await ExpTech().getRealtime() - : await ExpTech().getHistory(); - } else { - history = _currentMode.isActive - ? await ExpTech().getRealtimeRegion(code) - : await ExpTech().getHistoryRegion(code); - } - - if (mounted) setState(() => _history = history); - } catch (e, s) { - if (!mounted) return; - TalkerManager.instance.error('_HomePageState._fetchHistory', e, s); - context.scaffoldMessenger.showSnackBar( - SnackBar(content: Text('取得歷史資訊異常'.i18n)), - ); - } - } - - void _onModeChanged(HomeMode mode) { - setState(() => _currentMode = mode); - _refresh(); - } - - @override - Widget build(BuildContext context) { - final isVisible = ModalRoute.of(context)?.isCurrent ?? false; - if (!_wasVisible && isVisible) { - WidgetsBinding.instance.addPostFrameCallback((_) => _refresh()); - } - _wasVisible = isVisible; - - final homeSections = context - .select>( - (model) => model.homeSections, - ); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _locationButtonKey.currentContext != null) { - final RenderBox? box = - _locationButtonKey.currentContext!.findRenderObject() as RenderBox?; - if (box != null && box.hasSize) { - final newHeight = box.size.height; - if (_locationButtonHeight != newHeight) { - setState(() { - _locationButtonHeight = newHeight; - }); - } - } - } - }); - - return Stack( - children: [ - // 雨滴背景(全螢幕,持續顯示) - Positioned.fill( - child: RainShaderBackground( - animated: _isRaining, - ), - ), - // 主內容 - ExpressiveRefreshIndicator.contained( - key: _refreshIndicatorKey, - edgeOffset: context.padding.top + kToolbarHeight, - backgroundColor: context.colors.primaryContainer, - onRefresh: _refresh, - child: CustomScrollView( - controller: _scrollController, - slivers: [ - // 英雄區塊 - 第一屏簡潔顯示 - SliverToBoxAdapter( - child: _buildHeroSection(), - ), - // 詳細內容區塊 - SliverToBoxAdapter( - child: _buildContentSection(homeSections), - ), - ], - ), - ), - // 位置按鈕 - Positioned( - top: 24, - left: 0, - right: 0, - child: SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: LocationButton(key: _locationButtonKey), - ), - ), - ), - ], - ); - } - - Widget _buildHeroSection() { - final code = GlobalProviders.location.code; - - // 如果沒有設定位置或服務區域外,顯示提示 - if (code == null) { - return SizedBox( - height: MediaQuery.of(context).size.height * 0.65, - child: const Center( - child: Padding( - padding: EdgeInsets.all(32), - child: LocationNotSetCard(), - ), - ), - ); - } - - if (_isOutOfService) { - return SizedBox( - height: MediaQuery.of(context).size.height * 0.65, - child: const Center( - child: Padding( - padding: EdgeInsets.all(32), - child: LocationOutOfServiceCard(), - ), - ), - ); - } - - return HeroWeather( - weather: _weather, - isLoading: _isLoading, - ); - } - - Widget _buildContentSection(List homeSections) { - final children = [ - // 拖曳指示器 - Container( - margin: const EdgeInsets.only(top: 12, bottom: 8), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(2), - ), - ), - // 實時警報 - if (!_isLoading) ..._buildRealtimeInfo(), - ]; - - for (final section in homeSections) { - switch (section) { - case HomeDisplaySection.radar: - children.add(_buildRadarMap()); - break; - case HomeDisplaySection.forecast: - children.add(_buildForecast()); - if (!_isLoading && _weather != null) children.add(_buildWindCard()); - break; - case HomeDisplaySection.history: - children.add(_buildHistoryTimeline()); - break; - } - } - - if (homeSections.isEmpty && GlobalProviders.location.code != null) { - children.add( - Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - '您還沒有啟用首頁區塊,請到設定選擇要顯示的內容。'.i18n, - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () => SettingsLayoutRoute().push(context), - child: Text('前往設定'.i18n), - ), - ], - ), - ), - ); - } - // 社群區塊 (放在最後) - children.add(_buildCommunityCards()); - - // 底部安全區域 - children.add( - SizedBox(height: MediaQuery.of(context).padding.bottom + 16), - ); - - return Column(children: children); - } - - Widget _buildCommunityCards() { - return ResponsiveContainer( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outlineVariant.withValues(alpha: 0.5), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 標題 - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Symbols.group_rounded, - color: Theme.of(context).colorScheme.primary, - size: 18, - ), - ), - const SizedBox(width: 10), - Text( - '社群'.i18n, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 16), - // 社群卡片 - Row( - children: [ - Expanded( - child: _buildSocialCard( - icon: SimpleIcons.discord, - label: 'Discord', - color: const Color(0xFF5865F2), - onTap: () => - launchUrl(Uri.parse('https://exptech.com.tw/dc')), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildSocialCard( - icon: SimpleIcons.threads, - label: 'Threads', - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - onTap: () => launchUrl( - Uri.parse('https://www.threads.net/@dpip.tw'), - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _buildSocialCard( - icon: SimpleIcons.youtube, - label: 'YouTube', - color: const Color(0xFFFF0000), - onTap: () => launchUrl( - Uri.parse('https://www.youtube.com/@exptechtw/live'), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _buildDonateCard(), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildSocialCard({ - required IconData icon, - required String label, - required Color color, - required VoidCallback onTap, - }) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Ink( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 18, color: color), - const SizedBox(width: 8), - Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildDonateCard() { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => context.push(SettingsDonatePage.route), - borderRadius: BorderRadius.circular(12), - child: Ink( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primaryContainer, - Theme.of(context).colorScheme.tertiaryContainer, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Symbols.favorite_rounded, - size: 18, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 8), - Text( - '贊助我們'.i18n, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ], - ), - ), - ), - ), - ); - } - - List _buildRealtimeInfo() { - return [ - if (GlobalProviders.data.eew.isNotEmpty) - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - itemCount: GlobalProviders.data.eew.length, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.all(16), - child: EewCard(GlobalProviders.data.eew[index]), - ), - ), - if (_thunderstorm != null) - Padding( - padding: const EdgeInsets.all(16), - child: ThunderstormCard(_thunderstorm!), - ), - ]; - } - - Widget _buildWindCard() { - if (_weather == null) return const SizedBox.shrink(); - return WindCard(_weather!); - } - - Widget _buildRadarMap() { - return Padding( - padding: const EdgeInsets.all(16), - child: RadarMapCard(key: _mapKey), - ); - } - - Widget _buildForecast() { - if (_forecast == null) return const SizedBox.shrink(); - return ForecastCard(_forecast!); - } - - Widget _buildHistoryTimeline() { - return ResponsiveContainer( - child: Builder( - builder: (context) { - final history = _history; - - if (history == null || history.isEmpty) { - return Column( - children: [ - DateTimelineItem( - TZDateTime.now(UTC).toLocaleFullDateString(context), - first: true, - last: true, - mode: _currentMode, - onModeChanged: _onModeChanged, - isOutOfService: _isOutOfService, - ), - ], - ); - } - - final grouped = groupBy( - history, - (e) => e.time.send.toLocaleFullDateString(context), - ); - - return Column( - children: grouped.entries - .sorted((a, b) => b.key.compareTo(a.key)) - .mapIndexed( - (index, entry) => _buildHistoryGroup(entry, index, history), - ) - .toList(), - ); - }, - ), - ); - } - - Widget _buildHistoryGroup( - MapEntry> entry, - int index, - List allHistory, - ) { - final historyGroup = entry.value.sorted( - (a, b) => b.time.send.compareTo(a.time.send), - ); - - return Column( - children: [ - DateTimelineItem( - entry.key, - first: index == 0, - mode: index == 0 ? _currentMode : null, - onModeChanged: index == 0 ? _onModeChanged : null, - isOutOfService: _isOutOfService, - ), - ...historyGroup.map((item) { - return HistoryTimelineItem( - expired: item.isExpired, - history: item, - last: item == allHistory.last, - ); - }), - ], - ); - } -} From 0ba987fc64fbb10c1290ea0db14ed298dbd0d3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E5=A4=9C?= Date: Tue, 30 Dec 2025 17:40:29 +0800 Subject: [PATCH 3/6] fix: Drag-and-drop-homepage-style --- lib/app/home/page.dart | 40 +------ lib/app/settings/layout/page.dart | 171 ++++++++++++++++-------------- 2 files changed, 99 insertions(+), 112 deletions(-) diff --git a/lib/app/home/page.dart b/lib/app/home/page.dart index ddb43e686..376eb4f31 100644 --- a/lib/app/home/page.dart +++ b/lib/app/home/page.dart @@ -452,12 +452,14 @@ class _HomePageState extends State with WidgetsBindingObserver { children.add(_buildForecast()); if (!_isLoading && _weather != null) children.add(_buildWindCard()); break; + case HomeDisplaySection.wind: + if (!_isLoading && _weather != null) children.add(_buildWindCard()); + break; case HomeDisplaySection.history: children.add(_buildHistoryTimeline()); break; } } - if (homeSections.isEmpty && GlobalProviders.location.code != null) { children.add( Padding( @@ -477,42 +479,10 @@ class _HomePageState extends State with WidgetsBindingObserver { ], ), ), - // 實時警報 - if (!_isLoading) ..._buildRealtimeInfo(), - // 其他區塊 - if (homeSections.isNotEmpty) ...[ - if (homeSections.contains(HomeDisplaySection.radar)) _buildRadarMap(), - if (homeSections.contains(HomeDisplaySection.forecast)) - _buildForecast(), - if (!_isLoading && - homeSections.contains(HomeDisplaySection.wind) && - _weather != null) - _buildWindCard(), - _buildCommunityCards(), - if (homeSections.contains(HomeDisplaySection.history)) - _buildHistoryTimeline(), - ] else if (GlobalProviders.location.code != null) - Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - '您還沒有啟用首頁區塊,請到設定選擇要顯示的內容。'.i18n, - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () => SettingsLayoutRoute().push(context), - child: Text('前往設定'.i18n), - ), - ], - ), - ), - // 底部安全區域 - SizedBox(height: MediaQuery.of(context).padding.bottom + 16), ); }; + children.add(_buildCommunityCards()); + children.add(SizedBox(height: MediaQuery.of(context).padding.bottom + 16)); return Column(children: children); } diff --git a/lib/app/settings/layout/page.dart b/lib/app/settings/layout/page.dart index 147a29c9c..ff99fa0c8 100644 --- a/lib/app/settings/layout/page.dart +++ b/lib/app/settings/layout/page.dart @@ -29,7 +29,10 @@ class SettingsLayoutPage extends StatelessWidget { const SizedBox(height: 16), if (enabledSections.isNotEmpty) ...[ Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Text( '顯示中'.i18n, style: context.texts.labelLarge?.copyWith( @@ -67,7 +70,10 @@ class SettingsLayoutPage extends StatelessWidget { ], if (disabledSections.isNotEmpty) ...[ Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Text( '已隱藏'.i18n, style: context.texts.labelLarge?.copyWith( @@ -75,79 +81,81 @@ class SettingsLayoutPage extends StatelessWidget { ), ), ), - ...disabledSections.map((section) { - final details = _getSectionDetails(section); - return _buildSectionCard( - context, - key: ValueKey(section), - icon: details.icon, - iconColor: details.color, - title: details.title, - subtitle: details.subtitle, - value: false, - onChanged: (v) => model.toggleSection(section, v), - isReorderable: false, - ); - }, - ), - Selector( - selector: (context, model) => model.isEnabled(.forecast), - builder: (context, isEnabled, child) { - return SectionListTile( - leading: ContainedIcon( - Symbols.radar_rounded, - color: Colors.orangeAccent, - ), - title: Text('天氣預報'.i18n), - subtitle: Text('顯示未來 24 小時的天氣預報'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.forecast, value); - }, - ), - ); - }, - ), - Selector( - selector: (context, model) => model.isEnabled(.wind), - builder: (context, isEnabled, child) { - return SectionListTile( - leading: ContainedIcon( - Symbols.wind_power_rounded, - color: Colors.orangeAccent, - ), - title: Text('風向'.i18n), - subtitle: Text('顯示風向與風力級數'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.wind, value); - }, - ), - ); - }, - ), - Selector( - selector: (context, model) => model.isEnabled(.history), - builder: (context, isEnabled, child) { - return SectionListTile( - isLast: true, - leading: ContainedIcon( - Symbols.history_rounded, - color: Colors.greenAccent, - ), - title: Text('歷史事件'.i18n), - subtitle: Text('顯示地震與災害歷史紀錄'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.history, value); - }, - ), - ); - }, - ), + ...disabledSections.map( + (section) { + final details = _getSectionDetails(section); + return _buildSectionCard( + context, + key: ValueKey(section), + icon: details.icon, + iconColor: details.color, + title: details.title, + subtitle: details.subtitle, + value: false, + onChanged: (v) => model.toggleSection(section, v), + isReorderable: false, + ); + }, + ), + Selector( + selector: (context, model) => model.isEnabled(.forecast), + builder: (context, isEnabled, child) { + return SectionListTile( + leading: ContainedIcon( + Symbols.radar_rounded, + color: Colors.orangeAccent, + ), + title: Text('天氣預報'.i18n), + subtitle: Text('顯示未來 24 小時的天氣預報'.i18n), + trailing: Switch( + value: isEnabled, + onChanged: (value) { + context.userInterface.toggleSection(.forecast, value); + }, + ), + ); + }, + ), + Selector( + selector: (context, model) => model.isEnabled(.wind), + builder: (context, isEnabled, child) { + return SectionListTile( + leading: ContainedIcon( + Symbols.wind_power_rounded, + color: Colors.orangeAccent, + ), + title: Text('風向'.i18n), + subtitle: Text('顯示風向與風力級數'.i18n), + trailing: Switch( + value: isEnabled, + onChanged: (value) { + context.userInterface.toggleSection(.wind, value); + }, + ), + ); + }, + ), + Selector( + selector: (context, model) => model.isEnabled(.history), + builder: (context, isEnabled, child) { + return SectionListTile( + isLast: true, + leading: ContainedIcon( + Symbols.history_rounded, + color: Colors.greenAccent, + ), + title: Text('歷史事件'.i18n), + subtitle: Text('顯示地震與災害歷史紀錄'.i18n), + trailing: Switch( + value: isEnabled, + onChanged: (value) { + context.userInterface.toggleSection(.history, value); + }, + ), + ); + }, + ), + ], ], ); }, @@ -155,7 +163,7 @@ class SettingsLayoutPage extends StatelessWidget { } ({IconData icon, Color color, String title, String subtitle}) - _getSectionDetails(HomeDisplaySection section) { + _getSectionDetails(HomeDisplaySection section) { switch (section) { case HomeDisplaySection.radar: return ( @@ -178,6 +186,13 @@ class SettingsLayoutPage extends StatelessWidget { title: '歷史事件'.i18n, subtitle: '顯示地震與災害歷史紀錄'.i18n, ); + case HomeDisplaySection.wind: + return ( + icon: Symbols.wind_power_rounded, + color: Colors.purple, + title: '風向'.i18n, + subtitle: '顯示風向與風力級數'.i18n, + ); } } @@ -258,7 +273,9 @@ class SettingsLayoutPage extends StatelessWidget { if (isReorderable) ...[ Icon( Symbols.drag_handle_rounded, - color: context.colors.onSurfaceVariant.withValues(alpha: 0.5), + color: context.colors.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), const SizedBox(width: 12), ], From f90022e85b29ece541500244d183df76ca5dec31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E5=A4=9C?= Date: Tue, 30 Dec 2025 17:58:27 +0800 Subject: [PATCH 4/6] fix: Drag-and-drop-homepage-style --- lib/app/home/page.dart | 5 +++-- lib/app/settings/layout/page.dart | 7 +++++++ lib/models/settings/ui.dart | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/app/home/page.dart b/lib/app/home/page.dart index 376eb4f31..dadbcb0a9 100644 --- a/lib/app/home/page.dart +++ b/lib/app/home/page.dart @@ -450,7 +450,6 @@ class _HomePageState extends State with WidgetsBindingObserver { break; case HomeDisplaySection.forecast: children.add(_buildForecast()); - if (!_isLoading && _weather != null) children.add(_buildWindCard()); break; case HomeDisplaySection.wind: if (!_isLoading && _weather != null) children.add(_buildWindCard()); @@ -458,6 +457,9 @@ class _HomePageState extends State with WidgetsBindingObserver { case HomeDisplaySection.history: children.add(_buildHistoryTimeline()); break; + case HomeDisplaySection.community: + children.add(_buildCommunityCards()); + break; } } if (homeSections.isEmpty && GlobalProviders.location.code != null) { @@ -481,7 +483,6 @@ class _HomePageState extends State with WidgetsBindingObserver { ), ); }; - children.add(_buildCommunityCards()); children.add(SizedBox(height: MediaQuery.of(context).padding.bottom + 16)); return Column(children: children); diff --git a/lib/app/settings/layout/page.dart b/lib/app/settings/layout/page.dart index ff99fa0c8..26409d2c2 100644 --- a/lib/app/settings/layout/page.dart +++ b/lib/app/settings/layout/page.dart @@ -193,6 +193,13 @@ class SettingsLayoutPage extends StatelessWidget { title: '風向'.i18n, subtitle: '顯示風向與風力級數'.i18n, ); + case HomeDisplaySection.community: + return ( + icon: Symbols.people_alt_rounded, + color: Colors.teal, + title: '社群動態'.i18n, + subtitle: '顯示來自社群的即時資訊'.i18n, + ); } } diff --git a/lib/models/settings/ui.dart b/lib/models/settings/ui.dart index 0d3ae79a9..7e094d068 100644 --- a/lib/models/settings/ui.dart +++ b/lib/models/settings/ui.dart @@ -12,6 +12,7 @@ enum HomeDisplaySection { forecast, history, wind, + community, } class SettingsUserInterfaceModel extends ChangeNotifier { From a0320beb7971ec0c19ff4bc5c5bc25ea973d8961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E5=A4=9C?= Date: Tue, 30 Dec 2025 18:33:08 +0800 Subject: [PATCH 5/6] fix: Drag-and-drop-homepage-style --- lib/app/settings/layout/page.dart | 58 ------------------------------- 1 file changed, 58 deletions(-) diff --git a/lib/app/settings/layout/page.dart b/lib/app/settings/layout/page.dart index 26409d2c2..cd631e18a 100644 --- a/lib/app/settings/layout/page.dart +++ b/lib/app/settings/layout/page.dart @@ -97,64 +97,6 @@ class SettingsLayoutPage extends StatelessWidget { ); }, ), - Selector( - selector: (context, model) => model.isEnabled(.forecast), - builder: (context, isEnabled, child) { - return SectionListTile( - leading: ContainedIcon( - Symbols.radar_rounded, - color: Colors.orangeAccent, - ), - title: Text('天氣預報'.i18n), - subtitle: Text('顯示未來 24 小時的天氣預報'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.forecast, value); - }, - ), - ); - }, - ), - Selector( - selector: (context, model) => model.isEnabled(.wind), - builder: (context, isEnabled, child) { - return SectionListTile( - leading: ContainedIcon( - Symbols.wind_power_rounded, - color: Colors.orangeAccent, - ), - title: Text('風向'.i18n), - subtitle: Text('顯示風向與風力級數'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.wind, value); - }, - ), - ); - }, - ), - Selector( - selector: (context, model) => model.isEnabled(.history), - builder: (context, isEnabled, child) { - return SectionListTile( - isLast: true, - leading: ContainedIcon( - Symbols.history_rounded, - color: Colors.greenAccent, - ), - title: Text('歷史事件'.i18n), - subtitle: Text('顯示地震與災害歷史紀錄'.i18n), - trailing: Switch( - value: isEnabled, - onChanged: (value) { - context.userInterface.toggleSection(.history, value); - }, - ), - ); - }, - ), ], ], ); From 80c3c66f332173fa41c996ed761e52604b80cd79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9C=E5=A4=9C?= Date: Tue, 30 Dec 2025 19:58:05 +0800 Subject: [PATCH 6/6] fix: Drag-and-drop-homepage-style --- lib/app/settings/layout/page.dart | 9 ++++++--- lib/models/settings/ui.dart | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/app/settings/layout/page.dart b/lib/app/settings/layout/page.dart index cd631e18a..19435f29f 100644 --- a/lib/app/settings/layout/page.dart +++ b/lib/app/settings/layout/page.dart @@ -62,7 +62,10 @@ class SettingsLayoutPage extends StatelessWidget { title: details.title, subtitle: details.subtitle, value: true, - onChanged: (v) => model.toggleSection(section, v), + onChanged: (section == HomeDisplaySection.history || + section == HomeDisplaySection.community) + ? null + : (v) => model.toggleSection(section, v), isReorderable: true, ); }, @@ -200,7 +203,7 @@ class SettingsLayoutPage extends StatelessWidget { required String title, required String subtitle, required bool value, - required ValueChanged onChanged, + required ValueChanged? onChanged, required bool isReorderable, }) { return Container( @@ -214,7 +217,7 @@ class SettingsLayoutPage extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), - onTap: () => onChanged(!value), + onTap: onChanged == null ? null : () => onChanged(!value), child: Padding( padding: const EdgeInsets.all(16), child: Row( diff --git a/lib/models/settings/ui.dart b/lib/models/settings/ui.dart index 7e094d068..3600bfb0b 100644 --- a/lib/models/settings/ui.dart +++ b/lib/models/settings/ui.dart @@ -10,8 +10,8 @@ import 'package:provider/provider.dart'; enum HomeDisplaySection { radar, forecast, - history, wind, + history, community, }