diff --git a/lib/app.dart b/lib/app.dart index e0a8e4c..53f8691 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -40,6 +40,7 @@ import 'package:geopod/services/places_service.dart'; /// The root application widget. /// /// Uses SolidLogin from solidui for authentication UI. + class App extends StatelessWidget { const App({super.key}); @@ -47,14 +48,15 @@ class App extends StatelessWidget { Widget build(BuildContext context) { // Register global callback for clearing caches during logout // This is called once at app startup to ensure caches are cleared - // BEFORE any blocking network operations during logout + // BEFORE any blocking network operations during logout. registerLogoutCacheCallback(() async { await PlacesService.clearCache(); + // Note: MapSettings are user preferences, not user data, - // so we don't clear them during logout + // so we don't clear them during logout. }); - // Wrap appScaffold to ensure preload happens on navigation + // Wrap appScaffold to ensure preload happens on navigation. final appWithPreload = _AppScaffoldWrapper(child: appScaffold); final loginWidget = SolidLogin( @@ -76,6 +78,7 @@ class App extends StatelessWidget { /// Preloads data on app startup for instant map page access. /// All platforms use this for initial data preloading. + class _StartupPreloader extends StatefulWidget { const _StartupPreloader({required this.child}); @@ -91,7 +94,8 @@ class _StartupPreloaderState extends State<_StartupPreloader> { super.initState(); // Preload map settings on app startup (both guests and logged-in users) - // Places data is now loaded on-demand by the map page + // Places data is now loaded on-demand by the map page. + WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(preloadMapSettings()); }); @@ -106,6 +110,7 @@ class _StartupPreloaderState extends State<_StartupPreloader> { /// Wrapper that triggers preload when navigated to (for Continue button). /// This ensures data is preloaded even when user navigates via Continue button /// after the initial app startup preload. + class _AppScaffoldWrapper extends StatefulWidget { const _AppScaffoldWrapper({required this.child}); @@ -121,10 +126,13 @@ class _AppScaffoldWrapperState extends State<_AppScaffoldWrapper> { super.initState(); // Trigger preload when this widget is mounted (after login) - // Only preload local settings, network data will be loaded by pages themselves + // Only preload local settings, network data will be loaded by pages themselves. + WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(preloadMapSettings()); - // Sync settings from POD with delay to avoid network congestion + + // Sync settings from POD with delay to avoid network congestion. + unawaited(syncSettingsFromPod()); }); } diff --git a/lib/app_scaffold.dart b/lib/app_scaffold.dart index 74f96af..54a8155 100644 --- a/lib/app_scaffold.dart +++ b/lib/app_scaffold.dart @@ -37,10 +37,12 @@ import 'widgets/geomap.dart'; import 'widgets/locations_page.dart'; /// App scaffold widget that responds to fullscreen mode changes. + class AppScaffoldWidget extends StatelessWidget { const AppScaffoldWidget({super.key}); /// Global key to access the GeoMap state for settings dialog. + static final GlobalKey geoMapKey = GlobalKey(); @@ -50,10 +52,11 @@ class AppScaffoldWidget extends StatelessWidget { valueListenable: fullscreenModeNotifier, builder: (context, isFullscreen, child) { if (isFullscreen) { - // Fullscreen mode: show only the Home content without navigation + // Fullscreen mode: show only the Home content without navigation. return Home(title: appTitle, geoMapKey: geoMapKey); } - // Normal mode: show full scaffold with navigation + + // Normal mode: show full scaffold with navigation. return _buildFullScaffold(); }, ); @@ -61,7 +64,7 @@ class AppScaffoldWidget extends StatelessWidget { Widget _buildFullScaffold() { return SolidScaffold( - // MENU + // MENU. menu: [ SolidMenuItem( icon: Icons.home, @@ -130,11 +133,11 @@ class AppScaffoldWidget extends StatelessWidget { ), ], - // APP BAR + // APP BAR. appBar: SolidAppBarConfig( title: appTitle.split('-')[0], - // VERSION + // VERSION. versionConfig: const SolidVersionConfig( changelogUrl: 'https://github.com/gjwgit/geopod/blob/dev/' @@ -146,7 +149,7 @@ class AppScaffoldWidget extends StatelessWidget { SolidAppBarAction( icon: Icons.settings, onPressed: () { - // Call the GeoMap's settings dialog + // Call the GeoMap's settings dialog. AppScaffoldWidget.geoMapKey.currentState?.showSettingsDialog(); }, tooltip: 'Settings', @@ -155,7 +158,7 @@ class AppScaffoldWidget extends StatelessWidget { overflowItems: [], ), - // STATUS BAR + // STATUS BAR. statusBar: const SolidStatusBarConfig( serverInfo: SolidServerInfo( serverUri: 'https://pods.solidcommunity.au', @@ -165,7 +168,7 @@ class AppScaffoldWidget extends StatelessWidget { showOnNarrowScreens: true, // Show status bar on Android/mobile ), - // ABOUT + // ABOUT. aboutConfig: SolidAboutConfig( applicationName: appTitle.split(' - ')[0], applicationIcon: Image.asset( @@ -185,7 +188,7 @@ class AppScaffoldWidget extends StatelessWidget { ''', ), - // THEME DARK/LIGHT Mode + // THEME DARK/LIGHT Mode. themeToggle: const SolidThemeToggleConfig( enabled: true, showInAppBarActions: true, @@ -197,4 +200,5 @@ class AppScaffoldWidget extends StatelessWidget { } /// Convenience variable for backward compatibility. + final appScaffold = const AppScaffoldWidget(); diff --git a/lib/constants/example_places_data.dart b/lib/constants/example_places_data.dart index 127267e..9502110 100644 --- a/lib/constants/example_places_data.dart +++ b/lib/constants/example_places_data.dart @@ -15,6 +15,7 @@ library; /// Raw example places data as a constant list. /// This data is compiled into the binary, ensuring zero-latency access. + const List> kExamplePlacesData = [ { 'id': 'local_001', diff --git a/lib/main.dart b/lib/main.dart index fa2395c..240503f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -56,15 +56,16 @@ void main() async { // CRITICAL: Set app directory name BEFORE any Pod operations // This must be called early to prevent double-slash bug in file paths - // Without this, paths become //data/places.json instead of geopod/data/places.json + // Without this, paths become //data/places.json instead of geopod/data/places.json. await setAppDirName('geopod'); // Initialize encrypted places service to load persistent flags - // This improves performance by avoiding repeated checks + // This improves performance by avoiding repeated checks. await EncryptedPlacesService.initialize(); // Configure SolidAuthHandler with app-specific settings - // This ensures proper login page navigation when guest users want to authenticate + // This ensures proper login page navigation when guest users want to authenticate. + SolidAuthHandler.instance.configure( SolidAuthConfig( appTitle: appTitle, @@ -73,7 +74,8 @@ void main() async { appImage: const AssetImage('assets/images/app_image.png'), appLogo: const AssetImage('assets/images/app_icon.png'), loginSuccessWidget: appScaffold, - // Clear security key on logout to ensure clean state + + // Clear security key on logout to ensure clean state. onSecurityKeyReset: () async { await KeyManager.clear(); debugPrint('GeoPod: Security key cleared on logout'); diff --git a/lib/models/hourly_weather_data.dart b/lib/models/hourly_weather_data.dart index ce39a88..3b6a632 100644 --- a/lib/models/hourly_weather_data.dart +++ b/lib/models/hourly_weather_data.dart @@ -11,6 +11,7 @@ library; /// Hourly weather data point. + class HourlyWeatherPoint { HourlyWeatherPoint({ required this.time, @@ -28,6 +29,7 @@ class HourlyWeatherPoint { } /// Hourly weather data series. + class HourlyWeatherData { HourlyWeatherData({ required this.data, @@ -74,6 +76,7 @@ class HourlyWeatherData { final DateTime endDate; /// Get daily average temperatures. + Map getDailyAverages() { final dailyTemps = >{}; @@ -89,6 +92,7 @@ class HourlyWeatherData { } /// Get daily average humidity. + Map getDailyAverageHumidity() { final dailyHumidity = >{}; @@ -107,6 +111,7 @@ class HourlyWeatherData { } /// Get daily average wind speed. + Map getDailyAverageWindSpeed() { final dailyWindSpeed = >{}; @@ -124,6 +129,7 @@ class HourlyWeatherData { /// Get daily min/max values for a specific data type. /// Returns a map where each date maps to (min, max) tuple. + Map getDailyMinMax(String dataType) { final dailyValues = >{}; @@ -159,6 +165,7 @@ class HourlyWeatherData { } /// Get temperature range (min, max). + (double min, double max) getTemperatureRange() { var min = data.first.temperature; var max = data.first.temperature; @@ -172,6 +179,7 @@ class HourlyWeatherData { } /// Get humidity range (min, max). + (double min, double max) getHumidityRange() { final validPoints = data.where((p) => p.humidity != null).toList(); if (validPoints.isEmpty) return (0, 100); @@ -189,6 +197,7 @@ class HourlyWeatherData { } /// Get wind speed range (min, max). + (double min, double max) getWindSpeedRange() { final validPoints = data.where((p) => p.windSpeed != null).toList(); if (validPoints.isEmpty) return (0, 30); @@ -206,11 +215,12 @@ class HourlyWeatherData { /// Get daily total precipitation. /// Sums all hourly precipitation values for each day. + Map getDailyTotalPrecipitation() { final dailyPrecipitation = >{}; for (final point in data) { - // Include 0 values, skip only null + // Include 0 values, skip only null. if (point.precipitation == null) continue; final date = DateTime(point.time.year, point.time.month, point.time.day); dailyPrecipitation.putIfAbsent(date, () => []).add(point.precipitation!); @@ -218,6 +228,7 @@ class HourlyWeatherData { // Return empty map ONLY if no precipitation data exists at all // If all values are 0, we still return the data (not empty map) + if (dailyPrecipitation.isEmpty) return {}; return dailyPrecipitation.map( @@ -230,6 +241,7 @@ class HourlyWeatherData { /// Get count of hours with precipitation for each day. /// Counts hours where precipitation > 0. + Map getDailyPrecipitationHours() { final dailyHours = {}; @@ -238,10 +250,11 @@ class HourlyWeatherData { final date = DateTime(point.time.year, point.time.month, point.time.day); // Count hours with measurable precipitation (> 0) + if (point.precipitation! > 0) { dailyHours[date] = (dailyHours[date] ?? 0) + 1; } else { - // Ensure date exists in map even if no precipitation + // Ensure date exists in map even if no precipitation. dailyHours.putIfAbsent(date, () => 0); } } @@ -250,6 +263,7 @@ class HourlyWeatherData { } /// Get precipitation range (min, max) for hourly data. + (double min, double max) getPrecipitationRange() { final validPoints = data.where((p) => p.precipitation != null).toList(); if (validPoints.isEmpty) return (0, 2); // Smaller default range @@ -262,7 +276,8 @@ class HourlyWeatherData { if (point.precipitation! > max) max = point.precipitation!; } - // If all values are 0 or very close, set a small visible range + // If all values are 0 or very close, set a small visible range. + if (max < 0.1) { return (0, 1.0); // Show 0-1mm range for very small/zero precipitation } @@ -277,6 +292,7 @@ class HourlyWeatherData { /// Get daily total precipitation range (min, max). /// Used for chart axis scaling when displaying daily totals. /// Min is always 0 (precipitation cannot be negative). + (double min, double max) getDailyTotalPrecipitationRange() { final dailyTotals = getDailyTotalPrecipitation(); if (dailyTotals.isEmpty) return (0, 10); // Default range for no data @@ -288,7 +304,8 @@ class HourlyWeatherData { if (value > maxValue) maxValue = value; } - // If all values are 0 or very close, set a visible range + // If all values are 0 or very close, set a visible range. + if (maxValue < 0.5) { return (0, 5.0); // Show 0-5mm range for very small/zero precipitation } @@ -297,7 +314,7 @@ class HourlyWeatherData { return (0, maxValue + 5.0); } - // Add some padding to the max for better visualization + // Add some padding to the max for better visualization. return (0, maxValue * 1.1); } } diff --git a/lib/models/place.dart b/lib/models/place.dart index 3602f92..2690c5a 100644 --- a/lib/models/place.dart +++ b/lib/models/place.dart @@ -26,6 +26,7 @@ library; /// Data model representing a saved place. + class Place { final String id; final double lat; @@ -56,6 +57,7 @@ class Place { /// Creates a Place from JSON map. /// /// [isLocalSource] indicates if the JSON comes from local assets. + factory Place.fromJson( Map json, { bool isLocalSource = false, @@ -77,6 +79,7 @@ class Place { /// Converts Place to JSON map. /// Note: isLocal is not serialized as it's determined by source. + Map toJson() { return { 'id': id, @@ -90,18 +93,22 @@ class Place { /// Returns a formatted display string for the place. /// Now returns the full note without truncation. + String get displayTitle { return note.isNotEmpty ? note : '(No title)'; } /// Returns formatted coordinates string. + String get coordinates => '${lat.toStringAsFixed(4)}, ${lng.toStringAsFixed(4)}'; /// Returns the address or coordinates if address is not available. + String get displayAddress => address ?? coordinates; /// Returns a short version of the address for display in limited space. + String get shortAddress { if (address == null || address!.isEmpty) { return coordinates; @@ -113,6 +120,7 @@ class Place { } /// Returns formatted date string. + String get formattedDate { try { final date = DateTime.parse(timestamp); @@ -124,6 +132,7 @@ class Place { } /// Creates a copy of this Place with optional field overrides. + Place copyWith({ String? id, double? lat, diff --git a/lib/models/pod_file_item.dart b/lib/models/pod_file_item.dart index 2b0c93c..efe07d8 100644 --- a/lib/models/pod_file_item.dart +++ b/lib/models/pod_file_item.dart @@ -11,6 +11,7 @@ library; /// Represents a file or directory item in the POD. + class PodFileItem { /// The name of the file or directory. final String name; @@ -40,11 +41,13 @@ class PodFileItem { }); /// Creates a directory item. + factory PodFileItem.directory({required String name, required String path}) { return PodFileItem(name: name, path: path, isDirectory: true); } /// Creates a file item. + factory PodFileItem.file({ required String name, required String path, @@ -63,6 +66,7 @@ class PodFileItem { } /// Get file extension (lowercase, without dot). + String? get extension { if (isDirectory) return null; final dot = name.lastIndexOf('.'); @@ -71,6 +75,7 @@ class PodFileItem { } /// Check if this is a text file based on extension. + bool get isTextFile { const textExtensions = { 'txt', @@ -98,12 +103,14 @@ class PodFileItem { } /// Check if this is an image file based on extension. + bool get isImageFile { const imageExtensions = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico'}; return imageExtensions.contains(extension); } /// Check if this is a media file based on extension. + bool get isMediaFile { const mediaExtensions = {'mp3', 'wav', 'ogg', 'mp4', 'webm', 'avi'}; return mediaExtensions.contains(extension); diff --git a/lib/models/weather_data.dart b/lib/models/weather_data.dart index 5641841..52dcb6d 100644 --- a/lib/models/weather_data.dart +++ b/lib/models/weather_data.dart @@ -11,6 +11,7 @@ library; /// Weather data from Open-Meteo API. + class WeatherData { WeatherData({ required this.temperature, @@ -28,7 +29,8 @@ class WeatherData { factory WeatherData.fromJson(Map json) { final current = json['current'] as Map; - // Get today's min/max temperature from daily data if available + // Get today's min/max temperature from daily data if available. + double? maxTemp; double? minTemp; if (json.containsKey('daily')) { @@ -43,7 +45,8 @@ class WeatherData { } } - // Calculate today's total precipitation from hourly data + // Calculate today's total precipitation from hourly data. + double? todayTotal; if (json.containsKey('hourly')) { final hourly = json['hourly'] as Map; @@ -55,7 +58,9 @@ class WeatherData { double sum = 0.0; for (var i = 0; i < times.length; i++) { final time = DateTime.parse(times[i]); - // Only sum precipitation for hours up to current time today + + // Only sum precipitation for hours up to current time today. + if (time.year == now.year && time.month == now.month && time.day == now.day && @@ -94,6 +99,7 @@ class WeatherData { todayTotalPrecipitation; // Total precipitation accumulated today /// Get weather description from WMO weather code. + String get weatherDescription { return switch (weatherCode) { 0 => 'Clear sky', @@ -129,6 +135,7 @@ class WeatherData { } /// Get weather icon based on weather code. + String get weatherIcon { return switch (weatherCode) { 0 || 1 => '☀️', @@ -146,7 +153,8 @@ class WeatherData { } /// Get wind direction description from degrees. - /// 0° = North, 90° = East, 180° = South, 270° = West + /// 0° = North, 90° = East, 180° = South, 270° = West. + String get windDirectionDescription { if (windDirection >= 337.5 || windDirection < 22.5) return 'N'; if (windDirection >= 22.5 && windDirection < 67.5) return 'NE'; @@ -160,6 +168,7 @@ class WeatherData { } /// Get wind direction full name. + String get windDirectionFullName { return switch (windDirectionDescription) { 'N' => 'North', @@ -177,6 +186,7 @@ class WeatherData { /// Get arrow icon for wind direction. /// Wind direction follows meteorological convention: indicates where wind is coming FROM. /// Arrow points toward the direction the wind is coming FROM (e.g., 90° = East wind = arrow points → toward east). + String get windDirectionArrow { if (windDirection >= 337.5 || windDirection < 22.5) return '↑'; if (windDirection >= 22.5 && windDirection < 67.5) return '↗'; diff --git a/lib/services/fullscreen_service.dart b/lib/services/fullscreen_service.dart index 665ead0..ebeea63 100644 --- a/lib/services/fullscreen_service.dart +++ b/lib/services/fullscreen_service.dart @@ -14,9 +14,11 @@ import 'package:flutter/foundation.dart'; /// Global notifier for fullscreen mode state. /// When true, sidebars and navigation elements should be hidden. + final ValueNotifier fullscreenModeNotifier = ValueNotifier(false); /// Toggles the fullscreen mode on/off. + void toggleFullscreenMode() { fullscreenModeNotifier.value = !fullscreenModeNotifier.value; } diff --git a/lib/services/gdelt_news_service.dart b/lib/services/gdelt_news_service.dart index 224a658..03e3597 100644 --- a/lib/services/gdelt_news_service.dart +++ b/lib/services/gdelt_news_service.dart @@ -24,6 +24,7 @@ import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; /// Represents a news marker with geographic location and metadata. + class NewsMarker { final String id; final LatLng location; @@ -46,6 +47,7 @@ class NewsMarker { }); /// Parse a NewsMarker from GDELT GeoJSON feature. + factory NewsMarker.fromGeoJson(Map feature) { final geometry = feature['geometry'] as Map; final properties = feature['properties'] as Map?; @@ -54,13 +56,14 @@ class NewsMarker { final lng = (coordinates[0] as num).toDouble(); final lat = (coordinates[1] as num).toDouble(); - // Extract title and URL from HTML field + // Extract title and URL from HTML field. + String title = properties?['name']?.toString() ?? 'No title'; String? url; final html = properties?['html']?.toString(); if (html != null && html.isNotEmpty) { - // Parse HTML to extract title and URL + // Parse HTML to extract title and URL. final hrefMatch = RegExp(r'href="([^"]+)"').firstMatch(html); final titleMatch = RegExp(r'>([^<]+)').firstMatch(html); @@ -86,6 +89,7 @@ class NewsMarker { } /// Service for fetching news from GDELT GeoJSON API with debouncing and caching. + class GdeltNewsService { static const String _baseUrl = 'https://api.gdeltproject.org/api/v2/geo/geo'; static const Duration _debounceDuration = Duration(milliseconds: 500); @@ -94,13 +98,14 @@ class GdeltNewsService { DateTime? _lastFetchTime; static const Duration _minFetchInterval = Duration(seconds: 2); - // Cache for storing fetched news markers + // Cache for storing fetched news markers. final List _cachedMarkers = []; LatLngBounds? _cachedBounds; DateTime? _cacheTime; static const Duration _cacheExpiry = Duration(minutes: 10); /// Fetch news markers within the specified bounds with debouncing and caching. + Future> fetchNews({ required LatLngBounds bounds, String query = 'news', @@ -137,7 +142,8 @@ class GdeltNewsService { timeSpan: timeSpan, ); - // Update cache + // Update cache. + _cachedMarkers.clear(); _cachedMarkers.addAll(markers); _cachedBounds = bounds; @@ -154,6 +160,7 @@ class GdeltNewsService { } /// Perform the actual API fetch without debouncing. + Future> _performFetch({ required LatLngBounds bounds, required String query, @@ -216,6 +223,7 @@ class GdeltNewsService { } /// Calculate approximate radius in kilometers from bounds. + double _calculateRadiusKm(LatLngBounds bounds) { const Distance distance = Distance(); @@ -223,19 +231,22 @@ class GdeltNewsService { final southEast = LatLng(bounds.south, bounds.east); final diagonalMeters = distance(northWest, southEast); - // Use half diagonal and limit to max 500km for faster queries + + // Use half diagonal and limit to max 500km for faster queries. return (diagonalMeters / 1000 / 2).clamp(1, 500); } /// Filter cached markers for the given bounds without making API call. + List getMarkersInBounds(LatLngBounds bounds) { if (_cachedMarkers.isEmpty) return []; - // Check if cache is still valid + // Check if cache is still valid. + if (_cacheTime != null) { final elapsed = DateTime.now().difference(_cacheTime!); if (elapsed > _cacheExpiry) { - // Cache expired, clear it + // Cache expired, clear it. _cachedMarkers.clear(); _cachedBounds = null; _cacheTime = null; @@ -243,7 +254,7 @@ class GdeltNewsService { } } - // Filter cached markers that are within the requested bounds + // Filter cached markers that are within the requested bounds. return _cachedMarkers.where((marker) { return marker.location.latitude >= bounds.south && marker.location.latitude <= bounds.north && @@ -253,19 +264,20 @@ class GdeltNewsService { } /// Check if the given bounds are mostly covered by cached data. + bool isBoundsCovered(LatLngBounds bounds) { if (_cachedBounds == null || _cacheTime == null) return false; - // Check if cache is still valid + // Check if cache is still valid. final elapsed = DateTime.now().difference(_cacheTime!); if (elapsed > _cacheExpiry) return false; // Calculate cache coverage percentage - // Only fetch new data if more than 40% of the view is outside cache + // Only fetch new data if more than 40% of the view is outside cache. final viewLatRange = bounds.north - bounds.south; final viewLngRange = bounds.east - bounds.west; - // Calculate overlap + // Calculate overlap. final overlapSouth = bounds.south.clamp( _cachedBounds!.south, _cachedBounds!.north, @@ -290,11 +302,12 @@ class GdeltNewsService { final lngCoverage = viewLngRange > 0 ? overlapLngRange / viewLngRange : 0.0; final coverage = (latCoverage + lngCoverage) / 2; - // Consider covered if at least 60% of view is in cache + // Consider covered if at least 60% of view is in cache. return coverage >= 0.6; } /// Clear the cache manually. + void clearCache() { _cachedMarkers.clear(); _cachedBounds = null; @@ -302,6 +315,7 @@ class GdeltNewsService { } /// Cancel any pending debounced requests. + void dispose() { _debounceTimer?.cancel(); clearCache(); diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart index 56711b3..fbc59a9 100644 --- a/lib/services/geocoding_service.dart +++ b/lib/services/geocoding_service.dart @@ -33,18 +33,21 @@ import 'package:http/http.dart' as http; /// /// Uses OpenStreetMap's Nominatim API which is free and works on both /// Web and Desktop platforms without requiring any API keys. + class GeocodingService { /// Nominatim API endpoint for reverse geocoding. static const String _nominatimEndpoint = 'https://nominatim.openstreetmap.org/reverse'; /// User-Agent header required by Nominatim API. + static const String _userAgent = 'GeopodApp/1.0 (Flutter)'; /// Converts latitude/longitude coordinates to a human-readable address. /// /// Returns "Address not found" if the request fails or no address is found. /// Always returns addresses in English. + static Future getAddress(double lat, double lng) async { try { final uri = Uri.parse( @@ -80,6 +83,7 @@ class GeocodingService { /// Gets a shortened version of the address (city, state, country). /// /// This extracts key parts from the full address for display in limited space. + static Future getShortAddress(double lat, double lng) async { try { final uri = Uri.parse( diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index e28af72..a301cf3 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -18,6 +18,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; /// Result of location request with detailed error message. + class LocationResult { final LatLng? location; final String? errorMessage; @@ -32,12 +33,13 @@ class LocationResult { } /// Service class for handling location operations. + class LocationService { /// Get current user location with detailed error information. /// Returns LocationResult with location or error message. static Future getCurrentLocation() async { try { - // Check if location services are enabled + // Check if location services are enabled. bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { return LocationResult.error( @@ -45,7 +47,8 @@ class LocationService { ); } - // Check permission + // Check permission. + LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { @@ -64,7 +67,8 @@ class LocationService { ); } - // Get current position with platform-specific settings + // Get current position with platform-specific settings. + Position position = await Geolocator.getCurrentPosition( locationSettings: kIsWeb ? const LocationSettings( @@ -89,7 +93,7 @@ class LocationService { 'Location request timed out. Please check your GPS signal and try again.', ); } catch (e) { - // Check for specific error messages + // Check for specific error messages. final errorStr = e.toString().toLowerCase(); if (errorStr.contains('location') && errorStr.contains('disabled')) { return LocationResult.error( @@ -102,6 +106,7 @@ class LocationService { } /// Check if location permission is granted. + static Future hasLocationPermission() async { LocationPermission permission = await Geolocator.checkPermission(); return permission == LocationPermission.always || @@ -109,6 +114,7 @@ class LocationService { } /// Open app settings to allow user to enable location permission. + static Future openAppSettings() async { return await Geolocator.openAppSettings(); } diff --git a/lib/services/map_settings_pod.dart b/lib/services/map_settings_pod.dart index f7e90e0..47f3b1f 100644 --- a/lib/services/map_settings_pod.dart +++ b/lib/services/map_settings_pod.dart @@ -25,11 +25,13 @@ import 'package:geopod/services/pod/pod_directory_service.dart'; const String settingsFileName = 'settings.json'; /// Keys for last viewport storage. + const String keyLastLat = 'map_last_lat'; const String keyLastLng = 'map_last_lng'; const String keyLastZoom = 'map_last_zoom'; /// Get the full file path for settings in POD. + Future getSettingsFilePath() async { final path = await getDataDirPath(); return '$path/$settingsFileName'; @@ -37,6 +39,7 @@ Future getSettingsFilePath() async { /// Read settings from POD. /// Returns null if not logged in or if no settings exist. + Future?> readSettingsFromPod() async { try { // Quick sync check - avoid slow async checkLoggedIn() @@ -68,6 +71,7 @@ Future?> readSettingsFromPod() async { /// Write settings to POD (silently, in background). /// Note: This is only called when user is logged in (from settings dialog close). + Future writeSettingsToPod(Map data) async { try { final fp = await getSettingsFilePath(); @@ -97,6 +101,7 @@ Future writeSettingsToPod(Map data) async { } /// Represents a map viewport position (center + zoom). + class ViewportPosition { final double lat; final double lng; @@ -110,6 +115,7 @@ class ViewportPosition { } /// Saves the last viewed viewport position. + Future saveLastViewport({ required double lat, required double lng, @@ -127,6 +133,7 @@ Future saveLastViewport({ } /// Loads the last viewed viewport position. + Future loadLastViewport() async { try { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/services/map_settings_service.dart b/lib/services/map_settings_service.dart index b85dd0d..2e324c2 100644 --- a/lib/services/map_settings_service.dart +++ b/lib/services/map_settings_service.dart @@ -39,6 +39,7 @@ export 'package:geopod/services/map_settings_pod.dart' show ViewportPosition; export 'package:geopod/services/map_source.dart'; /// Keys for SharedPreferences storage. + const String _keyShowLocalPlaces = 'map_show_local_places'; const String _keyShowEncryptedPlaces = 'map_show_encrypted_places'; const String _keyHideAllMarkers = 'map_hide_all_markers'; @@ -52,16 +53,19 @@ const String _keyInitialLng = 'map_initial_lng'; const String _keyInitialZoom = 'map_initial_zoom'; /// Default viewport settings (Darwin centered). + const double defaultInitialLat = -12.4634; const double defaultInitialLng = 130.8456; const double defaultInitialZoom = 11.0; /// Default colors for map markers. + const Color defaultUserColor = Colors.blue; const Color defaultLocalColor = Colors.red; const Color defaultEncryptedColor = Colors.purple; /// Data class holding all map display settings. + class MapSettings { /// Whether to show local (canned example) places on the map. final bool showLocalPlaces; @@ -115,11 +119,13 @@ class MapSettings { /// Time-based default map source. /// Always defaults to OpenStreetMap. /// Night mode styling is handled by app theme + color filter. + static MapSource getDefaultMapSource() { return MapSource.openStreetMap; } /// Creates a copy with optional overrides. + MapSettings copyWith({ bool? showLocalPlaces, bool? showEncryptedPlaces, @@ -150,6 +156,7 @@ class MapSettings { } /// Service for loading and saving map display settings. + class MapSettingsService { /// Convert MapSettings to JSON map. static Map _settingsToJson(MapSettings settings) { @@ -169,6 +176,7 @@ class MapSettingsService { } /// Create MapSettings from JSON map. + static MapSettings _settingsFromJson(Map json) { final savedSourceIndex = json['mapSource'] as int?; final mapSource = @@ -201,6 +209,7 @@ class MapSettingsService { } /// Save settings to SharedPreferences. + static Future _saveToPrefs(MapSettings settings) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_keyShowLocalPlaces, settings.showLocalPlaces); @@ -226,6 +235,7 @@ class MapSettingsService { } /// Load settings from SharedPreferences. + static Future _loadFromPrefs() async { final prefs = await SharedPreferences.getInstance(); @@ -270,14 +280,17 @@ class MapSettingsService { } /// Check if we have cached settings in SharedPreferences. + static Future _hasLocalCache() async { final prefs = await SharedPreferences.getInstance(); + // Check if any setting key exists (mapSource is always saved) return prefs.containsKey(_keyMapSource); } /// Loads settings from SharedPreferences (fast, non-blocking). /// POD sync is done separately via syncFromPod(). + static Future loadSettings() async { try { // Always load from SharedPreferences first (fast, no network) @@ -290,22 +303,25 @@ class MapSettingsService { /// Smart load: if no local cache, try POD first (for first login). /// Otherwise load from local cache (fast). + static Future loadSettingsSmart() async { try { - // Check if we have local cache + // Check if we have local cache. if (await _hasLocalCache()) { debugPrint('loadSettingsSmart: using local cache'); return await _loadFromPrefs(); } - // No local cache - only try POD if logged in + // No local cache - only try POD if logged in. + if (authStateNotifier.value) { debugPrint('loadSettingsSmart: no local cache, trying POD...'); final podData = await readSettingsFromPod(); if (podData != null) { debugPrint('loadSettingsSmart: loaded from POD'); final settings = _settingsFromJson(podData); - // Save to local cache + + // Save to local cache. await _saveToPrefs(settings); return settings; } @@ -314,7 +330,7 @@ class MapSettingsService { debugPrint('loadSettingsSmart: not logged in, using defaults'); } - // Use defaults + // Use defaults. return MapSettings(mapSource: MapSettings.getDefaultMapSource()); } catch (e) { debugPrint('Error in loadSettingsSmart: $e'); @@ -324,6 +340,7 @@ class MapSettingsService { /// Saves settings to SharedPreferences only (fast, no network). /// POD sync is done separately via syncToPod() when needed. + static Future saveSettings(MapSettings settings) async { try { // Save to SharedPreferences only (fast, no blocking) @@ -337,6 +354,7 @@ class MapSettingsService { /// Manually sync current settings to POD. /// Call this when user closes settings dialog or at app exit. + static Future syncToPod() async { try { final settings = await _loadFromPrefs(); @@ -350,6 +368,7 @@ class MapSettingsService { /// Gets the initial viewport based on settings. /// If rememberViewport is ON, returns last viewport if available. /// Otherwise returns the configured initial viewport. + static Future getStartupViewport( MapSettings settings, ) async { @@ -365,6 +384,7 @@ class MapSettingsService { } /// Resets all settings to defaults. + static Future resetToDefaults() async { try { final prefs = await SharedPreferences.getInstance(); @@ -399,8 +419,9 @@ class MapSettingsService { /// Sync settings from POD to local (background, non-blocking). /// Call this after login to ensure local settings match POD. /// Returns the synced settings if POD has data, null otherwise. + static Future syncFromPod() async { - // CRITICAL: Only sync if logged in + // CRITICAL: Only sync if logged in. if (!authStateNotifier.value) { debugPrint('syncFromPod: skipped (not logged in)'); return null; @@ -414,7 +435,7 @@ class MapSettingsService { await _saveToPrefs(settings); return settings; } else { - // POD has no settings, upload local settings to POD + // POD has no settings, upload local settings to POD. debugPrint('syncFromPod: POD empty, uploading local settings'); final localSettings = await _loadFromPrefs(); unawaited(writeSettingsToPod(_settingsToJson(localSettings))); @@ -429,6 +450,7 @@ class MapSettingsService { /// Start background sync from POD. /// This is non-blocking and updates settings silently. /// [onSettingsUpdated] is called if POD has newer settings. + static void startBackgroundSync({ void Function(MapSettings)? onSettingsUpdated, }) { @@ -446,27 +468,29 @@ class MapSettingsService { /// Preloads map settings in the background to warm up cache. /// Call this on app startup to make settings instantly available. /// Uses smart loading: if no local cache, loads from POD first. + Future preloadMapSettings() async { try { - // Smart load: if no local cache, try POD first + // Smart load: if no local cache, try POD first. await MapSettingsService.loadSettingsSmart().catchError((_) { - // Silently ignore preload errors - will use defaults + // Silently ignore preload errors - will use defaults. return MapSettings(mapSource: MapSettings.getDefaultMapSource()); }); } catch (_) { - // Silently ignore preload errors + // Silently ignore preload errors. } } /// Syncs settings from POD in background. /// Call this after preloadMapSettings() to keep settings in sync. /// Only needed when local cache exists (preloadMapSettings handles first login). + Future syncSettingsFromPod() async { try { - // Small delay to let UI settle first + // Small delay to let UI settle first. await Future.delayed(const Duration(seconds: 3)); await MapSettingsService.syncFromPod(); } catch (_) { - // Silently ignore sync errors + // Silently ignore sync errors. } } diff --git a/lib/services/map_source.dart b/lib/services/map_source.dart index 34ebe54..d22221a 100644 --- a/lib/services/map_source.dart +++ b/lib/services/map_source.dart @@ -28,36 +28,38 @@ library; import 'package:flutter/material.dart'; /// Available map tile sources. + enum MapSource { /// OpenStreetMap - Standard street map (day default) openStreetMap, - /// CartoDB Voyager - Colorful detailed map + /// CartoDB Voyager - Colorful detailed map. cartoVoyager, - /// CartoDB Dark Matter - Night-optimized dark map + /// CartoDB Dark Matter - Night-optimized dark map. cartoDarkMatter, - /// CartoDB Positron - Light grayscale map + /// CartoDB Positron - Light grayscale map. cartoPositron, - /// Esri World Street Map - Professional street map + /// Esri World Street Map - Professional street map. esriWorldStreetMap, - /// Esri World Imagery - Satellite imagery + /// Esri World Imagery - Satellite imagery. esriWorldImagery, - /// Esri World Topo - Topographic map + /// Esri World Topo - Topographic map. esriWorldTopo, - /// OpenTopoMap - Free topographic map + /// OpenTopoMap - Free topographic map. openTopoMap, - /// CyclOSM - Optimized for cycling + /// CyclOSM - Optimized for cycling. cyclOSM, } /// Extension for MapSource to get tile URLs and metadata. + extension MapSourceExtension on MapSource { /// Returns the tile URL template for this map source. String get urlTemplate { @@ -84,6 +86,7 @@ extension MapSourceExtension on MapSource { } /// Returns subdomains if applicable (for load balancing). + List get subdomains { switch (this) { case MapSource.cartoVoyager: @@ -99,6 +102,7 @@ extension MapSourceExtension on MapSource { } /// Display name. + String get displayName { switch (this) { case MapSource.openStreetMap: @@ -123,6 +127,7 @@ extension MapSourceExtension on MapSource { } /// Short description. + String get description { switch (this) { case MapSource.openStreetMap: @@ -147,6 +152,7 @@ extension MapSourceExtension on MapSource { } /// Icon for this map source. + IconData get icon { switch (this) { case MapSource.openStreetMap: @@ -166,18 +172,21 @@ extension MapSourceExtension on MapSource { } /// Whether this is a priority source (preload on startup). + bool get isPriority { return this == MapSource.openStreetMap || this == MapSource.cartoDarkMatter; } /// Whether this is a dark/night-optimized map source. /// Dark sources don't need color matrix filter in dark mode. + bool get isDarkSource { return this == MapSource.cartoDarkMatter; } /// Returns the maximum native zoom level for this map source. /// Beyond this level, tiles are upscaled from the max available. + int get maxNativeZoom { switch (this) { case MapSource.openStreetMap: diff --git a/lib/services/places/encrypted_places_io.dart b/lib/services/places/encrypted_places_io.dart index 1438d16..01e6916 100644 --- a/lib/services/places/encrypted_places_io.dart +++ b/lib/services/places/encrypted_places_io.dart @@ -47,6 +47,7 @@ import 'package:geopod/services/places/encrypted_places_paths.dart'; /// cleanup or another client), the persistent flag won't detect it until a write /// operation fails. The app handles this by clearing the flag on write failures, /// forcing re-verification on the next attempt (see encrypted_places_service.dart). + Future<(bool success, bool dirCreated)> ensureEncryptedPlacesDir( bool directoryVerified, ) async { @@ -58,7 +59,7 @@ Future<(bool success, bool dirCreated)> ensureEncryptedPlacesDir( try { // Only check directory if not yet verified - // This network call happens only once per installation + // This network call happens only once per installation. final fullDirPath = await getFullEncryptedPlacesDirPath(); final dirUrl = await getDirUrl(fullDirPath); @@ -70,6 +71,7 @@ Future<(bool success, bool dirCreated)> ensureEncryptedPlacesDir( await setInheritKeyDir(dirUrl, createAcl: true); return (true, true); // Directory was created } + // Directory exists, return success without creation return (true, false); } catch (e) { @@ -80,6 +82,7 @@ Future<(bool success, bool dirCreated)> ensureEncryptedPlacesDir( /// Read encrypted places from Pod. /// Optimized: tries to read directly without checking existence first. + Future> fetchEncryptedPlacesFromPod() async { final places = []; @@ -93,7 +96,8 @@ Future> fetchEncryptedPlacesFromPod() async { final filePath = getEncryptedPlacesFilePath(); final content = await readPod(filePath); - // Handle non-existent file or errors gracefully + // Handle non-existent file or errors gracefully. + if (content == SolidFunctionCallStatus.notLoggedIn.toString() || content == SolidFunctionCallStatus.fail.toString() || content.isEmpty) { @@ -135,12 +139,13 @@ Future> fetchEncryptedPlacesFromPod() async { /// paths relative to the data directory (e.g., "encrypted_data/places.json"). /// The inheritKeyFrom parameter also uses a relative directory path. /// This differs from ensureEncryptedPlacesDir() which uses full URLs. + Future<(bool success, bool dirCreated)> writeEncryptedPlacesToPod( List places, bool directoryVerified, ) async { try { - // Ensure directory exists + // Ensure directory exists. final (dirExists, dirCreated) = await ensureEncryptedPlacesDir( directoryVerified, ); @@ -153,7 +158,7 @@ Future<(bool success, bool dirCreated)> writeEncryptedPlacesToPod( final filePath = getEncryptedPlacesFilePath(); final dirPath = getEncryptedPlacesDirPath(); - // Convert places to JSON + // Convert places to JSON. final jsonList = places.map((p) => p.toJson()).toList(); final jsonContent = jsonEncode(jsonList); @@ -172,7 +177,7 @@ Future<(bool success, bool dirCreated)> writeEncryptedPlacesToPod( inheritKeyFrom: dirPath, ); - // writePod returns void in 0.9.x, assume success if no exception + // writePod returns void in 0.9.x, assume success if no exception. return (true, dirCreated); } catch (e) { debugPrint('Error writing encrypted places: $e'); diff --git a/lib/services/places/encrypted_places_paths.dart b/lib/services/places/encrypted_places_paths.dart index 0cc5574..84ea28b 100644 --- a/lib/services/places/encrypted_places_paths.dart +++ b/lib/services/places/encrypted_places_paths.dart @@ -16,24 +16,29 @@ import 'package:solidpod/solidpod.dart'; /// Directory name for encrypted places data. /// Using underscore instead of space to avoid URL encoding issues. + const String encryptedPlacesDirName = 'encrypted_data'; /// File name for encrypted places. + const String encryptedPlacesFileName = 'encrypted_places.ttl'; /// Get the directory path for encrypted places (relative to data dir). /// This is used with PathType.relativeToData, so just the subdirectory name. + String getEncryptedPlacesDirPath() { return encryptedPlacesDirName; } /// Get the file path for encrypted places (relative to data dir). /// This is used with PathType.relativeToData. + String getEncryptedPlacesFilePath() { return '$encryptedPlacesDirName/$encryptedPlacesFileName'; } /// Get the full directory path for encrypted places (relative to POD root). + Future getFullEncryptedPlacesDirPath() async { final dataPath = await getDataDirPath(); return '$dataPath/$encryptedPlacesDirName'; diff --git a/lib/services/places/encrypted_places_service.dart b/lib/services/places/encrypted_places_service.dart index c58cc2f..3e5829f 100644 --- a/lib/services/places/encrypted_places_service.dart +++ b/lib/services/places/encrypted_places_service.dart @@ -27,25 +27,32 @@ import 'package:geopod/services/places_service.dart' show placesChangeNotifier; import 'package:geopod/widgets/encryption/security_key_dialog.dart'; /// Service for managing encrypted places. + class EncryptedPlacesService { EncryptedPlacesService._(); /// SharedPreferences key for directory verification flag. + static const _keyDirVerified = 'encrypted_places_dir_verified'; /// Cache for encrypted places. + static List? _cachedEncryptedPlaces; /// Flag to track if directory has been verified. + static bool _directoryVerified = false; /// Flag to track if security key has been verified this session. + static bool _securityKeyVerified = false; /// Cached security key availability status. + static bool? _securityKeyAvailableCache; /// Load persistent flags from storage. + static Future _loadPersistentFlags() async { try { final prefs = await SharedPreferences.getInstance(); @@ -56,6 +63,7 @@ class EncryptedPlacesService { } /// Save directory verified flag to storage. + static Future _saveDirVerifiedFlag(bool verified) async { try { final prefs = await SharedPreferences.getInstance(); @@ -68,16 +76,20 @@ class EncryptedPlacesService { /// Initialize service - load persistent flags. /// Call this once at app startup for better performance. + static Future initialize() async { await _loadPersistentFlags(); } /// Reset session state (call on logout). + static Future resetSessionState() async { _cachedEncryptedPlaces = null; _securityKeyVerified = false; _securityKeyAvailableCache = null; - // Clear persistent directory flag on logout + + // Clear persistent directory flag on logout. + try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_keyDirVerified); @@ -89,8 +101,9 @@ class EncryptedPlacesService { /// Check if security key is available for encryption operations. /// Uses cache to avoid repeated KeyManager calls. + static Future isSecurityKeyAvailable() async { - // Return cached value if available + // Return cached value if available. if (_securityKeyAvailableCache != null) { return _securityKeyAvailableCache!; } @@ -106,11 +119,13 @@ class EncryptedPlacesService { } /// Clear security key cache (call when key is added/removed). + static void clearSecurityKeyCache() { _securityKeyAvailableCache = null; } /// Check if verification key exists (meaning encryption was set up). + static Future hasEncryptionSetup() async { try { final verificationKey = await KeyManager.getVerificationKey(); @@ -124,11 +139,12 @@ class EncryptedPlacesService { /// Returns true if key is now available, false otherwise. /// Uses session caching to avoid repeated checks. /// Uses dialog mode instead of full-screen to avoid navigation issues on cancel. + static Future ensureSecurityKey( BuildContext context, Widget child, ) async { - // Skip if already verified this session + // Skip if already verified this session. if (_securityKeyVerified) { return true; } @@ -138,7 +154,8 @@ class EncryptedPlacesService { return true; } - // Check if encryption has been set up + // Check if encryption has been set up. + if (!await hasEncryptionSetup()) { if (!context.mounted) return false; await showEncryptionNotSetupDialog(context); @@ -146,13 +163,16 @@ class EncryptedPlacesService { } // Prompt for security key using dialog mode (not full-screen) - // This avoids navigation issues when user cancels + // This avoids navigation issues when user cancels. + if (!context.mounted) return false; final result = await showSecurityKeyDialog(context); if (result) { _securityKeyVerified = true; _securityKeyAvailableCache = true; // Update cache - // Notify the status bar that security key is now available + + // Notify the status bar that security key is now available. + if (context.mounted) { const SecurityKeyStatusChangedNotification( isKeySaved: true, @@ -164,6 +184,7 @@ class EncryptedPlacesService { } /// Read encrypted places from Pod. + static Future> fetchEncryptedPlaces({ bool forceRefresh = false, }) async { @@ -179,35 +200,38 @@ class EncryptedPlacesService { /// Write encrypted places to Pod. /// Optimized: Uses persistent directoryVerified flag to skip repeated /// directory status checks across app sessions. + static Future writeEncryptedPlaces( List places, BuildContext context, Widget child, ) async { - // Ensure security key is available + // Ensure security key is available. if (!await ensureSecurityKey(context, child)) { return false; } // Write to Pod using IO helper // The directoryVerified flag (loaded from persistent storage) prevents - // repeated checkResourceStatus calls for the directory + // repeated checkResourceStatus calls for the directory. final (success, dirCreated) = await writeEncryptedPlacesToPod( places, _directoryVerified, ); if (success) { - // Update and persist directory verification flag if write succeeded + // Update and persist directory verification flag if write succeeded. if (!_directoryVerified) { await _saveDirVerifiedFlag(true); } _cachedEncryptedPlaces = places; - // Notify places change to trigger UI refresh + // Notify places change to trigger UI refresh. + placesChangeNotifier.value++; // If directory was newly created, update security key cache and notify UI // (directory creation confirms encryption keys are available) + if (dirCreated) { _securityKeyAvailableCache = true; // Keys are now known to be available if (context.mounted) { @@ -236,6 +260,7 @@ class EncryptedPlacesService { /// Uses local cache to avoid fetching from server if available. /// IMPORTANT: If cache is empty, ensures security key is available before /// fetching existing places to prevent data loss. + static Future addEncryptedPlace( Place place, BuildContext context, @@ -247,6 +272,7 @@ class EncryptedPlacesService { /// Add multiple encrypted places in a single batch operation. /// More efficient than calling addEncryptedPlace multiple times. /// Uses local cache to avoid fetching from server if available. + static Future addEncryptedPlacesBatch( List places, BuildContext context, @@ -255,7 +281,7 @@ class EncryptedPlacesService { if (places.isEmpty) return true; try { - // Load persistent flags first + // Load persistent flags first. if (!_directoryVerified) { await _loadPersistentFlags(); } @@ -263,6 +289,7 @@ class EncryptedPlacesService { // If no cached data, we MUST ensure security key is available first // before fetching existing places. Otherwise, fetchEncryptedPlaces() // might return empty list and we'd overwrite existing data. + if (_cachedEncryptedPlaces == null) { if (!await ensureSecurityKey(context, child)) { debugPrint( @@ -285,13 +312,14 @@ class EncryptedPlacesService { /// Delete an encrypted place by ID. /// Ensures security key is available before modifying encrypted data. + static Future deleteEncryptedPlace( String placeId, BuildContext context, Widget child, ) async { try { - // Ensure security key is available before fetching/modifying data + // Ensure security key is available before fetching/modifying data. if (_cachedEncryptedPlaces == null) { if (!await ensureSecurityKey(context, child)) { debugPrint( @@ -315,13 +343,14 @@ class EncryptedPlacesService { /// Update an encrypted place. /// Ensures security key is available before modifying encrypted data. + static Future updateEncryptedPlace( Place updatedPlace, BuildContext context, Widget child, ) async { try { - // Ensure security key is available before fetching/modifying data + // Ensure security key is available before fetching/modifying data. if (_cachedEncryptedPlaces == null) { if (!await ensureSecurityKey(context, child)) { debugPrint( @@ -344,11 +373,13 @@ class EncryptedPlacesService { } /// Clear the cache. + static void clearCache() { _cachedEncryptedPlaces = null; } /// Merge imported places into encrypted storage. + static Future mergeImportedEncryptedPlaces( List importedPlaces, BuildContext context, @@ -356,7 +387,7 @@ class EncryptedPlacesService { void Function(int current, int total)? onProgress, }) async { try { - // Ensure security key + // Ensure security key. if (!await ensureSecurityKey(context, child)) { return false; } @@ -364,22 +395,23 @@ class EncryptedPlacesService { final existingPlaces = await fetchEncryptedPlaces(); final existingIds = existingPlaces.map((p) => p.id).toSet(); - // Filter out duplicates + // Filter out duplicates. final newPlaces = importedPlaces .where((p) => !existingIds.contains(p.id)) .toList(); if (newPlaces.isEmpty && importedPlaces.isNotEmpty) { - // All were duplicates + // All were duplicates. return true; } - // Report progress + // Report progress. + for (int i = 0; i < newPlaces.length; i++) { onProgress?.call(i + 1, newPlaces.length); } - // Merge and write + // Merge and write. final allPlaces = [...newPlaces, ...existingPlaces]; return await writeEncryptedPlaces(allPlaces, context, child); } catch (e) { diff --git a/lib/services/places/places_cache_manager.dart b/lib/services/places/places_cache_manager.dart index e3ab1da..f34e307 100644 --- a/lib/services/places/places_cache_manager.dart +++ b/lib/services/places/places_cache_manager.dart @@ -30,28 +30,35 @@ import 'package:solidpod/solidpod.dart'; import 'package:geopod/models/place.dart'; /// In-memory cache manager for instant access to places data. + class PlacesCacheManager { - // Singleton pattern + // Singleton pattern. static final PlacesCacheManager _instance = PlacesCacheManager._internal(); factory PlacesCacheManager() => _instance; PlacesCacheManager._internal(); /// Cached all places (local + Pod) + List? _allPlacesCache; - /// Cached Pod-only places + /// Cached Pod-only places. + List? _podPlacesCache; - /// Last cache update timestamp + /// Last cache update timestamp. + DateTime? _lastCacheTime; /// Login state when cache was created (to prevent guest using logged-in user's cache) + bool? _wasLoggedInWhenCached; /// Cache validity duration (in-memory cache, should be long enough for login) + static const Duration _memoryCacheExpiry = Duration(minutes: 30); /// Gets all cached places (local + Pod) + List? get allPlaces { if (_allPlacesCache == null || _isCacheExpired()) { return null; @@ -59,10 +66,12 @@ class PlacesCacheManager { return List.unmodifiable(_allPlacesCache!); } - /// Gets the login state when cache was created + /// Gets the login state when cache was created. + bool? get wasLoggedInWhenCached => _wasLoggedInWhenCached; - /// Gets cached Pod places only + /// Gets cached Pod places only. + List? get podPlaces { if (_podPlacesCache == null || _isCacheExpired()) { return null; @@ -70,26 +79,30 @@ class PlacesCacheManager { return List.unmodifiable(_podPlacesCache!); } - /// Caches all places data with current login state + /// Caches all places data with current login state. + void cacheAllPlaces(List places) { _allPlacesCache = List.from(places); _lastCacheTime = DateTime.now(); _wasLoggedInWhenCached = authStateNotifier.value; } - /// Caches Pod places data + /// Caches Pod places data. + void cachePodPlaces(List places) { _podPlacesCache = List.from(places); _lastCacheTime = DateTime.now(); } - /// Checks if in-memory cache is expired + /// Checks if in-memory cache is expired. + bool _isCacheExpired() { if (_lastCacheTime == null) return true; return DateTime.now().difference(_lastCacheTime!) > _memoryCacheExpiry; } - /// Clears all in-memory cache + /// Clears all in-memory cache. + void clearCache() { _allPlacesCache = null; _podPlacesCache = null; @@ -98,16 +111,19 @@ class PlacesCacheManager { } /// Clears only Pod-related cache, preserves local places structure - /// allPlaces cache is cleared because it contains merged data + /// allPlaces cache is cleared because it contains merged data. + void clearPodCacheOnly() { _allPlacesCache = null; // Clear merged cache (will be rebuilt) _podPlacesCache = null; // Clear Pod cache _lastCacheTime = null; _wasLoggedInWhenCached = null; - // Note: Local places are cached in PlacesService._cachedLocalPlaces, not here + + // Note: Local places are cached in PlacesService._cachedLocalPlaces, not here. } - /// Forces cache refresh on next fetch + /// Forces cache refresh on next fetch. + void invalidateCache() { _lastCacheTime = null; } diff --git a/lib/services/places/places_cache_persistence.dart b/lib/services/places/places_cache_persistence.dart index e7a710d..4754f6d 100644 --- a/lib/services/places/places_cache_persistence.dart +++ b/lib/services/places/places_cache_persistence.dart @@ -20,25 +20,29 @@ import 'package:geopod/models/place.dart'; /// /// This allows the app to show previously loaded places immediately /// on startup while fetching fresh data in the background. + class PlacesCachePersistence { /// Key for storing cached Pod places JSON in SharedPreferences. static const String _podPlacesCacheKey = 'geopod_pod_places_cache'; /// Key for storing cache timestamp. + static const String _podPlacesCacheTimestampKey = 'geopod_pod_places_cache_timestamp'; /// Maximum cache age in hours before considering it stale. + static const int _maxCacheAgeHours = 24; /// Get cached Pod places from SharedPreferences. /// /// Returns null if no cache exists or cache is too old. + static Future?> getCachedPodPlaces() async { try { final prefs = await SharedPreferences.getInstance(); - // Check cache age + // Check cache age. final timestamp = prefs.getInt(_podPlacesCacheTimestampKey); if (timestamp != null) { final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp); @@ -61,7 +65,7 @@ class PlacesCachePersistence { try { places.add(Place.fromJson(item, isLocalSource: false)); } catch (_) { - // Skip malformed entries + // Skip malformed entries. } } } @@ -76,6 +80,7 @@ class PlacesCachePersistence { /// Cache Pod places JSON content to SharedPreferences. /// /// [content] should be the raw JSON string from the Pod. + static Future cachePodPlaces(String content) async { try { final prefs = await SharedPreferences.getInstance(); @@ -85,22 +90,24 @@ class PlacesCachePersistence { DateTime.now().millisecondsSinceEpoch, ); } catch (_) { - // Silently fail - caching is best-effort + // Silently fail - caching is best-effort. } } /// Clear the Pod places cache. + static Future clearPodPlacesCache() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_podPlacesCacheKey); await prefs.remove(_podPlacesCacheTimestampKey); } catch (_) { - // Silently fail + // Silently fail. } } /// Check if cache exists and is valid. + static Future hasFreshCache() async { try { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/services/places/places_import_export.dart b/lib/services/places/places_import_export.dart index 0a49061..38a7b67 100644 --- a/lib/services/places/places_import_export.dart +++ b/lib/services/places/places_import_export.dart @@ -35,6 +35,7 @@ import 'package:uuid/uuid.dart'; import 'package:geopod/models/place.dart'; /// Result of an import operation. + class ImportResult { /// Successfully parsed and validated places. final List places = []; @@ -43,19 +44,24 @@ class ImportResult { final List errors = []; /// Number of items that were skipped due to validation errors. + int skippedCount = 0; /// Whether the user cancelled the file picker. + bool cancelled = false; /// Whether the import was successful (at least one place imported). + bool get hasPlaces => places.isNotEmpty; /// Whether there were any errors during import. + bool get hasErrors => errors.isNotEmpty; } /// Service for importing and exporting places data. + class PlacesImportExport { /// Exports user's Pod places to a JSON file and triggers download. /// @@ -93,7 +99,8 @@ class PlacesImportExport { /// - id: auto-generated if missing (UUID) /// - timestamp: uses current time if missing /// - note: defaults to empty string if missing - /// - address: defaults to "Unknown Location" if missing + /// - address: defaults to "Unknown Location" if missing. + static Future importPlaces() async { final result = ImportResult(); @@ -160,6 +167,7 @@ class PlacesImportExport { } // Validate lat/lng ranges. + if (lat < -90 || lat > 90) { result.errors.add( 'Item ${i + 1}: lat must be between -90 and 90, skipped', diff --git a/lib/services/places/places_pod_file.dart b/lib/services/places/places_pod_file.dart index 6cd4cd0..13478b8 100644 --- a/lib/services/places/places_pod_file.dart +++ b/lib/services/places/places_pod_file.dart @@ -24,24 +24,28 @@ import 'package:geopod/models/place.dart'; const String placesFileName = 'places.json'; /// Get the full file path for places.json. + Future getPlacesFilePath() async { final path = await getDataDirPath(); return '$path/places/$placesFileName'; } /// Get the directory path for places. + Future getPlacesDirPath() async { final path = await getDataDirPath(); return '$path/places'; } /// Get file path for individual place file. + Future getIndividualPlaceFilePath(String placeId) async { final dirPath = await getPlacesDirPath(); return '$dirPath/place_$placeId.json'; } /// Read the main places.json file. + Future readPlacesJsonFile() async { try { final fp = await getPlacesFilePath(); @@ -63,6 +67,7 @@ Future readPlacesJsonFile() async { } /// Write content to places.json file. + Future writePlacesJsonFile(String content) async { try { final fp = await getPlacesFilePath(); @@ -86,6 +91,7 @@ Future writePlacesJsonFile(String content) async { } /// Write an individual place file. + Future writeIndividualPlaceFile(Place place) async { try { final fp = await getIndividualPlaceFilePath(place.id); @@ -111,6 +117,7 @@ Future writeIndividualPlaceFile(Place place) async { } /// Delete an individual place file. + Future deleteIndividualPlaceFile(String placeId) async { try { final fp = await getIndividualPlaceFilePath(placeId); @@ -128,7 +135,8 @@ Future deleteIndividualPlaceFile(String placeId) async { 'DPoP': dPopToken, }, ); - // 404 means file doesn't exist, which is fine + + // 404 means file doesn't exist, which is fine. return r.statusCode >= 200 && r.statusCode < 300 || r.statusCode == 404; } catch (_) { return false; @@ -136,6 +144,7 @@ Future deleteIndividualPlaceFile(String placeId) async { } /// Delete all individual place files for given place IDs. + Future deleteAllIndividualPlaceFiles(List ids) async { await Future.wait(ids.map((id) => deleteIndividualPlaceFile(id))); } diff --git a/lib/services/places_service.dart b/lib/services/places_service.dart index bec494e..47335eb 100644 --- a/lib/services/places_service.dart +++ b/lib/services/places_service.dart @@ -52,6 +52,7 @@ class PlacesService { static List? _cachedLocalPlaces; /// Get local example places synchronously. + static List getLocalPlacesSync() { _cachedLocalPlaces ??= kExamplePlacesData .map((json) => Place.fromJson(json, isLocalSource: true)) @@ -62,6 +63,7 @@ class PlacesService { /// Load local example places (async wrapper for API compatibility). /// NOTE: Prefer using getLocalPlacesSync() directly as local places /// are now compiled into the app and don't require async loading. + @Deprecated('Use getLocalPlacesSync() instead - local data is compiled in') static Future> loadLocalPlaces() async => getLocalPlacesSync(); @@ -73,18 +75,19 @@ class PlacesService { if (!forceRefresh) { final c = cm.allPlaces; if (c != null) { - // If cached but need encrypted, check if encrypted is included + // If cached but need encrypted, check if encrypted is included. if (includeEncrypted && !c.any((p) => p.isEncrypted)) { - // Need to fetch encrypted separately + // Need to fetch encrypted separately. } else { return c; } } } - // Local places are synchronous (compiled into binary) - get them immediately + + // Local places are synchronous (compiled into binary) - get them immediately. final localPlaces = getLocalPlacesSync(); - // Fetch network data in parallel for better performance + // Fetch network data in parallel for better performance. final results = await Future.wait([ fetchPodPlaces(forceRefresh: forceRefresh), includeEncrypted @@ -104,12 +107,14 @@ class PlacesService { /// Returns empty list if not logged in or no security key available. /// NOTE: Will not prompt for security key - use EncryptedPlacesService /// directly if you need to prompt the user. + static Future> fetchEncryptedPlaces({ bool forceRefresh = false, }) async { try { if (!authStateNotifier.value) return []; - // Check if security key is available - don't try to load if not + + // Check if security key is available - don't try to load if not. final hasKey = await EncryptedPlacesService.isSecurityKeyAvailable(); if (!hasKey) { debugPrint( @@ -117,7 +122,8 @@ class PlacesService { ); return []; } - // Import and use EncryptedPlacesService + + // Import and use EncryptedPlacesService. final encPlaces = await EncryptedPlacesService.fetchEncryptedPlaces( forceRefresh: forceRefresh, ); @@ -200,6 +206,7 @@ class PlacesService { static Future> refreshPodDataOnly() async { final cm = PlacesCacheManager(); + // Local places are synchronous (compiled into binary) final local = getLocalPlacesSync(); await clearPodCacheOnly(); @@ -220,7 +227,7 @@ class PlacesService { var existing = cm.podPlaces ?? await fetchPodPlaces(); final updated = List.from(existing)..insert(0, place); - // Write main file first to ensure it succeeds + // Write main file first to ensure it succeeds. final mainSuccess = await writePlacesJsonFile( jsonEncode(updated.map((p) => p.toJson()).toList()), ); @@ -234,7 +241,9 @@ class PlacesService { await clearCache(); placesChangeNotifier.value++; - // Clear directory cache completely to force refresh + + // Clear directory cache completely to force refresh. + PodDirectoryService.clearCache(); PodDirectoryService.notifyChange(); } @@ -257,7 +266,7 @@ class PlacesService { final updated = List.from(existing) ..removeWhere((p) => p.id == placeId); - // Delete individual file and update main file in parallel + // Delete individual file and update main file in parallel. final results = await Future.wait([ writePlacesJsonFile( jsonEncode(updated.map((p) => p.toJson()).toList()), @@ -269,7 +278,9 @@ class PlacesService { if (success) { await clearCache(); placesChangeNotifier.value++; - // Invalidate directory cache and notify file browser + + // Invalidate directory cache and notify file browser. + PodDirectoryService.invalidateCache('data/places'); PodDirectoryService.notifyChange(); } @@ -315,17 +326,19 @@ class PlacesService { } final merged = [...withAddr, ...existing]; - // Write main file first + // Write main file first. final success = await writePlacesJsonFile( jsonEncode(merged.map((p) => p.toJson()).toList()), ); if (success) { - // Write individual files for new places in parallel + // Write individual files for new places in parallel. await Future.wait(withAddr.map((p) => writeIndividualPlaceFile(p))); await clearCache(); placesChangeNotifier.value++; - // Invalidate directory cache and notify file browser + + // Invalidate directory cache and notify file browser. + PodDirectoryService.invalidateCache('data/places'); PodDirectoryService.notifyChange(); } @@ -342,17 +355,19 @@ class PlacesService { try { if (!authStateNotifier.value) return false; - // Get all existing place IDs before clearing + // Get all existing place IDs before clearing. final existing = await fetchPodPlaces(); final placeIds = existing.map((p) => p.id).toList(); final success = await writePlacesJsonFile('[]'); if (success) { - // Delete all individual place files + // Delete all individual place files. await deleteAllIndividualPlaceFiles(placeIds); await clearCache(); placesChangeNotifier.value++; - // Invalidate directory cache and notify file browser + + // Invalidate directory cache and notify file browser. + PodDirectoryService.invalidateCache('data/places'); PodDirectoryService.notifyChange(); } @@ -396,7 +411,7 @@ class PlacesService { list[i] = toSave; } - // Update both main file and individual file in parallel + // Update both main file and individual file in parallel. final results = await Future.wait([ writePlacesJsonFile(jsonEncode(list.map((p) => p.toJson()).toList())), writeIndividualPlaceFile(toSave), @@ -406,7 +421,9 @@ class PlacesService { if (success) { await clearCache(); placesChangeNotifier.value++; - // Invalidate directory cache and notify file browser + + // Invalidate directory cache and notify file browser. + PodDirectoryService.invalidateCache('data/places'); PodDirectoryService.notifyChange(); } @@ -419,6 +436,7 @@ class PlacesService { /// Delete a place by its individual file path. /// This is called when a user deletes place_xxx.json from the file browser. /// It will also remove the place from the main places.json file. + static Future deletePlaceByFilePath( String filePath, BuildContext context, @@ -434,12 +452,14 @@ class PlacesService { } /// Check if a file path is a places.json file. + static bool isMainPlacesFile(String filePath) { return filePath.endsWith('/places.json') || filePath.endsWith('\\places.json'); } /// Check if a file path is an individual place file. + static bool isIndividualPlaceFile(String filePath) { final fileName = filePath.split('/').last.split('\\').last; return RegExp(r'^place_.+\.json$').hasMatch(fileName); diff --git a/lib/services/pod/pod.dart b/lib/services/pod/pod.dart index 87c425b..bcc8364 100644 --- a/lib/services/pod/pod.dart +++ b/lib/services/pod/pod.dart @@ -12,18 +12,23 @@ library; -// Core authentication +// Core authentication. + export 'pod_auth.dart'; -// Path utilities +// Path utilities. + export 'pod_path.dart'; -// HTTP client +// HTTP client. + export 'pod_http.dart' show PodResponse, ResourceStatus, PodContentType, PodHttp; -// High-level file system API +// High-level file system API. + export 'pod_file_system.dart'; -// Directory listing service +// Directory listing service. + export 'pod_directory_service.dart'; diff --git a/lib/services/pod/pod_auth.dart b/lib/services/pod/pod_auth.dart index 535f7e9..d23ad16 100644 --- a/lib/services/pod/pod_auth.dart +++ b/lib/services/pod/pod_auth.dart @@ -17,9 +17,11 @@ import 'package:solid_auth/solid_auth.dart' show genDpopToken; import 'package:solidpod/solidpod.dart' show AuthDataManager, authStateNotifier; /// Authentication token pair for POD requests. + typedef TokenPair = ({String accessToken, String dPopToken}); /// Provides authentication utilities for POD access. + class PodAuth { PodAuth._(); @@ -27,6 +29,7 @@ class PodAuth { /// /// [resourceUrl] - The URL of the resource to access. /// [method] - HTTP method (GET, PUT, POST, DELETE). + static Future getTokens(String resourceUrl, String method) async { final authData = await AuthDataManager.loadAuthData(); if (authData == null) { @@ -49,22 +52,26 @@ class PodAuth { } /// Get the current user's WebID. + static Future getWebId() async { return await AuthDataManager.getWebId(); } /// Check if user is currently logged in. + static Future isLoggedIn() async { final webId = await AuthDataManager.getWebId(); return webId != null && webId.isNotEmpty; } /// Check if user is logged in (synchronous, uses cached data). + static bool isLoggedInSync() { return authStateNotifier.value; } /// Profile card path constant (same as solidpod). + static const String _profCard = 'profile/card#me'; /// Extract POD base URL from WebID. @@ -73,14 +80,16 @@ class PodAuth { /// Returns: `https://pods.solidcommunity.au/` /// /// Uses the same approach as solidpod to handle ports and custom paths. + static Future getPodBaseUrl() async { final webId = await getWebId(); if (webId == null) return null; // Same method as solidpod: replace profile/card#me with empty string - // This preserves ports and any other URL components + // This preserves ports and any other URL components. + if (!webId.contains(_profCard)) { - // Fallback to URI parsing if WebID doesn't have standard format + // Fallback to URI parsing if WebID doesn't have standard format. final uri = Uri.parse(webId); final port = uri.hasPort ? ':${uri.port}' : ''; return '${uri.scheme}://${uri.host}$port/'; @@ -93,6 +102,7 @@ class PodAuth { /// /// [resourcePath] - Path to the resource (e.g., 'geopod/data/places'). /// [isContainer] - Whether this is a directory (adds trailing slash). + static Future getResourceUrl( String resourcePath, { bool isContainer = false, diff --git a/lib/services/pod/pod_directory_service.dart b/lib/services/pod/pod_directory_service.dart index 09c2ec1..5a67416 100644 --- a/lib/services/pod/pod_directory_service.dart +++ b/lib/services/pod/pod_directory_service.dart @@ -21,38 +21,46 @@ import 'package:geopod/models/pod_file_item.dart'; import 'package:geopod/services/pod/pod.dart'; /// Cache expiry duration. + const Duration _cacheExpiry = Duration(minutes: 2); /// Notifier for file system changes. /// Increments when files are added, deleted, or modified. /// UI components can listen to this to refresh their views. + final podFilesChangeNotifier = ValueNotifier(0); /// Service for listing and managing POD directories. + class PodDirectoryService { PodDirectoryService._(); /// Directory cache: path -> (items, timestamp) + static final Map, DateTime)> _cache = {}; /// Notify listeners that the file system has changed. /// This only notifies UI components to refresh their views. /// Use invalidateCache() to clear specific cache entries before calling this. + static void notifyChange() { podFilesChangeNotifier.value++; debugPrint('PodDirectoryService: Notified file system change'); } /// Clear all cached data. + static void clearCache() { _cache.clear(); debugPrint('PodDirectoryService: Cache cleared'); } /// Clear cache for a specific path and its parent. + static void invalidateCache(String path) { _cache.remove(path); - // Also invalidate parent directory + + // Also invalidate parent directory. final parentPath = PodPath.getParentPath(path); if (parentPath != path) { _cache.remove( @@ -65,8 +73,9 @@ class PodDirectoryService { } /// Remove an item from the cache (used after deletion). + static void removeFromCache(String itemPath) { - // Find the parent directory in cache and remove the item + // Find the parent directory in cache and remove the item. final parentPath = _getParentFromItemPath(itemPath); final cached = _cache[parentPath]; if (cached != null) { @@ -78,6 +87,7 @@ class PodDirectoryService { } /// Get parent path from an item path. + static String _getParentFromItemPath(String itemPath) { final lastSlash = itemPath.lastIndexOf('/'); if (lastSlash <= 0) return ''; @@ -90,6 +100,7 @@ class PodDirectoryService { /// Empty string means app root directory (geopod/). /// [forceRefresh] - If true, bypass cache and fetch fresh data. /// Returns a list of [PodFileItem] representing files and directories. + static Future> listDirectory( String relativePath, { bool forceRefresh = false, @@ -109,17 +120,18 @@ class PodDirectoryService { } try { - // Build the directory URL + // Build the directory URL. String dirUrl = await PodPath.getDirUrl(relativePath); // Ensure URL ends with / + if (!dirUrl.endsWith('/')) { dirUrl = '$dirUrl/'; } debugPrint('PodDirectoryService: Listing directory: $dirUrl'); - // Get authentication tokens + // Get authentication tokens. final tokens = await PodAuth.getTokens(dirUrl, 'GET'); // Make the request with proper headers (matching solidpod's implementation) @@ -138,7 +150,7 @@ class PodDirectoryService { ); if (response.statusCode == 404) { - // Directory doesn't exist + // Directory doesn't exist. debugPrint('PodDirectoryService: Directory not found'); return []; } @@ -155,10 +167,11 @@ class PodDirectoryService { throw Exception('Failed to list directory: ${response.statusCode}'); } - // Parse the Turtle/RDF response to extract file list + // Parse the Turtle/RDF response to extract file list. final items = _parseTurtleResponse(response.body, relativePath); - // Update cache + // Update cache. + _cache[relativePath] = (List.from(items), DateTime.now()); debugPrint( 'PodDirectoryService: Cached ${items.length} items for: $relativePath', @@ -173,6 +186,7 @@ class PodDirectoryService { /// Parse Turtle/RDF response to extract file and directory items. /// Uses the same heuristic as solidpod's _parseGetContainerResponse. + static List _parseTurtleResponse( String responseBody, String basePath, @@ -191,7 +205,8 @@ class PodDirectoryService { final isDirectory = line.contains('ldp:Container'); - // Extract name: for dirs, for files + // Extract name: for dirs, for files. + String name; if (isDirectory) { // Remove < and /> @@ -201,12 +216,13 @@ class PodDirectoryService { name = nameMatch.substring(1, nameMatch.length - 1); } - // Skip ACL and meta files + // Skip ACL and meta files. + if (name.endsWith('.acl') || name.endsWith('.meta')) { continue; } - // Build relative path + // Build relative path. final itemPath = basePath.isEmpty ? name : '$basePath/$name'; items.add( @@ -216,7 +232,8 @@ class PodDirectoryService { } } - // Sort: directories first, then files alphabetically + // Sort: directories first, then files alphabetically. + items.sort((a, b) { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; @@ -229,6 +246,7 @@ class PodDirectoryService { /// Create a directory in the POD. /// /// [relativePath] - Path relative to the app data directory. + static Future createDirectory(String relativePath) async { final success = await PodFileSystem.createDirectory(relativePath); if (success) { @@ -242,19 +260,21 @@ class PodDirectoryService { /// /// [relativePath] - Path relative to the app data directory. /// Also deletes the associated ACL file if it exists. + static Future delete(String relativePath) async { final success = await PodFileSystem.deleteFile(relativePath); if (success) { - // Remove from cache immediately + // Remove from cache immediately. removeFromCache(relativePath); notifyChange(); // Notify listeners // Also try to delete the ACL file (ignore errors) + try { await PodFileSystem.deleteFile('$relativePath.acl'); debugPrint('PodDirectoryService: Deleted ACL file for: $relativePath'); } catch (_) { - // ACL file may not exist, ignore + // ACL file may not exist, ignore. } } return success; @@ -263,6 +283,7 @@ class PodDirectoryService { /// Check if a path exists in the POD. /// /// [relativePath] - Path relative to the app data directory. + static Future exists(String relativePath) async { return await PodFileSystem.fileExists(relativePath); } @@ -270,15 +291,17 @@ class PodDirectoryService { /// Read file content from the POD. /// /// [relativePath] - Path relative to the app data directory. + static Future readFile(String relativePath) async { return await PodFileSystem.readFile(relativePath); } /// Preload common directories into cache. /// Call this after login to make file browser feel instant. + static Future preload() async { try { - // Check if already cached + // Check if already cached. if (_cache.containsKey('') && _cache.containsKey('data')) { debugPrint('PodDirectoryService.preload: skipped (cache exists)'); return; @@ -286,7 +309,7 @@ class PodDirectoryService { debugPrint('PodDirectoryService.preload: starting...'); - // Preload root directory and data directory in parallel + // Preload root directory and data directory in parallel. await Future.wait([ listDirectory(''), // geopod/ listDirectory('data'), // geopod/data/ diff --git a/lib/services/pod/pod_file_system.dart b/lib/services/pod/pod_file_system.dart index bd63150..e650908 100644 --- a/lib/services/pod/pod_file_system.dart +++ b/lib/services/pod/pod_file_system.dart @@ -20,6 +20,7 @@ import 'package:geopod/services/pod/pod_http.dart'; import 'package:geopod/services/pod/pod_path.dart'; /// Result of a file operation. + class FileOperationResult { final bool success; final String? content; @@ -37,6 +38,7 @@ class FileOperationResult { /// High-level POD file system operations. /// /// Provides simple read/write operations without encryption. + class PodFileSystem { PodFileSystem._(); @@ -46,6 +48,7 @@ class PodFileSystem { /// Example: `places/places.json` /// /// Returns the file content as a string, or null if file doesn't exist. + static Future readFile(String relativePath) async { if (!await PodAuth.isLoggedIn()) { debugPrint('PodFileSystem.readFile() - not logged in'); @@ -81,6 +84,7 @@ class PodFileSystem { /// [createParentDirs] - Whether to create parent directories if missing. /// /// Returns true if write was successful. + static Future writeFile( String relativePath, String content, { @@ -95,7 +99,8 @@ class PodFileSystem { try { final url = await PodPath.getFileUrl(relativePath); - // Check if parent directory exists and create if needed + // Check if parent directory exists and create if needed. + if (createParentDirs) { final parentPath = PodPath.getParentPath( PodPath.getFilePath(relativePath), @@ -129,6 +134,7 @@ class PodFileSystem { /// [relativePath] - Path relative to the data directory. /// /// Returns true if deletion was successful. + static Future deleteFile(String relativePath) async { if (!await PodAuth.isLoggedIn()) { debugPrint('PodFileSystem.deleteFile() - not logged in'); @@ -157,6 +163,7 @@ class PodFileSystem { /// Check if a file exists in the POD. /// /// [relativePath] - Path relative to the data directory. + static Future fileExists(String relativePath) async { if (!await PodAuth.isLoggedIn()) { return false; @@ -175,6 +182,7 @@ class PodFileSystem { /// Check if a directory exists in the POD. /// /// [relativePath] - Path relative to the data directory. + static Future directoryExists(String relativePath) async { if (!await PodAuth.isLoggedIn()) { return false; @@ -195,6 +203,7 @@ class PodFileSystem { /// [relativePath] - Path relative to the data directory. /// /// Returns true if creation was successful. + static Future createDirectory(String relativePath) async { if (!await PodAuth.isLoggedIn()) { debugPrint('PodFileSystem.createDirectory() - not logged in'); @@ -210,8 +219,9 @@ class PodFileSystem { } /// Ensure a directory exists, creating it and any parent directories if needed. + static Future _ensureDirectoryExists(String dirPath) async { - // Split path into parts and create each level + // Split path into parts and create each level. final parts = dirPath.split('/').where((p) => p.isNotEmpty).toList(); var currentPath = ''; @@ -222,7 +232,7 @@ class PodFileSystem { final status = await PodHttp.checkStatus(url); if (status == ResourceStatus.notExist) { - // Get parent URL and create this directory + // Get parent URL and create this directory. final parentPath = i == 0 ? '' : '${parts.sublist(0, i).join('/')}/'; final parentUrl = await _getAbsoluteUrl( parentPath.isEmpty ? '' : parentPath, @@ -259,6 +269,7 @@ class PodFileSystem { } /// Get absolute URL for a path (without normalizing through getFilePath). + static Future _getAbsoluteUrl(String path) async { final baseUrl = await PodAuth.getPodBaseUrl(); if (baseUrl == null) { diff --git a/lib/services/pod/pod_http.dart b/lib/services/pod/pod_http.dart index 8c495fe..b36e69d 100644 --- a/lib/services/pod/pod_http.dart +++ b/lib/services/pod/pod_http.dart @@ -21,6 +21,7 @@ import 'package:http/http.dart' as http; import 'package:geopod/services/pod/pod_auth.dart'; /// Result of a POD HTTP operation. + class PodResponse { final int statusCode; final String body; @@ -39,9 +40,11 @@ class PodResponse { } /// Resource status enumeration. + enum ResourceStatus { exist, notExist, forbidden, unknown } /// Content types for POD resources. + enum PodContentType { json('application/json'), turtle('text/turtle'), @@ -54,10 +57,12 @@ enum PodContentType { } /// Low-level HTTP client for POD operations. + class PodHttp { PodHttp._(); /// Perform a GET request to fetch a resource. + static Future get( String url, { PodContentType accept = PodContentType.any, @@ -87,6 +92,7 @@ class PodHttp { } /// Perform a PUT request to create or replace a resource. + static Future put( String url, String content, { @@ -121,6 +127,7 @@ class PodHttp { } /// Perform a POST request to create a resource. + static Future post( String containerUrl, String name, @@ -131,7 +138,8 @@ class PodHttp { try { final tokens = await PodAuth.getTokens(containerUrl, 'POST'); - // Link header for resource type + // Link header for resource type. + const fileTypeLink = '; rel="type"'; const dirTypeLink = '; rel="type"'; @@ -162,6 +170,7 @@ class PodHttp { } /// Perform a DELETE request to remove a resource. + static Future delete(String url) async { try { final tokens = await PodAuth.getTokens(url, 'DELETE'); @@ -188,6 +197,7 @@ class PodHttp { } /// Perform a HEAD request to check resource existence. + static Future checkStatus(String url) async { try { final tokens = await PodAuth.getTokens(url, 'HEAD'); diff --git a/lib/services/pod/pod_path.dart b/lib/services/pod/pod_path.dart index a7ca0c2..9241301 100644 --- a/lib/services/pod/pod_path.dart +++ b/lib/services/pod/pod_path.dart @@ -15,29 +15,35 @@ library; import 'package:geopod/services/pod/pod_auth.dart'; /// Application directory name in the POD. + const String appDirName = 'geopod'; /// Data subdirectory name. + const String dataDir = 'data'; /// Provides path utilities for POD resources. + class PodPath { PodPath._(); /// Get the app root directory path. /// Returns: `geopod` + static String getAppRootPath() => appDirName; /// Get the data directory path. /// Returns: `geopod/data` + static String getDataDirPath() => '$appDirName/$dataDir'; /// Check if a path is within the data directory (user-editable). /// /// [path] - Relative path to check. /// Returns true if path is in `geopod/data/` or is the data dir itself. + static bool isDataPath(String path) { - // Normalize: remove leading/trailing slashes + // Normalize: remove leading/trailing slashes. var normalized = path; if (normalized.startsWith('/')) { normalized = normalized.substring(1); @@ -47,6 +53,7 @@ class PodPath { } // Is it the data dir itself? + if (normalized == dataDir || normalized == '$appDirName/$dataDir') { return true; } @@ -60,6 +67,7 @@ class PodPath { /// /// [path] - Relative path to check. /// Returns true if the path cannot be modified by the user. + static bool isReadOnly(String path) { return !isDataPath(path); } @@ -70,23 +78,26 @@ class PodPath { /// Example: `data/places/places.json` → `geopod/data/places/places.json` /// Example: `data` → `geopod/data` /// Example: `` (empty) → `geopod` + static String getFilePath(String relativePath) { - // Remove leading slash if present + // Remove leading slash if present. final cleanPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; // If path already includes app dir, return as-is + if (cleanPath.startsWith('$appDirName/') || cleanPath == appDirName) { return cleanPath; } - // Empty path means app root + // Empty path means app root. + if (cleanPath.isEmpty) { return appDirName; } - // Otherwise prepend app dir + // Otherwise prepend app dir. return '$appDirName/$cleanPath'; } @@ -94,6 +105,7 @@ class PodPath { /// /// [filePath] - Relative path to the file. /// Returns full URL like: `https://pods.solidcommunity.au/geopod/data/places/places.json` + static Future getFileUrl(String filePath) async { final fullPath = getFilePath(filePath); return await PodAuth.getResourceUrl(fullPath, isContainer: false); @@ -103,6 +115,7 @@ class PodPath { /// /// [dirPath] - Relative path to the directory. /// Returns full URL with trailing slash. + static Future getDirUrl(String dirPath) async { final fullPath = getFilePath(dirPath); return await PodAuth.getResourceUrl(fullPath, isContainer: true); @@ -112,6 +125,7 @@ class PodPath { /// /// [url] - Full POD URL. /// Returns path relative to POD root. + static String? extractPath(String url) { try { final uri = Uri.parse(url); @@ -125,6 +139,7 @@ class PodPath { /// /// [path] - File or directory path. /// Example: `geopod/data/places/places.json` → `geopod/data/places/` + static String getParentPath(String path) { final cleanPath = path.endsWith('/') ? path.substring(0, path.length - 1) diff --git a/lib/services/weather_service.dart b/lib/services/weather_service.dart index fa06dcd..e6050a1 100644 --- a/lib/services/weather_service.dart +++ b/lib/services/weather_service.dart @@ -18,11 +18,13 @@ import 'package:geopod/models/hourly_weather_data.dart'; import 'package:geopod/models/weather_data.dart'; /// Service for fetching weather data from Open-Meteo API. + class WeatherService { /// Base URL for Open-Meteo forecast API. static const String _forecastUrl = 'https://api.open-meteo.com/v1/forecast'; /// Base URL for Open-Meteo archive API. + static const String _archiveUrl = 'https://archive-api.open-meteo.com/v1/era5'; @@ -30,6 +32,7 @@ class WeatherService { /// /// Returns [WeatherData] for the specified [latitude] and [longitude]. /// Throws an exception if the request fails. + Future getCurrentWeather({ required double latitude, required double longitude, @@ -72,6 +75,7 @@ class WeatherService { /// Returns [HourlyWeatherData] with hourly data for the past [days]. /// Uses the archive API to get actual past weather, not forecasts. /// Maximum [days] is 10 to ensure data is available (ERA5 has ~5-7 day delay). + Future getPastWeather({ required double latitude, required double longitude, @@ -127,6 +131,7 @@ class WeatherService { /// /// Returns [HourlyWeatherData] with hourly forecast data for the next [days]. /// Maximum [days] is 16 (Open-Meteo forecast limit). + Future getForecastWeather({ required double latitude, required double longitude, @@ -174,17 +179,19 @@ class WeatherService { /// /// **Important**: ERA5 archive data has a delay of about 5-7 days. /// The end date must be at least 7 days before today. + Future getHistoricalWeather({ required double latitude, required double longitude, required DateTime startDate, required DateTime endDate, }) async { - // ERA5 data has approximately 5-7 days delay + // ERA5 data has approximately 5-7 days delay. final now = DateTime.now(); final maxEndDate = now.subtract(const Duration(days: 7)); - // Validate date range + // Validate date range. + if (endDate.isBefore(startDate)) { throw ArgumentError('endDate must be after startDate'); } diff --git a/lib/utils/ui_utils.dart b/lib/utils/ui_utils.dart index d10d744..e26111b 100644 --- a/lib/utils/ui_utils.dart +++ b/lib/utils/ui_utils.dart @@ -15,6 +15,7 @@ library; import 'package:flutter/material.dart'; /// Helper for showing snackbars with consistent styling. + class SnackBarHelper { /// Shows a loading snackbar with a progress indicator. static void showLoading( @@ -46,6 +47,7 @@ class SnackBarHelper { } /// Shows a success snackbar with a checkmark icon. + static void showSuccess( BuildContext context, String message, { @@ -68,6 +70,7 @@ class SnackBarHelper { } /// Shows an error snackbar with an error icon. + static void showError( BuildContext context, String message, { @@ -90,6 +93,7 @@ class SnackBarHelper { } /// Shows a warning snackbar with a warning icon. + static void showWarning( BuildContext context, String message, { @@ -112,6 +116,7 @@ class SnackBarHelper { } /// Shows an info snackbar. + static void showInfo( BuildContext context, String message, { @@ -128,6 +133,7 @@ class SnackBarHelper { } /// Helper for showing confirmation dialogs. + class DialogHelper { /// Shows a confirmation dialog with customizable title, content, and buttons. /// @@ -177,6 +183,7 @@ class DialogHelper { } /// Shows a destructive confirmation dialog (for delete operations). + static Future showDestructiveConfirmation( BuildContext context, { required String title, diff --git a/lib/utils/widget_utils.dart b/lib/utils/widget_utils.dart index 3446876..c1a07a2 100644 --- a/lib/utils/widget_utils.dart +++ b/lib/utils/widget_utils.dart @@ -29,18 +29,22 @@ import 'package:solidpod/solidpod.dart' show authStateNotifier; /// } /// } /// ``` + mixin AuthStateManagement on State { bool _isLoggedIn = false; /// Current login state. + bool get isLoggedIn => _isLoggedIn; /// Called when authentication state changes. /// Override this method to handle auth state changes. + void onAuthStateChanged(bool isLoggedIn); /// Initialize auth state listener. /// Call this in initState(). + void initAuthStateListener() { _isLoggedIn = authStateNotifier.value; authStateNotifier.addListener(_handleAuthStateChanged); @@ -48,6 +52,7 @@ mixin AuthStateManagement on State { /// Cleanup auth state listener. /// Call this in dispose(). + void disposeAuthStateListener() { authStateNotifier.removeListener(_handleAuthStateChanged); } @@ -70,6 +75,7 @@ mixin AuthStateManagement on State { /// _myValue = newValue; /// }); /// ``` + void safeSetState(State state, VoidCallback fn) { if (state.mounted) { // ignore: invalid_use_of_protected_member @@ -93,6 +99,7 @@ void safeSetState(State state, VoidCallback fn) { /// }, /// ); /// ``` + Future executeWithLoading({ required State state, required void Function(bool) setLoading, @@ -109,7 +116,7 @@ Future executeWithLoading({ try { await operation(); if (state.mounted) { - // CRITICAL: Execute setState to trigger rebuild after operation completes + // CRITICAL: Execute setState to trigger rebuild after operation completes. safeSetState(state, () => setLoading(false)); } return true; @@ -125,6 +132,7 @@ Future executeWithLoading({ } /// Post-frame callback helper that checks mounted state. + void addPostFrameCallback(State state, VoidCallback callback) { WidgetsBinding.instance.addPostFrameCallback((_) { if (state.mounted) { diff --git a/lib/widgets/add_place_form.dart b/lib/widgets/add_place_form.dart index 3c5836d..28c672f 100644 --- a/lib/widgets/add_place_form.dart +++ b/lib/widgets/add_place_form.dart @@ -39,6 +39,7 @@ import 'package:geopod/widgets/weather_dialog.dart'; /// Result returned from AddPlaceForm containing the place data. /// Used for optimistic updates - the Place is returned immediately /// before the save completes. + class AddPlaceResult { final Place place; @@ -50,6 +51,7 @@ class AddPlaceResult { /// A form widget that allows users to add a new place with coordinates and /// a note. Returns immediately with optimistic data for instant UI updates. + class AddPlaceForm extends StatefulWidget { const AddPlaceForm({ super.key, @@ -82,12 +84,15 @@ class _AddPlaceFormState extends State { Timer? _debounceTimer; /// Whether to encrypt this place. + bool _encrypt = true; @override void initState() { super.initState(); + // Pre-fill coordinates if provided. + if (widget.initialLatitude != null) { _latitudeController.text = widget.initialLatitude!.toStringAsFixed(6); } @@ -95,11 +100,13 @@ class _AddPlaceFormState extends State { _longitudeController.text = widget.initialLongitude!.toStringAsFixed(6); } - // Listen to coordinate changes for live preview + // Listen to coordinate changes for live preview. + _latitudeController.addListener(_onCoordinateChanged); _longitudeController.addListener(_onCoordinateChanged); - // Load initial address preview if coordinates provided + // Load initial address preview if coordinates provided. + if (widget.initialLatitude != null && widget.initialLongitude != null) { _loadAddressPreview(); } @@ -114,23 +121,26 @@ class _AddPlaceFormState extends State { super.dispose(); } - /// Called when coordinates change - triggers debounced address lookup + /// Called when coordinates change - triggers debounced address lookup. + void _onCoordinateChanged() { - // Cancel previous timer + // Cancel previous timer. _debounceTimer?.cancel(); // Start new timer (wait 800ms after user stops typing) + _debounceTimer = Timer(const Duration(milliseconds: 800), () { _loadAddressPreview(); }); } - /// Loads address preview from coordinates + /// Loads address preview from coordinates. + Future _loadAddressPreview() async { final latText = _latitudeController.text.trim(); final lngText = _longitudeController.text.trim(); - // Validate coordinates + // Validate coordinates. final lat = double.tryParse(latText); final lng = double.tryParse(lngText); @@ -150,14 +160,15 @@ class _AddPlaceFormState extends State { return; } - // Show loading state + // Show loading state. + setState(() { _isLoadingAddress = true; _addressPreview = null; }); try { - // Call geocoding API + // Call geocoding API. final address = await GeocodingService.getAddress(lat, lng); safeSetState(this, () { @@ -173,6 +184,7 @@ class _AddPlaceFormState extends State { } /// Validates that the input is a valid latitude (-90 to 90). + String? _validateLatitude(String? value) { if (value == null || value.trim().isEmpty) { return 'Latitude is required'; @@ -188,6 +200,7 @@ class _AddPlaceFormState extends State { } /// Validates that the input is a valid longitude (-180 to 180). + String? _validateLongitude(String? value) { if (value == null || value.trim().isEmpty) { return 'Longitude is required'; @@ -203,6 +216,7 @@ class _AddPlaceFormState extends State { } /// Validates that the note is not empty. + String? _validateNote(String? value) { if (value == null || value.trim().isEmpty) { return 'Note is required'; @@ -212,6 +226,7 @@ class _AddPlaceFormState extends State { /// INSTANT SAVE: Returns immediately with optimistic Place data. /// The actual save (geocoding + writePod) happens in the parent widget. + void _handleSave() { if (!_formKey.currentState!.validate()) { return; @@ -232,6 +247,7 @@ class _AddPlaceFormState extends State { ); // INSTANT: Close dialog and return Place for optimistic update. + Navigator.pop(context, AddPlaceResult(place: place, encrypted: _encrypt)); } @@ -317,7 +333,7 @@ class _AddPlaceFormState extends State { ), const SizedBox(height: 16), - // Address preview + // Address preview. if (_isLoadingAddress || _addressPreview != null) Container( padding: const EdgeInsets.all(12), diff --git a/lib/widgets/encryption/security_key_dialog.dart b/lib/widgets/encryption/security_key_dialog.dart index f9564cc..f94f076 100644 --- a/lib/widgets/encryption/security_key_dialog.dart +++ b/lib/widgets/encryption/security_key_dialog.dart @@ -18,6 +18,7 @@ import 'package:solidpod/solidpod.dart'; /// Shows a dialog to prompt user for security key. /// Returns true if key was successfully entered and verified. + Future showSecurityKeyDialog(BuildContext context) async { final keyController = TextEditingController(); bool result = false; @@ -131,6 +132,7 @@ Future showSecurityKeyDialog(BuildContext context) async { } /// Verify security key and set it if valid. + Future _verifyAndSetKey( String key, void Function(void Function()) setState, @@ -155,6 +157,7 @@ Future _verifyAndSetKey( } /// Shows a dialog when encryption is not set up. + Future showEncryptionNotSetupDialog(BuildContext context) async { await showDialog( context: context, diff --git a/lib/widgets/files_page.dart b/lib/widgets/files_page.dart index 8e93215..c02bd45 100644 --- a/lib/widgets/files_page.dart +++ b/lib/widgets/files_page.dart @@ -20,6 +20,7 @@ import 'package:geopod/widgets/pod/pod_file_browser.dart'; /// A widget that wraps PodFileBrowser with login state checking. /// Shows a friendly login prompt when user is not logged in. + class FilesPage extends StatefulWidget { const FilesPage({super.key}); @@ -45,7 +46,7 @@ class _FilesPageState extends State with AuthStateManagement { @override void onAuthStateChanged(bool isLoggedIn) { - // State is already updated by mixin + // State is already updated by mixin. } Future _checkLoginStatus() async { @@ -81,7 +82,7 @@ class _FilesPageState extends State with AuthStateManagement { ); } - // Use the new PodFileBrowser instead of SolidFile + // Use the new PodFileBrowser instead of SolidFile. return const PodFileBrowser(basePath: '', title: 'Files'); } } diff --git a/lib/widgets/geomap.dart b/lib/widgets/geomap.dart index dcf5efd..e634750 100644 --- a/lib/widgets/geomap.dart +++ b/lib/widgets/geomap.dart @@ -65,7 +65,7 @@ class GeoMapWidgetState extends State GeoMapSettingsLoader, GeoMapEncryptedPlacesLoader, GeoMapNewsMixin { - // State variables implementation for mixins + // State variables implementation for mixins. @override final MapController mapController = MapController(); @override @@ -150,7 +150,8 @@ class GeoMapWidgetState extends State ); if (!mounted) return; - // Update state if login changed + // Update state if login changed. + if (result.loginStateChanged) { setState(() { isLoggedIn = result.actuallyLoggedIn; @@ -160,14 +161,15 @@ class GeoMapWidgetState extends State }); } - // Load fresh data if needed + // Load fresh data if needed. + if (result.needsRefresh) { final places = await loadAllPlaces(forceRefresh: false); if (mounted) { setState(() => allPlaces = places); } } else if (result.places != null && !result.loginStateChanged) { - // Use cached places if no refresh needed and state didn't change + // Use cached places if no refresh needed and state didn't change. if (mounted) { setState(() => allPlaces = result.places!); } @@ -234,7 +236,8 @@ class GeoMapWidgetState extends State } }); - // Handle encrypted places toggle + // Handle encrypted places toggle. + if (changes.encryptedToggled && changes.encryptedEnabled) { unawaited( loadEncryptedPlaces(skipKeyVerification: true).catchError(( @@ -255,6 +258,7 @@ class GeoMapWidgetState extends State } /// Handle location button tap - get user location and move map to it. + Future _onLocatePressed() async { if (isLocating) return; @@ -266,13 +270,14 @@ class GeoMapWidgetState extends State if (!mounted) return; if (result.success && result.location != null) { - // Save user location and move map to it + // Save user location and move map to it. setState(() { userLocation = result.location; }); mapController.move(result.location!, 15.0); - // Show success message + // Show success message. + ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Location found successfully'), @@ -281,7 +286,7 @@ class GeoMapWidgetState extends State ), ); } else { - // Show detailed error message + // Show detailed error message. if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -311,6 +316,7 @@ class GeoMapWidgetState extends State } /// Cached getter for filtered markers to avoid expensive rebuilds. + List get _filteredMarkers { return getCachedFilteredMarkers( allPlaces: allPlaces, @@ -331,7 +337,7 @@ class GeoMapWidgetState extends State longitude: lng, ); if (result != null && mounted) { - // If adding encrypted place, auto-enable showEncryptedPlaces so user can see it + // If adding encrypted place, auto-enable showEncryptedPlaces so user can see it. if (result.encrypted && !mapSettings.showEncryptedPlaces) { safeSetState(this, () { mapSettings = mapSettings.copyWith(showEncryptedPlaces: true); @@ -362,7 +368,7 @@ class GeoMapWidgetState extends State } Future _confirmAndDeletePlace(MarkerData m) async { - // Set flag to skip placesChangeNotifier during our delete operation + // Set flag to skip placesChangeNotifier during our delete operation. skipPlacesChangeNotification = true; try { await confirmAndDeletePlace( @@ -405,7 +411,8 @@ class GeoMapWidgetState extends State initialZoom: initialZoom, userLocation: userLocation, ), - // Loading indicator + + // Loading indicator. buildLoadingIndicator(isLoading: isLoadingPlaces), AddPlaceOverlayButton( isLoading: isLoadingPlaces, @@ -424,7 +431,8 @@ class GeoMapWidgetState extends State visibleNewsCount: getVisibleNewsMarkersImpl().length, onTap: toggleNewsMarkers, ), - // Fullscreen toggle button + + // Fullscreen toggle button. const FullscreenToggleButton(), ], ), diff --git a/lib/widgets/hourly_weather_chart.dart b/lib/widgets/hourly_weather_chart.dart index 5c9d1c4..8357619 100644 --- a/lib/widgets/hourly_weather_chart.dart +++ b/lib/widgets/hourly_weather_chart.dart @@ -25,6 +25,7 @@ import 'weather/weather_chart_pdf.dart'; import 'weather/weather_chart_range_indicator.dart'; /// Displays hourly weather data as a simple line chart. + class HourlyWeatherChart extends StatefulWidget { const HourlyWeatherChart({ required this.data, @@ -67,10 +68,10 @@ class _HourlyWeatherChartState extends State { @override Widget build(BuildContext context) { - // Get configuration for this data type + // Get configuration for this data type. const maxChartDataPoints = 30; - // Process weather data + // Process weather data. final processedData = processWeatherData( data: widget.data, dataType: widget.dataType, @@ -78,7 +79,7 @@ class _HourlyWeatherChartState extends State { maxChartDataPoints: maxChartDataPoints, ); - // Extract processed data + // Extract processed data. final dailyData = processedData.dailyData; final originalDailyData = processedData.originalDailyData; final originalDailyMaxData = processedData.originalDailyMaxData; @@ -93,12 +94,13 @@ class _HourlyWeatherChartState extends State { final dailyMinMax = processedData.dailyMinMax; final precipitationHours = processedData.precipitationHours; - // Get data metadata + // Get data metadata. final title = getDataTitle(widget.dataType); final unit = getDataUnit(widget.dataType); final icon = getDataIcon(widget.dataType); - // Check if data is empty + // Check if data is empty. + if (dailyData.isEmpty) { return WeatherChartEmptyState(dataTitle: title); } @@ -106,7 +108,7 @@ class _HourlyWeatherChartState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header with date range + // Header with date range. WeatherChartHeader( title: title, startDate: widget.data.startDate, @@ -126,7 +128,7 @@ class _HourlyWeatherChartState extends State { ), const SizedBox(height: 16), - // Legend for temperature and wind speed dual-line chart + // Legend for temperature and wind speed dual-line chart. if (widget.dataType == 'temperature') Padding( padding: const EdgeInsets.only(bottom: 8), @@ -175,7 +177,7 @@ class _HourlyWeatherChartState extends State { ), ), - // Simple chart using daily data + // Simple chart using daily data. Container( height: 250, decoration: BoxDecoration( @@ -211,7 +213,7 @@ class _HourlyWeatherChartState extends State { ), const SizedBox(height: 4), - // Chart info with tooltip + // Chart info with tooltip. if (widget.data.getDailyAverages().length > maxChartDataPoints) Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -220,7 +222,7 @@ class _HourlyWeatherChartState extends State { Builder( builder: (context) => GestureDetector( onTap: () { - // Show info dialog when tapped + // Show info dialog when tapped. showDialog( context: context, builder: (context) => AlertDialog( @@ -260,7 +262,7 @@ class _HourlyWeatherChartState extends State { ), const SizedBox(height: 8), - // Export PDF button + // Export PDF button. Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: OutlinedButton.icon( @@ -293,7 +295,7 @@ class _HourlyWeatherChartState extends State { ), const SizedBox(height: 12), - // Daily data list with scrollbar + // Daily data list with scrollbar. Row( children: [ Text( diff --git a/lib/widgets/locations/detail_row.dart b/lib/widgets/locations/detail_row.dart index 750f651..391cc02 100644 --- a/lib/widgets/locations/detail_row.dart +++ b/lib/widgets/locations/detail_row.dart @@ -28,6 +28,7 @@ library; import 'package:flutter/material.dart'; /// A simple detail row widget. + class DetailRow extends StatelessWidget { const DetailRow({super.key, required this.label, required this.value}); diff --git a/lib/widgets/locations/edit_import_place_dialog.dart b/lib/widgets/locations/edit_import_place_dialog.dart index a8e91ee..4cb0248 100644 --- a/lib/widgets/locations/edit_import_place_dialog.dart +++ b/lib/widgets/locations/edit_import_place_dialog.dart @@ -30,6 +30,7 @@ import 'package:flutter/material.dart'; import 'package:geopod/models/place.dart'; /// Dialog for editing a place during import preview. + class EditImportPlaceDialog extends StatefulWidget { const EditImportPlaceDialog({ super.key, diff --git a/lib/widgets/locations/edit_place_dialog.dart b/lib/widgets/locations/edit_place_dialog.dart index ca20471..3eff6f2 100644 --- a/lib/widgets/locations/edit_place_dialog.dart +++ b/lib/widgets/locations/edit_place_dialog.dart @@ -31,6 +31,7 @@ import 'package:geopod/models/place.dart'; import 'package:geopod/services/geocoding_service.dart'; /// Dialog for editing a place's lat, lng, and note. + class EditPlaceDialog extends StatefulWidget { const EditPlaceDialog({super.key, required this.place}); @@ -70,6 +71,7 @@ class _EditPlaceDialogState extends State { } /// Previews the address for current coordinates. + Future _previewAddressForCoordinates() async { final lat = double.tryParse(_latController.text); final lng = double.tryParse(_lngController.text); @@ -195,6 +197,7 @@ class _EditPlaceDialogState extends State { ], ), const SizedBox(height: 12), + // Preview address button. SizedBox( width: double.infinity, @@ -214,6 +217,7 @@ class _EditPlaceDialogState extends State { ), ), const SizedBox(height: 12), + // Address preview. Container( width: double.infinity, diff --git a/lib/widgets/locations/import_format_dialog.dart b/lib/widgets/locations/import_format_dialog.dart index 7691254..02ebab8 100644 --- a/lib/widgets/locations/import_format_dialog.dart +++ b/lib/widgets/locations/import_format_dialog.dart @@ -28,6 +28,7 @@ library; import 'package:flutter/material.dart'; /// Dialog showing the expected JSON format for importing places. + class ImportFormatDialog extends StatelessWidget { const ImportFormatDialog({super.key}); diff --git a/lib/widgets/locations/import_operations.dart b/lib/widgets/locations/import_operations.dart index 0453ab9..74a3e39 100644 --- a/lib/widgets/locations/import_operations.dart +++ b/lib/widgets/locations/import_operations.dart @@ -19,6 +19,7 @@ import 'package:geopod/widgets/locations/import_preview_dialog.dart'; import 'package:geopod/widgets/locations_page.dart'; /// Shows import failed dialog with errors. + Future showImportFailedDialog( BuildContext context, List errors, @@ -81,6 +82,7 @@ Future showImportFailedDialog( } /// Shows importing progress dialog. + void showImportingProgressDialog( BuildContext context, ValueNotifier progress, @@ -107,6 +109,7 @@ void showImportingProgressDialog( } /// Shows import success snackbar. + void showImportSuccessSnackbar(BuildContext context, int count) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -123,6 +126,7 @@ void showImportSuccessSnackbar(BuildContext context, int count) { } /// Shows import failure snackbar. + void showImportFailureSnackbar(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -133,6 +137,7 @@ void showImportFailureSnackbar(BuildContext context) { } /// Shows no places to export snackbar. + void showNoPlacesToExportSnackbar(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -143,6 +148,7 @@ void showNoPlacesToExportSnackbar(BuildContext context) { } /// Shows no places found snackbar. + void showNoPlacesFoundSnackbar(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -153,6 +159,7 @@ void showNoPlacesFoundSnackbar(BuildContext context) { } /// Performs the full import flow. + Future performImportFlow( BuildContext context, Future Function() onSuccess, @@ -195,7 +202,8 @@ Future performImportFlow( if (!context.mounted) return false; - // If encrypted, check security key first + // If encrypted, check security key first. + if (encrypted) { final hasKey = await EncryptedPlacesService.ensureSecurityKey( context, @@ -222,7 +230,7 @@ Future performImportFlow( bool success; if (encrypted) { - // Import to encrypted storage + // Import to encrypted storage. success = await EncryptedPlacesService.mergeImportedEncryptedPlaces( edited, context, @@ -231,7 +239,7 @@ Future performImportFlow( 'Importing ${edited.length} places (encrypted)...\nFetching addresses ($c/$t)...', ); } else { - // Import to regular storage + // Import to regular storage. success = await PlacesService.mergeImportedPlaces( edited, context, diff --git a/lib/widgets/locations/import_preview_dialog.dart b/lib/widgets/locations/import_preview_dialog.dart index 1832306..a7bac46 100644 --- a/lib/widgets/locations/import_preview_dialog.dart +++ b/lib/widgets/locations/import_preview_dialog.dart @@ -31,6 +31,7 @@ import 'package:geopod/models/place.dart'; import 'package:geopod/widgets/locations/edit_import_place_dialog.dart'; /// Result of import preview dialog containing places and encryption flag. + class ImportPreviewResult { final List places; final bool encrypted; @@ -39,6 +40,7 @@ class ImportPreviewResult { } /// Dialog showing a preview of places to be imported with edit/delete capabilities. + class ImportPreviewDialog extends StatefulWidget { const ImportPreviewDialog({ super.key, @@ -60,16 +62,20 @@ class _ImportPreviewDialogState extends State { /// Whether to encrypt imported places. /// Defaults to true for consistency with add place form. + bool _encrypt = true; @override void initState() { super.initState(); + // Create a mutable copy of the places list. + _editablePlaces = List.from(widget.places); } /// Opens the edit dialog for a place in the preview list. + Future _editPreviewPlace(int index) async { final place = _editablePlaces[index]; final result = await showDialog( @@ -85,6 +91,7 @@ class _ImportPreviewDialogState extends State { } /// Removes a place from the preview list. + void _removePreviewPlace(int index) { setState(() { _editablePlaces.removeAt(index); @@ -140,6 +147,7 @@ class _ImportPreviewDialogState extends State { ), const SizedBox(height: 12), ], + // Info box about editing. Container( padding: const EdgeInsets.all(10), @@ -169,6 +177,7 @@ class _ImportPreviewDialogState extends State { ), ), const SizedBox(height: 12), + // Encryption option. Container( padding: const EdgeInsets.all(12), diff --git a/lib/widgets/locations/locations_page_header.dart b/lib/widgets/locations/locations_page_header.dart index 2b40e1e..891610f 100644 --- a/lib/widgets/locations/locations_page_header.dart +++ b/lib/widgets/locations/locations_page_header.dart @@ -28,6 +28,7 @@ library; import 'package:flutter/material.dart'; /// Header section showing title and count of places. + class LocationsPageHeader extends StatelessWidget { final int placeCount; final bool isLoading; @@ -72,6 +73,7 @@ class LocationsPageHeader extends StatelessWidget { } /// Action buttons row for import/export/clear. + class LocationsActionButtons extends StatelessWidget { final bool isLoading; final VoidCallback onExport; diff --git a/lib/widgets/locations/locations_page_views.dart b/lib/widgets/locations/locations_page_views.dart index b688842..34ee4ed 100644 --- a/lib/widgets/locations/locations_page_views.dart +++ b/lib/widgets/locations/locations_page_views.dart @@ -28,6 +28,7 @@ library; import 'package:flutter/material.dart'; /// Widget shown while loading places. + class LoadingView extends StatelessWidget { const LoadingView({super.key}); @@ -47,6 +48,7 @@ class LoadingView extends StatelessWidget { } /// Widget shown when an error occurs. + class ErrorView extends StatelessWidget { final String errorMessage; final VoidCallback onRetry; @@ -88,6 +90,7 @@ class ErrorView extends StatelessWidget { } /// Widget shown when no places are saved. + class EmptyPlacesView extends StatelessWidget { final VoidCallback onRefresh; final VoidCallback onImport; diff --git a/lib/widgets/locations/place_list_tile.dart b/lib/widgets/locations/place_list_tile.dart index 048465b..0d7e584 100644 --- a/lib/widgets/locations/place_list_tile.dart +++ b/lib/widgets/locations/place_list_tile.dart @@ -33,6 +33,7 @@ import 'package:geopod/widgets/locations/detail_row.dart'; /// A list tile widget for displaying a single user place. /// /// Only displays user's Pod data (not local canned examples). + class PlaceListTile extends StatelessWidget { const PlaceListTile({ super.key, diff --git a/lib/widgets/locations/place_operations.dart b/lib/widgets/locations/place_operations.dart index 8654572..2970714 100644 --- a/lib/widgets/locations/place_operations.dart +++ b/lib/widgets/locations/place_operations.dart @@ -17,6 +17,7 @@ import 'package:geopod/utils/ui_utils.dart'; import 'package:geopod/widgets/locations_page.dart'; /// Shows delete confirmation dialog. + Future showDeletePlaceConfirmation(BuildContext context, Place place) { return DialogHelper.showDestructiveConfirmation( context, @@ -27,6 +28,7 @@ Future showDeletePlaceConfirmation(BuildContext context, Place place) { } /// Shows clear all confirmation dialog. + Future showClearAllConfirmation(BuildContext context, int count) { return DialogHelper.showDestructiveConfirmation( context, @@ -38,6 +40,7 @@ Future showClearAllConfirmation(BuildContext context, int count) { } /// Deletes a place and shows appropriate snackbars. + Future deletePlaceWithFeedback(BuildContext context, Place place) async { SnackBarHelper.showInfo( context, @@ -62,6 +65,7 @@ Future deletePlaceWithFeedback(BuildContext context, Place place) async { } /// Clears all places and shows appropriate snackbars. + Future clearAllPlacesWithFeedback(BuildContext context, int count) async { SnackBarHelper.showInfo( context, @@ -85,6 +89,7 @@ Future clearAllPlacesWithFeedback(BuildContext context, int count) async { } /// Shows export success/failure snackbar. + void showExportResultSnackbar(BuildContext context, bool success, int count) { if (success) { SnackBarHelper.showSuccess(context, 'Exported $count places successfully'); @@ -94,6 +99,7 @@ void showExportResultSnackbar(BuildContext context, bool success, int count) { } /// Shows updating place snackbar. + void showUpdatingPlaceSnackbar(BuildContext context, bool coordsChanged) { SnackBarHelper.showLoading( context, @@ -104,11 +110,13 @@ void showUpdatingPlaceSnackbar(BuildContext context, bool coordsChanged) { } /// Shows update success snackbar. + void showUpdateSuccessSnackbar(BuildContext context) { SnackBarHelper.showSuccess(context, 'Place updated successfully'); } /// Shows update failure snackbar. + void showUpdateFailureSnackbar(BuildContext context) { SnackBarHelper.showError(context, 'Failed to update place'); } diff --git a/lib/widgets/locations_page.dart b/lib/widgets/locations_page.dart index f97e40e..dba4227 100644 --- a/lib/widgets/locations_page.dart +++ b/lib/widgets/locations_page.dart @@ -43,17 +43,20 @@ class _LocationsPageState extends State @override void initState() { super.initState(); - // CRITICAL: Initialize auth listener FIRST to get current state + + // CRITICAL: Initialize auth listener FIRST to get current state. + initAuthStateListener(); - // Try to load from cache for instant display + // Try to load from cache for instant display. final cm = PlacesCacheManager(); final cached = cm.allPlaces; final cacheState = cm.wasLoggedInWhenCached; - // Only use cache if it matches current login state + // Only use cache if it matches current login state. + if (cached != null && cached.isNotEmpty && cacheState == isLoggedIn) { - // Show POD places if logged in, local places if not + // Show POD places if logged in, local places if not. _places = isLoggedIn ? cached .where((p) => !p.isLocal) @@ -66,7 +69,7 @@ class _LocationsPageState extends State } else { // Cache state doesn't match current login state or no cache // This happens when: guest cache exists but now logged in, or vice versa - // _verifyLoginAndRefresh will handle the refresh + // _verifyLoginAndRefresh will handle the refresh. _isLoading = true; } @@ -75,10 +78,10 @@ class _LocationsPageState extends State } Future _verifyLoginAndRefresh() async { - // Always check current login state from server + // Always check current login state from server. final loggedIn = await isUserLoggedIn(); - // Check if cache matches the actual login state from server + // Check if cache matches the actual login state from server. final cm = PlacesCacheManager(); final cacheState = cm.wasLoggedInWhenCached; final cacheMatchesLoginState = cacheState == loggedIn; @@ -87,18 +90,19 @@ class _LocationsPageState extends State // If auth state differs from mixin state, force reload // (onAuthStateChanged will also be triggered, but that's okay - double check ensures consistency) - // If we haven't loaded once yet, check if cache matches login state + // If we haven't loaded once yet, check if cache matches login state. + if (loggedIn != isLoggedIn) { - // Auth state mismatch - force refresh to get correct data + // Auth state mismatch - force refresh to get correct data. await _loadPlaces(forceRefresh: true); } else if (!_hasLoadedOnce) { // First load but auth state matches - // Check if cache is from the correct login state + // Check if cache is from the correct login state. if (!cacheMatchesLoginState) { // Cache is from different login state (e.g., guest cache when now logged in) await _loadPlaces(forceRefresh: true); } else { - // Cache matches current login state - safe to use + // Cache matches current login state - safe to use. await _loadPlaces(forceRefresh: false); } } @@ -116,12 +120,12 @@ class _LocationsPageState extends State // MUST force refresh on auth state change: // - Guest -> Login: need to fetch Pod data // - Login -> Logout: need to show local examples - // - Cache from previous state is invalid + // - Cache from previous state is invalid. _loadPlaces(forceRefresh: true); } void _onPlacesChanged() { - // Load places regardless of login state to ensure local places are visible + // Load places regardless of login state to ensure local places are visible. if (mounted) { _loadPlaces(forceRefresh: false); } @@ -136,9 +140,11 @@ class _LocationsPageState extends State final places = await PlacesService.fetchPlaces( forceRefresh: forceRefresh, ); + // Filter based on login state: // - Logged in: show POD places (user's own places) - // - Not logged in: show local example places + // - Not logged in: show local example places. + _places = isLoggedIn ? places.where((p) => !p.isLocal).toList() : places.where((p) => p.isLocal).toList(); @@ -253,10 +259,11 @@ class _LocationsPageState extends State @override Widget build(BuildContext context) { - // Show loading view only on first load + // Show loading view only on first load. if (_isLoading && !_hasLoadedOnce) return const LoadingView(); - // Show error view if there's an error + // Show error view if there's an error. + if (_errorMessage != null) { return ErrorView(errorMessage: _errorMessage!, onRetry: _refresh); } @@ -266,10 +273,11 @@ class _LocationsPageState extends State // For not logged in: use _places directly (which contains local examples) final displayPlaces = isLoggedIn ? _userPlaces : _places; - // Show empty view if no places + // Show empty view if no places. + if (displayPlaces.isEmpty) { // Show NotLoggedInView for logged-in users with no places - // Show different message for not-logged-in users + // Show different message for not-logged-in users. if (isLoggedIn) { return EmptyPlacesView(onRefresh: _refresh, onImport: _importPlaces); } else { @@ -295,7 +303,8 @@ class _LocationsPageState extends State isLoading: _isLoading, onRefresh: _refresh, ), - // Only show action buttons when logged in + + // Only show action buttons when logged in. if (isLoggedIn) LocationsActionButtons( isLoading: _isLoading, @@ -312,7 +321,8 @@ class _LocationsPageState extends State final p = displayPlaces[i]; return PlaceListTile( place: p, - // Only allow edit/delete when logged in and place is not local + + // Only allow edit/delete when logged in and place is not local. onEdit: isLoggedIn && !p.isLocal ? () => _editPlace(p) : null, onDelete: isLoggedIn && !p.isLocal ? () => _deletePlace(p) diff --git a/lib/widgets/map/delete_place_handler.dart b/lib/widgets/map/delete_place_handler.dart index 8fd6c9c..1e982cc 100644 --- a/lib/widgets/map/delete_place_handler.dart +++ b/lib/widgets/map/delete_place_handler.dart @@ -20,6 +20,7 @@ import 'package:geopod/widgets/geomap.dart'; import 'package:geopod/widgets/map/marker_data.dart'; /// Result of a delete operation. + class DeletePlaceResult { final bool success; final int? removedIndex; @@ -33,6 +34,7 @@ class DeletePlaceResult { } /// Prepares a place for deletion by finding it in the list. + DeletePlaceResult prepareDeletePlace({ required MarkerData marker, required List allPlaces, @@ -49,6 +51,7 @@ DeletePlaceResult prepareDeletePlace({ } /// Restores a place after failed deletion. + void restorePlace({ required List allPlaces, required int originalIndex, @@ -62,26 +65,28 @@ void restorePlace({ } /// Updates cache after successful deletion. + void updateCacheAfterDelete(List allPlaces) { PlacesCacheManager().cacheAllPlaces(allPlaces); } /// Performs the delete operation on the server. /// Routes to appropriate service based on whether the place is encrypted. + Future performDeleteOnServer({ required String placeId, required BuildContext context, required bool isEncrypted, }) async { if (isEncrypted) { - // Delete from encrypted places service + // Delete from encrypted places service. return await EncryptedPlacesService.deleteEncryptedPlace( placeId, context, const GeoMapWidget(), ); } else { - // Delete from regular places service + // Delete from regular places service. return await PlacesService.deletePlace( placeId, context, @@ -91,6 +96,7 @@ Future performDeleteOnServer({ } /// Shows a confirmation dialog for deleting a place. + Future showDeleteConfirmationDialog( BuildContext context, MarkerData marker, @@ -125,6 +131,7 @@ Future showDeleteConfirmationDialog( } /// Shows a snackbar indicating place not found. + void showPlaceNotFoundSnackbar(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -135,6 +142,7 @@ void showPlaceNotFoundSnackbar(BuildContext context) { } /// Shows a snackbar indicating deletion in progress. + void showDeletingSnackbar(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -145,6 +153,7 @@ void showDeletingSnackbar(BuildContext context) { } /// Shows a success snackbar after place is deleted. + void showDeleteSuccessSnackbar(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -162,6 +171,7 @@ void showDeleteSuccessSnackbar(BuildContext context) { } /// Shows an error snackbar when delete fails. + void showDeleteErrorSnackbar(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/widgets/map/fullscreen_toggle_button.dart b/lib/widgets/map/fullscreen_toggle_button.dart index a98c258..a730826 100644 --- a/lib/widgets/map/fullscreen_toggle_button.dart +++ b/lib/widgets/map/fullscreen_toggle_button.dart @@ -16,6 +16,7 @@ import 'package:geopod/services/fullscreen_service.dart'; /// A semi-transparent fullscreen toggle button that appears in the corner. /// When pressed, it toggles fullscreen mode and becomes opaque. + class FullscreenToggleButton extends StatelessWidget { const FullscreenToggleButton({super.key}); diff --git a/lib/widgets/map/geomap_action_handlers.dart b/lib/widgets/map/geomap_action_handlers.dart index 51c2aff..7f5ec16 100644 --- a/lib/widgets/map/geomap_action_handlers.dart +++ b/lib/widgets/map/geomap_action_handlers.dart @@ -18,6 +18,7 @@ import 'package:geopod/services/places_service.dart'; import 'package:geopod/utils/ui_utils.dart'; /// Handles user actions for place management. + mixin GeoMapActionHandlers on State { Set get savingPlaceIds; List get allPlaces; @@ -29,6 +30,7 @@ mixin GeoMapActionHandlers on State { Future loadAllPlaces(); /// Handle refresh button tap - reload places and show success message. + Future handleRefreshPressed() async { if (isLoadingPlaces) return; @@ -59,11 +61,13 @@ mixin GeoMapActionHandlers on State { } /// Handle optimistic save of place. + Future handleOptimisticSave(Place place) async { - // Prevent places change notifications during save + // Prevent places change notifications during save. skipPlacesChangeNotification = true; // Show immediately (optimistic update) + setState(() { savingPlaceIds.add(place.id); allPlaces = [...allPlaces, place]; @@ -76,7 +80,7 @@ mixin GeoMapActionHandlers on State { if (success) { SnackBarHelper.showSuccess(context, 'Place saved successfully'); } else { - // Rollback on failure + // Rollback on failure. setState(() { allPlaces = allPlaces.where((p) => p.id != place.id).toList(); }); @@ -84,7 +88,7 @@ mixin GeoMapActionHandlers on State { } } } catch (e) { - // Rollback on error + // Rollback on error. if (mounted) { setState(() { allPlaces = allPlaces.where((p) => p.id != place.id).toList(); @@ -102,6 +106,7 @@ mixin GeoMapActionHandlers on State { } /// Confirm and delete a place. + Future confirmAndDeletePlace(Place place) async { if (!mounted) return; diff --git a/lib/widgets/map/geomap_builders.dart b/lib/widgets/map/geomap_builders.dart index fa788ed..55eefcb 100644 --- a/lib/widgets/map/geomap_builders.dart +++ b/lib/widgets/map/geomap_builders.dart @@ -15,6 +15,7 @@ library; import 'package:flutter/material.dart'; /// Builds loading indicator overlay. + Widget buildLoadingIndicator({required bool isLoading}) { if (!isLoading) return const SizedBox.shrink(); diff --git a/lib/widgets/map/geomap_core.dart b/lib/widgets/map/geomap_core.dart index 7351acf..fa9e116 100644 --- a/lib/widgets/map/geomap_core.dart +++ b/lib/widgets/map/geomap_core.dart @@ -27,7 +27,8 @@ import 'package:geopod/widgets/map/user_location_marker_layer.dart'; /// /// Performance optimizations: /// - Uses RepaintBoundary to isolate map repaints from overlay UI -/// - Defers marker layer updates when not animating +/// - Defers marker layer updates when not animating. + Widget buildFlutterMapWidget({ required MapController mapController, required Animation fadeAnimation, @@ -53,15 +54,16 @@ Widget buildFlutterMapWidget({ opacity: fadeAnimation, child: FlutterMap( // Remove key to allow FlutterMap to properly handle tileProvider changes - // The TileLayer's ObjectKey will handle proper recreation when needed + // The TileLayer's ObjectKey will handle proper recreation when needed. mapController: mapController, options: MapOptions( initialCenter: initialCenter, initialZoom: initialZoom, minZoom: 3.0, maxZoom: maxZoom ?? mapSettings.mapSource.maxNativeZoom.toDouble(), + // Constrain latitude to ±85.11° (Web Mercator projection limits) - // Longitude is unrestricted to allow horizontal map wrapping + // Longitude is unrestricted to allow horizontal map wrapping. cameraConstraint: const CameraConstraint.containLatitude( 85.051129, -85.051129, @@ -76,7 +78,8 @@ Widget buildFlutterMapWidget({ tileProvider: tileProvider, applyFilter: applyFilter, ), - // Wrap marker layer in RepaintBoundary to isolate marker animations + + // Wrap marker layer in RepaintBoundary to isolate marker animations. RepaintBoundary( child: buildPlacesMarkerLayer( context: context, @@ -92,6 +95,7 @@ Widget buildFlutterMapWidget({ newsMarkers: visibleNewsMarkers, ), ), + // User location marker layer (always on top) ...() { final userLocationLayer = buildUserLocationMarkerLayer( @@ -107,6 +111,7 @@ Widget buildFlutterMapWidget({ } /// Builds the loading indicator overlay. + Widget buildLoadingIndicator({required bool isLoading}) { if (!isLoading) return const SizedBox.shrink(); return const Positioned( diff --git a/lib/widgets/map/geomap_encrypted_places_loader.dart b/lib/widgets/map/geomap_encrypted_places_loader.dart index 24f143c..a853d6e 100644 --- a/lib/widgets/map/geomap_encrypted_places_loader.dart +++ b/lib/widgets/map/geomap_encrypted_places_loader.dart @@ -22,6 +22,7 @@ import 'package:geopod/utils/widget_utils.dart'; import 'package:geopod/widgets/map/geomap_places_loader.dart'; /// Handles encrypted places loading. + mixin GeoMapEncryptedPlacesLoader on State { bool get isLoggedIn; MapSettings get mapSettings; @@ -30,6 +31,7 @@ mixin GeoMapEncryptedPlacesLoader on State { set allPlaces(List value); /// Load all places including encrypted if enabled. + Future loadAllPlaces({ bool forceRefresh = false, bool? includeEncrypted, @@ -54,6 +56,7 @@ mixin GeoMapEncryptedPlacesLoader on State { } /// Load encrypted places with optional key verification. + Future loadEncryptedPlaces({bool skipKeyVerification = false}) async { if (!isLoggedIn || !mounted) return; diff --git a/lib/widgets/map/geomap_event_handlers.dart b/lib/widgets/map/geomap_event_handlers.dart index 00813d1..4ac130e 100644 --- a/lib/widgets/map/geomap_event_handlers.dart +++ b/lib/widgets/map/geomap_event_handlers.dart @@ -23,6 +23,7 @@ import 'package:geopod/services/places/encrypted_places_service.dart'; import 'package:geopod/services/places_service.dart'; /// Handles authentication and places change events. + mixin GeoMapEventHandlers on State { MapController get mapController; List get allPlaces; @@ -40,6 +41,7 @@ mixin GeoMapEventHandlers on State { MapSettings get mapSettings; /// Handle authentication state changes. + Future onAuthStateChanged() async { if (!mounted) return; @@ -59,6 +61,7 @@ mixin GeoMapEventHandlers on State { } /// Handle login event. + Future handleLogin() async { if (!mounted) return; setState(() { @@ -71,9 +74,10 @@ mixin GeoMapEventHandlers on State { } /// Handle logout event. + Future handleLogout() async { // Force refresh to ensure we don't use any stale cache after logout - // This is critical for non-web platforms where SharedPreferences cleanup may be async + // This is critical for non-web platforms where SharedPreferences cleanup may be async. final places = await PlacesService.fetchPlaces( forceRefresh: true, includeEncrypted: false, @@ -86,6 +90,7 @@ mixin GeoMapEventHandlers on State { } /// Handle places changes from services. + Future onPlacesChanged() async { if (skipPlacesChangeNotification) { return; @@ -97,6 +102,7 @@ mixin GeoMapEventHandlers on State { } /// Load all places from services. + Future loadAllPlaces() async { final places = await PlacesService.fetchPlaces( forceRefresh: false, @@ -111,11 +117,12 @@ mixin GeoMapEventHandlers on State { } /// Verify login state and reload encrypted places if needed. + Future verifyLoginStateAndLoadData() async { if (!mounted || !isLoggedIn) return; if (mapSettings.showEncryptedPlaces) { - // Fetch encrypted places - this will reload from pod if needed + // Fetch encrypted places - this will reload from pod if needed. await EncryptedPlacesService.fetchEncryptedPlaces(forceRefresh: true); if (!mounted) return; await loadAllPlaces(); diff --git a/lib/widgets/map/geomap_initialization.dart b/lib/widgets/map/geomap_initialization.dart index 63dcdde..ddd89e1 100644 --- a/lib/widgets/map/geomap_initialization.dart +++ b/lib/widgets/map/geomap_initialization.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; /// Initializes animation controller and listeners for map widget. + void initializeMapState({ required AnimationController animationController, required Animation fadeAnimation, @@ -39,6 +40,7 @@ void initializeMapState({ } /// Initializes map widget after first frame. + void initializeMapPostFrame({ required BuildContext context, required AnimationController animationController, @@ -50,12 +52,14 @@ void initializeMapPostFrame({ animationController.forward(); - // Defer settings loading slightly to not block animation + // Defer settings loading slightly to not block animation. + Future.microtask(() { if (context.mounted) loadSettingsSync(); }); - // Defer login verification even more + // Defer login verification even more. + Future.delayed(const Duration(milliseconds: 50), () { if (context.mounted) verifyLoginStateAndLoadData(); }); @@ -63,6 +67,7 @@ void initializeMapPostFrame({ } /// Handles app lifecycle changes for map widget. + void handleMapLifecycleChange({ required AppLifecycleState state, required VoidCallback onResume, @@ -77,6 +82,7 @@ void handleMapLifecycleChange({ } /// Creates tile provider for map tiles. + TileProvider createTileProvider() { return NetworkTileProvider(); } diff --git a/lib/widgets/map/geomap_news_logic.dart b/lib/widgets/map/geomap_news_logic.dart index 02460fb..fa9998e 100644 --- a/lib/widgets/map/geomap_news_logic.dart +++ b/lib/widgets/map/geomap_news_logic.dart @@ -18,24 +18,26 @@ import 'package:latlong2/latlong.dart'; /// /// Returns true if position/zoom changed significantly enough to warrant /// a cache update. This prevents excessive updates during small movements. + bool shouldUpdateNewsCache({ required LatLng newPosition, required double newZoom, required LatLng? lastPosition, required double? lastZoom, }) { - // First time or no previous position + // First time or no previous position. if (lastPosition == null || lastZoom == null) { return true; } - // Calculate position change in degrees + // Calculate position change in degrees. final latDiff = (newPosition.latitude - lastPosition.latitude).abs(); final lngDiff = (newPosition.longitude - lastPosition.longitude).abs(); final zoomDiff = (newZoom - lastZoom).abs(); // Thresholds: ~1km movement or 1 zoom level change - // At zoom 12, 0.01 degrees ≈ 1.1 km + // At zoom 12, 0.01 degrees ≈ 1.1 km. + const positionThreshold = 0.01; const zoomThreshold = 1.0; diff --git a/lib/widgets/map/geomap_news_mixin.dart b/lib/widgets/map/geomap_news_mixin.dart index 730d1a9..99934bf 100644 --- a/lib/widgets/map/geomap_news_mixin.dart +++ b/lib/widgets/map/geomap_news_mixin.dart @@ -22,33 +22,41 @@ import 'package:geopod/widgets/map/geomap_news_logic.dart'; import 'package:geopod/widgets/map/news_operations.dart'; /// Mixin that provides news-related functionality for GeoMapWidget. + mixin GeoMapNewsMixin on State { /// News service instance - must be initialized in initState. GdeltNewsService get newsService; /// Map controller for bounds calculations. + MapController get mapController; /// Current news markers list. + List get newsMarkers; set newsMarkers(List value); /// Whether news markers are currently shown. + bool get showNewsMarkers; set showNewsMarkers(bool value); /// Whether news is currently loading. + bool get isLoadingNews; set isLoadingNews(bool value); /// Track last position to avoid unnecessary cache updates. + LatLng? lastNewsUpdatePosition; double? lastNewsUpdateZoom; /// Toggles news markers visibility by showing the news list dialog. + void toggleNewsMarkers() => showNewsListDialogAsyncImpl(); /// Shows the news list dialog. + Future showNewsListDialogAsyncImpl() async { if (!mounted) return; await showNewsListDialogAsync( @@ -69,6 +77,7 @@ mixin GeoMapNewsMixin on State { } /// Handles map position changes for news updates. + void onMapPositionChangedForNews(MapCamera pos, bool gesture) { if (showNewsMarkers && gesture) { if (shouldUpdateNewsCacheImpl(pos.center, pos.zoom)) { @@ -78,6 +87,7 @@ mixin GeoMapNewsMixin on State { } /// Checks if news cache should be updated based on position change. + bool shouldUpdateNewsCacheImpl(LatLng newPosition, double newZoom) { final result = shouldUpdateNewsCache( newPosition: newPosition, @@ -95,6 +105,7 @@ mixin GeoMapNewsMixin on State { } /// Updates news markers from cache. + void updateNewsFromCacheImpl() { if (!mounted) return; updateNewsFromCacheForBounds( @@ -106,6 +117,7 @@ mixin GeoMapNewsMixin on State { } /// Fetches news for current map bounds. + Future fetchNewsForCurrentBoundsImpl() async { if (!mounted) return; await fetchNewsForBounds( @@ -124,6 +136,7 @@ mixin GeoMapNewsMixin on State { } /// Gets visible news markers within current map bounds. + List getVisibleNewsMarkersImpl() => getVisibleNewsInBounds( mapController: mapController, newsMarkers: newsMarkers, diff --git a/lib/widgets/map/geomap_place_handlers.dart b/lib/widgets/map/geomap_place_handlers.dart index fc32734..c79ae07 100644 --- a/lib/widgets/map/geomap_place_handlers.dart +++ b/lib/widgets/map/geomap_place_handlers.dart @@ -28,6 +28,7 @@ import 'package:geopod/widgets/map/place_save_handler.dart'; /// Updates UI immediately, then performs background save. /// If [encrypted] is true, the place will be marked as encrypted for /// immediate purple marker display. + void handleOptimisticPlaceSave({ required Place place, required List allPlaces, @@ -37,24 +38,31 @@ void handleOptimisticPlaceSave({ required Future Function(Place) performBackgroundSave, bool encrypted = false, }) { - // Mark place as encrypted if saving to encrypted storage + // Mark place as encrypted if saving to encrypted storage. final placeToSave = encrypted ? place.copyWith(isEncrypted: true) : place; - // Update state first + + // Update state first. + setState(() { allPlaces.insert(0, placeToSave); savingPlaceIds.add(placeToSave.id); }); - // Show snackbar after frame to avoid jank + + // Show snackbar after frame to avoid jank. + SchedulerBinding.instance.addPostFrameCallback((_) { if (context.mounted) { showSavingSnackbar(context, placeToSave); } }); - // Start background save + + // Start background save. + unawaited(performBackgroundSave(placeToSave)); } /// Performs background save and updates UI on completion. + Future performPlaceBackgroundSave({ required Place originalPlace, required BuildContext context, @@ -71,7 +79,7 @@ Future performPlaceBackgroundSave({ ); if (!context.mounted) return; if (updatedPlace != null) { - // Schedule state update after current frame to avoid animation jank + // Schedule state update after current frame to avoid animation jank. SchedulerBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; setState(() { @@ -85,7 +93,9 @@ Future performPlaceBackgroundSave({ } } catch (e) { if (!context.mounted) return; - // Schedule error handling after current frame + + // Schedule error handling after current frame. + SchedulerBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; setState(() { @@ -98,6 +108,7 @@ Future performPlaceBackgroundSave({ } /// Confirms and deletes a place with optimistic UI updates. + Future confirmAndDeletePlace({ required MarkerData marker, required BuildContext context, @@ -118,6 +129,7 @@ Future confirmAndDeletePlace({ // Update cache BEFORE server delete to prevent race condition // (placesChangeNotifier triggers _loadAllPlaces which would restore old data) + updateCacheAfterDelete(allPlaces); showDeletingSnackbar(context); diff --git a/lib/widgets/map/geomap_places_loader.dart b/lib/widgets/map/geomap_places_loader.dart index f67d636..6278e42 100644 --- a/lib/widgets/map/geomap_places_loader.dart +++ b/lib/widgets/map/geomap_places_loader.dart @@ -21,6 +21,7 @@ import 'package:geopod/services/places_service.dart' import 'package:geopod/widgets/map/geomap_state_logic.dart'; /// Result of loading places. + class LoadPlacesResult { final List places; final bool showLoading; @@ -34,6 +35,7 @@ class LoadPlacesResult { } /// Loads all places (local and pod) with optional encrypted places. + Future loadPlacesWithState({ required List currentPlaces, required bool forceRefresh, @@ -47,7 +49,7 @@ Future loadPlacesWithState({ includeEncrypted: includeEncrypted, ); - // Check if data actually changed to avoid unnecessary rebuild + // Check if data actually changed to avoid unnecessary rebuild. final hasChanges = currentPlaces.length != places.length || !currentPlaces.every((p) => places.any((np) => np.id == p.id)); @@ -60,6 +62,7 @@ Future loadPlacesWithState({ } /// Result of loading encrypted places. + class LoadEncryptedPlacesResult { final List encryptedPlaces; final bool cancelled; @@ -74,6 +77,7 @@ class LoadEncryptedPlacesResult { /// Load encrypted places on demand when user enables the setting. /// If [skipKeyVerification] is true, assumes security key is already verified. + Future loadEncryptedPlacesData({ required BuildContext context, required Widget widget, @@ -91,7 +95,7 @@ Future loadEncryptedPlacesData({ widget, ); if (!hasKey) { - // User cancelled or key not available + // User cancelled or key not available. return LoadEncryptedPlacesResult(encryptedPlaces: [], cancelled: true); } } @@ -108,18 +112,22 @@ Future loadEncryptedPlacesData({ } /// Merges encrypted places into all places list. + List mergeEncryptedPlaces({ required List allPlaces, required List encryptedPlaces, }) { - // Remove any existing encrypted places first + // Remove any existing encrypted places first. final result = allPlaces.where((p) => !p.isEncrypted).toList(); - // Add newly loaded encrypted places + + // Add newly loaded encrypted places. + result.addAll(encryptedPlaces); return result; } /// Removes encrypted places from all places list. + List removeEncryptedPlaces({required List allPlaces}) { return allPlaces.where((p) => !p.isEncrypted).toList(); } diff --git a/lib/widgets/map/geomap_settings.dart b/lib/widgets/map/geomap_settings.dart index 1c57640..692e0fa 100644 --- a/lib/widgets/map/geomap_settings.dart +++ b/lib/widgets/map/geomap_settings.dart @@ -23,6 +23,7 @@ import 'package:geopod/services/map_settings_service.dart'; import 'package:geopod/services/places/encrypted_places_service.dart'; /// Result of loading map settings. + class LoadSettingsResult { final MapSettings settings; final LatLng? initialCenter; @@ -38,6 +39,7 @@ class LoadSettingsResult { } /// Loads map settings synchronously from SharedPreferences. + Future loadMapSettingsSync({ required bool viewportInitialized, }) async { @@ -51,7 +53,8 @@ Future loadMapSettingsSync({ ); } - // Load viewport separately + // Load viewport separately. + try { final viewport = await MapSettingsService.getStartupViewport(settings); return LoadSettingsResult( @@ -76,6 +79,7 @@ Future loadMapSettingsSync({ /// Validates the saved encrypted places setting. /// Returns true if validation passed and encrypted places should be loaded. + Future validateSavedEncryptedSetting({ required MapSettings mapSettings, required bool isLoggedIn, @@ -84,11 +88,11 @@ Future validateSavedEncryptedSetting({ if (!mapSettings.showEncryptedPlaces) return false; if (!isLoggedIn) { - // Not logged in, setting should be reset + // Not logged in, setting should be reset. return false; } - // Check if encrypted places are already loaded + // Check if encrypted places are already loaded. final hasEncryptedPlaces = allPlaces.any((p) => p.isEncrypted); if (hasEncryptedPlaces) { debugPrint( @@ -97,7 +101,7 @@ Future validateSavedEncryptedSetting({ return false; } - // Check if security key is already available + // Check if security key is already available. final hasKey = await EncryptedPlacesService.isSecurityKeyAvailable(); debugPrint( 'validateSavedEncryptedSetting: hasKey=$hasKey, will load encrypted places', @@ -108,6 +112,7 @@ Future validateSavedEncryptedSetting({ } /// Saves current viewport position if rememberViewport is enabled. + void saveViewportIfEnabled({ required MapController mapController, required MapSettings mapSettings, @@ -119,11 +124,12 @@ void saveViewportIfEnabled({ final zoom = mapController.camera.zoom; saveLastViewport(lat: center.latitude, lng: center.longitude, zoom: zoom); } catch (_) { - // Ignore errors during viewport saving + // Ignore errors during viewport saving. } } /// Handles settings dialog changes. + class SettingsChangeResult { final bool mapSourceChanged; final bool encryptedToggled; @@ -137,6 +143,7 @@ class SettingsChangeResult { } /// Computes what changed when settings are updated. + SettingsChangeResult computeSettingsChanges({ required MapSettings oldSettings, required MapSettings newSettings, diff --git a/lib/widgets/map/geomap_settings_loader.dart b/lib/widgets/map/geomap_settings_loader.dart index c218233..03163f0 100644 --- a/lib/widgets/map/geomap_settings_loader.dart +++ b/lib/widgets/map/geomap_settings_loader.dart @@ -25,6 +25,7 @@ import 'package:geopod/utils/widget_utils.dart'; import 'package:geopod/widgets/map/geomap_settings.dart'; /// Handles settings loading and validation. + mixin GeoMapSettingsLoader on State { MapController get mapController; MapSettings get mapSettings; @@ -39,6 +40,7 @@ mixin GeoMapSettingsLoader on State { List get allPlaces; /// Load settings synchronously with viewport restoration. + void loadSettingsSync(VoidCallback onLoadEncrypted) { loadMapSettingsSync(viewportInitialized: viewportInitialized) .then((result) { @@ -53,12 +55,14 @@ mixin GeoMapSettingsLoader on State { } }); - // Move map after state update if viewport was loaded + // Move map after state update if viewport was loaded. + if (result.initialCenter != null) { mapController.move(result.initialCenter!, result.initialZoom!); } - // Validate encrypted setting if enabled + // Validate encrypted setting if enabled. + if (result.settings.showEncryptedPlaces) { Future.delayed(const Duration(milliseconds: 200), () { if (mounted) { @@ -71,6 +75,7 @@ mixin GeoMapSettingsLoader on State { } /// Validate saved encrypted setting and load if valid. + Future validateSavedEncryptedSettingAndLoad( VoidCallback onLoadEncrypted, ) async { @@ -81,7 +86,7 @@ mixin GeoMapSettingsLoader on State { ); if (!shouldLoad) { - // Reset setting if validation failed + // Reset setting if validation failed. if (!isLoggedIn) { safeSetState(this, () { mapSettings = mapSettings.copyWith(showEncryptedPlaces: false); @@ -90,7 +95,8 @@ mixin GeoMapSettingsLoader on State { return; } - // Load encrypted places + // Load encrypted places. + onLoadEncrypted(); } } diff --git a/lib/widgets/map/geomap_state_logic.dart b/lib/widgets/map/geomap_state_logic.dart index 246dc53..0b9b141 100644 --- a/lib/widgets/map/geomap_state_logic.dart +++ b/lib/widgets/map/geomap_state_logic.dart @@ -17,6 +17,7 @@ import 'package:geopod/services/places_service.dart' show PlacesService, PlacesCacheManager; /// Verifies login state and returns appropriate places. + Future verifyLoginStateAndLoadData({ required bool currentIsLoggedIn, }) async { @@ -53,6 +54,7 @@ Future verifyLoginStateAndLoadData({ } /// Result of login state verification. + class VerifyLoginResult { final bool actuallyLoggedIn; final bool loginStateChanged; @@ -68,6 +70,7 @@ class VerifyLoginResult { } /// Loads all places with optional force refresh. + Future> loadAllPlaces({ bool forceRefresh = false, bool includeEncrypted = false, diff --git a/lib/widgets/map/geomap_state_mixin.dart b/lib/widgets/map/geomap_state_mixin.dart index 14829b7..e56ce5f 100644 --- a/lib/widgets/map/geomap_state_mixin.dart +++ b/lib/widgets/map/geomap_state_mixin.dart @@ -23,6 +23,7 @@ import 'package:geopod/services/map_settings_service.dart'; import 'package:geopod/widgets/map/marker_data.dart'; /// Manages map state variables. + mixin GeoMapStateMixin { MapController get mapController; TileProvider get tileProvider; @@ -64,6 +65,7 @@ mixin GeoMapStateMixin { } /// Manages marker cache for performance. + mixin MarkerCacheMixin { List? _cachedFilteredMarkers; int _lastPlacesHash = 0; @@ -76,13 +78,14 @@ mixin MarkerCacheMixin { Color _lastEncryptedPlacesColor = Colors.purple; /// Get filtered markers with caching to avoid expensive rebuilds. + List getCachedFilteredMarkers({ required List allPlaces, required MapSettings mapSettings, required Set savingPlaceIds, required List Function() builder, }) { - // Compute hashes to detect changes + // Compute hashes to detect changes. final placesHash = Object.hashAll(allPlaces.map((p) => p.id)); final savingHash = Object.hashAll(savingPlaceIds); final showLocal = mapSettings.showLocalPlaces; @@ -92,7 +95,8 @@ mixin MarkerCacheMixin { final localColor = mapSettings.localPlacesColor; final encryptedColor = mapSettings.encryptedPlacesColor; - // Return cached if nothing changed + // Return cached if nothing changed. + if (_cachedFilteredMarkers != null && placesHash == _lastPlacesHash && savingHash == _lastSavingIdsHash && @@ -105,7 +109,8 @@ mixin MarkerCacheMixin { return _cachedFilteredMarkers!; } - // Rebuild and cache + // Rebuild and cache. + _lastPlacesHash = placesHash; _lastSavingIdsHash = savingHash; _lastShowLocalPlaces = showLocal; @@ -119,6 +124,7 @@ mixin MarkerCacheMixin { } /// Clear marker cache. + void clearMarkerCache() { _cachedFilteredMarkers = null; } diff --git a/lib/widgets/map/geomap_viewport_logic.dart b/lib/widgets/map/geomap_viewport_logic.dart index 1525c3d..4933d4d 100644 --- a/lib/widgets/map/geomap_viewport_logic.dart +++ b/lib/widgets/map/geomap_viewport_logic.dart @@ -19,6 +19,7 @@ import 'package:geopod/services/map_settings_service.dart'; /// Adjusts zoom level if it exceeds the map source's max native zoom. /// /// Returns true if zoom was adjusted. + bool adjustZoomForMapSource({ required MapController mapController, required MapSettings mapSettings, diff --git a/lib/widgets/map/login_required_dialog.dart b/lib/widgets/map/login_required_dialog.dart index 7792f49..f28ff4e 100644 --- a/lib/widgets/map/login_required_dialog.dart +++ b/lib/widgets/map/login_required_dialog.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:solidui/solidui.dart'; /// Shows a dialog prompting user to login. + Future showLoginRequiredDialog(BuildContext context) async { await showDialog( context: context, diff --git a/lib/widgets/map/map_floating_buttons.dart b/lib/widgets/map/map_floating_buttons.dart index f6e5ea7..f7f31e1 100644 --- a/lib/widgets/map/map_floating_buttons.dart +++ b/lib/widgets/map/map_floating_buttons.dart @@ -28,6 +28,7 @@ library; import 'package:flutter/material.dart'; /// Column of floating action buttons for map controls. + class MapFloatingButtons extends StatelessWidget { final bool isLoadingPlaces; final VoidCallback onZoomIn; diff --git a/lib/widgets/map/map_overlay_buttons.dart b/lib/widgets/map/map_overlay_buttons.dart index e7f597f..99577e7 100644 --- a/lib/widgets/map/map_overlay_buttons.dart +++ b/lib/widgets/map/map_overlay_buttons.dart @@ -28,6 +28,7 @@ library; import 'package:flutter/material.dart'; /// Overlay button for adding places. + class AddPlaceOverlayButton extends StatelessWidget { final bool isLoading; final bool isLoggedIn; @@ -97,6 +98,7 @@ class AddPlaceOverlayButton extends StatelessWidget { } /// Overlay button for toggling news markers. + class NewsOverlayButton extends StatelessWidget { final bool isLoadingNews; final bool showNewsMarkers; diff --git a/lib/widgets/map/map_tile_layer.dart b/lib/widgets/map/map_tile_layer.dart index 9b98170..e3a22e3 100644 --- a/lib/widgets/map/map_tile_layer.dart +++ b/lib/widgets/map/map_tile_layer.dart @@ -17,6 +17,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:geopod/services/map_settings_service.dart'; /// Midnight color matrix for dark mode. + const midnightMatrix = [ -0.33, -0.33, @@ -41,6 +42,7 @@ const midnightMatrix = [ ]; /// Builds the tile layer with optional dark mode filter. + Widget buildMapTileLayer({ required MapSettings mapSettings, required TileProvider tileProvider, @@ -52,7 +54,7 @@ Widget buildMapTileLayer({ : const ColorFilter.mode(Colors.transparent, BlendMode.dst), child: TileLayer( // Use ObjectKey with tileProvider to ensure fresh TileLayer when provider changes - // This prevents widget reuse issues when app resumes with a new provider + // This prevents widget reuse issues when app resumes with a new provider. key: ObjectKey(tileProvider), urlTemplate: mapSettings.mapSource.urlTemplate, subdomains: mapSettings.mapSource.subdomains, diff --git a/lib/widgets/map/marker_data.dart b/lib/widgets/map/marker_data.dart index 5121a11..77e7a0d 100644 --- a/lib/widgets/map/marker_data.dart +++ b/lib/widgets/map/marker_data.dart @@ -33,6 +33,7 @@ import 'package:geopod/models/place.dart'; import 'package:geopod/services/map_settings_service.dart'; /// Data model for a map marker. + class MarkerData { final LatLng position; final String title; @@ -71,6 +72,7 @@ class MarkerData { } /// Converts places to filtered marker data based on settings. + List buildFilteredMarkers({ required List allPlaces, required MapSettings mapSettings, @@ -80,9 +82,11 @@ List buildFilteredMarkers({ if (mapSettings.hideAllMarkers) return []; final visible = allPlaces.where((p) { - // Filter out local places if disabled + // Filter out local places if disabled. if (p.isLocal && !mapSettings.showLocalPlaces) return false; - // Filter out encrypted places if disabled + + // Filter out encrypted places if disabled. + if (p.isEncrypted && !mapSettings.showEncryptedPlaces) return false; return true; }).toList(); diff --git a/lib/widgets/map/marker_details_sheet.dart b/lib/widgets/map/marker_details_sheet.dart index af46c6b..997fb5c 100644 --- a/lib/widgets/map/marker_details_sheet.dart +++ b/lib/widgets/map/marker_details_sheet.dart @@ -31,6 +31,7 @@ import 'package:geopod/widgets/map/marker_data.dart'; import 'package:geopod/widgets/weather_dialog.dart'; /// Shows detailed information about a marker in a bottom sheet. + void showMarkerDetailsSheet( BuildContext context, MarkerData marker, { diff --git a/lib/widgets/map/marker_with_animation.dart b/lib/widgets/map/marker_with_animation.dart index b21675b..2692982 100644 --- a/lib/widgets/map/marker_with_animation.dart +++ b/lib/widgets/map/marker_with_animation.dart @@ -28,6 +28,7 @@ library; import 'package:flutter/material.dart'; /// Maximum number of markers to animate simultaneously to prevent jank. + const int _maxAnimatedMarkers = 20; /// Animated marker widget with delayed entrance animation. @@ -36,7 +37,8 @@ const int _maxAnimatedMarkers = 20; /// - Only animates first [_maxAnimatedMarkers] markers to reduce overhead /// - Uses lightweight easeOutBack curve instead of elasticOut /// - Minimal stagger delay (max 200ms total) -/// - Returns child directly when animation not needed +/// - Returns child directly when animation not needed. + class MarkerWithAnimation extends StatefulWidget { const MarkerWithAnimation({ super.key, @@ -65,12 +67,12 @@ class _MarkerWithAnimationState extends State void initState() { super.initState(); - // Skip animation for markers beyond threshold to reduce jank + // Skip animation for markers beyond threshold to reduce jank. final shouldActuallyAnimate = widget.shouldAnimate && widget.index < _maxAnimatedMarkers; if (shouldActuallyAnimate) { - // Defer controller creation to avoid blocking initState + // Defer controller creation to avoid blocking initState. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _isDisposed) return; _setupAnimation(); @@ -79,7 +81,7 @@ class _MarkerWithAnimationState extends State } void _setupAnimation() { - // Double-check that widget hasn't been disposed + // Double-check that widget hasn't been disposed. if (_isDisposed || !mounted) return; _controller = AnimationController( @@ -87,7 +89,8 @@ class _MarkerWithAnimationState extends State vsync: this, ); - // Use lighter curves + // Use lighter curves. + _scaleAnimation = CurvedAnimation( parent: _controller!, curve: Curves.easeOutCubic, // Lighter than easeOutBack @@ -98,13 +101,15 @@ class _MarkerWithAnimationState extends State curve: Curves.easeOut, ); - // Reduced stagger: max 200ms total delay for better perceived performance + // Reduced stagger: max 200ms total delay for better perceived performance. final delay = (widget.index * 25).clamp(0, 200); Future.delayed(Duration(milliseconds: delay), () { if (mounted && _controller != null) { _animationStarted = true; _controller!.forward(); - // Trigger rebuild to show animation + + // Trigger rebuild to show animation. + if (mounted) setState(() {}); } }); @@ -119,12 +124,13 @@ class _MarkerWithAnimationState extends State @override Widget build(BuildContext context) { - // Fast path: no animation needed + // Fast path: no animation needed. if (!widget.shouldAnimate || widget.index >= _maxAnimatedMarkers) { return widget.child; } - // Animation not yet set up - show child directly without opacity reduction + // Animation not yet set up - show child directly without opacity reduction. + if (_controller == null || !_animationStarted) { return widget.child; } diff --git a/lib/widgets/map/news_list_dialog.dart b/lib/widgets/map/news_list_dialog.dart index db303e3..6e09b13 100644 --- a/lib/widgets/map/news_list_dialog.dart +++ b/lib/widgets/map/news_list_dialog.dart @@ -32,6 +32,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:geopod/services/gdelt_news_service.dart'; /// Shows a dialog with the list of all news in current view. + Future showNewsListDialog({ required BuildContext context, required List visibleNewsMarkers, @@ -48,7 +49,7 @@ Future showNewsListDialog({ padding: const EdgeInsets.all(20), child: Column( children: [ - // Header + // Header. Row( children: [ Icon(Icons.article, color: Colors.blue.shade700, size: 28), @@ -64,7 +65,7 @@ Future showNewsListDialog({ IconButton( icon: const Icon(Icons.close), onPressed: () { - // Only close dialog, keep news markers visible + // Only close dialog, keep news markers visible. Navigator.of(dialogContext).pop(); }, ), @@ -76,7 +77,8 @@ Future showNewsListDialog({ style: TextStyle(color: Colors.grey.shade600), ), const SizedBox(height: 12), - // News list + + // News list. Expanded( child: visibleNewsMarkers.isEmpty ? Center( @@ -169,7 +171,8 @@ Future showNewsListDialog({ ), ), const SizedBox(height: 12), - // Close button + + // Close button. SizedBox( width: double.infinity, child: ElevatedButton.icon( @@ -194,6 +197,7 @@ Future showNewsListDialog({ } /// Launch URL in browser. + Future _launchUrl(String url) async { try { final uri = Uri.parse(url); @@ -201,6 +205,6 @@ Future _launchUrl(String url) async { await launchUrl(uri, mode: LaunchMode.externalApplication); } } catch (_) { - // Ignore errors + // Ignore errors. } } diff --git a/lib/widgets/map/news_marker_details_sheet.dart b/lib/widgets/map/news_marker_details_sheet.dart index 8b8f1b3..e006e11 100644 --- a/lib/widgets/map/news_marker_details_sheet.dart +++ b/lib/widgets/map/news_marker_details_sheet.dart @@ -32,6 +32,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:geopod/services/gdelt_news_service.dart'; /// Shows detailed information about a news marker in a bottom sheet. + void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { showModalBottomSheet( context: context, @@ -45,7 +46,7 @@ void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header with news icon + // Header with news icon. Row( children: [ Icon( @@ -66,7 +67,7 @@ void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { ), const Divider(height: 24), - // News title + // News title. Text( newsMarker.title, style: Theme.of( @@ -75,7 +76,7 @@ void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { ), const SizedBox(height: 12), - // Source and date + // Source and date. if (newsMarker.source != null || newsMarker.publishedAt != null) Row( children: [ @@ -104,7 +105,7 @@ void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { ], ), - // Tone indicator + // Tone indicator. if (newsMarker.tone != null) ...[ const SizedBox(height: 8), Row( @@ -133,7 +134,7 @@ void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { const SizedBox(height: 16), - // Location info + // Location info. Row( children: [ Icon(Icons.location_on, size: 16, color: Colors.grey.shade600), @@ -149,7 +150,7 @@ void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { const SizedBox(height: 20), - // Action buttons + // Action buttons. Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -178,6 +179,7 @@ void showNewsMarkerDetailsSheet(BuildContext context, NewsMarker newsMarker) { } /// Format DateTime for display. + String _formatDateTime(DateTime dateTime) { final now = DateTime.now(); final difference = now.difference(dateTime); @@ -194,6 +196,7 @@ String _formatDateTime(DateTime dateTime) { } /// Launch URL in browser. + Future _launchUrl(BuildContext context, String url) async { try { final uri = Uri.parse(url); diff --git a/lib/widgets/map/news_marker_layer.dart b/lib/widgets/map/news_marker_layer.dart index a8e5306..d5e1d8c 100644 --- a/lib/widgets/map/news_marker_layer.dart +++ b/lib/widgets/map/news_marker_layer.dart @@ -18,6 +18,7 @@ import 'package:geopod/services/gdelt_news_service.dart'; import 'package:geopod/widgets/map/news_marker_details_sheet.dart'; /// Builds a marker layer for news markers. + MarkerLayer buildNewsMarkerLayer({ required BuildContext context, required List newsMarkers, diff --git a/lib/widgets/map/news_operations.dart b/lib/widgets/map/news_operations.dart index 40fc51e..c1847a2 100644 --- a/lib/widgets/map/news_operations.dart +++ b/lib/widgets/map/news_operations.dart @@ -19,6 +19,7 @@ import 'package:geopod/widgets/map/news_list_dialog.dart'; import 'package:geopod/widgets/map/news_marker_details_sheet.dart'; /// Shows news list dialog and handles news marker operations. + Future showNewsListDialogAsync({ required BuildContext context, required MapController mapController, @@ -64,6 +65,7 @@ Future showNewsListDialogAsync({ } /// Updates news markers from cache when map position changes. + void updateNewsFromCacheForBounds({ required MapController mapController, required GdeltNewsService newsService, @@ -73,20 +75,23 @@ void updateNewsFromCacheForBounds({ final bounds = mapController.camera.visibleBounds; final cached = newsService.getMarkersInBounds(bounds); - // Always update visible markers from cache + // Always update visible markers from cache. + if (cached.isNotEmpty) { setMarkers(cached); } // Only fetch new data if significantly outside cached bounds - // This prevents unnecessary fetches during small movements + // This prevents unnecessary fetches during small movements. + if (!newsService.isBoundsCovered(bounds)) { - // Async fetch without blocking UI + // Async fetch without blocking UI. fetchForCurrentBounds(); } } /// Fetches news for current map bounds. + Future fetchNewsForBounds({ required BuildContext context, required MapController mapController, @@ -94,7 +99,7 @@ Future fetchNewsForBounds({ required void Function(List markers, bool loading) updateState, }) async { // Don't clear existing markers, just set loading state - // Get current cached markers first + // Get current cached markers first. final bounds = mapController.camera.visibleBounds; final currentMarkers = newsService.getMarkersInBounds(bounds); updateState(currentMarkers, true); @@ -120,6 +125,7 @@ Future fetchNewsForBounds({ } /// Gets visible news markers within map bounds. + List getVisibleNewsInBounds({ required MapController mapController, required List newsMarkers, diff --git a/lib/widgets/map/place_save_handler.dart b/lib/widgets/map/place_save_handler.dart index 19f8185..e251892 100644 --- a/lib/widgets/map/place_save_handler.dart +++ b/lib/widgets/map/place_save_handler.dart @@ -24,16 +24,19 @@ import 'package:geopod/widgets/geomap.dart'; import 'package:geopod/widgets/map/login_required_dialog.dart'; /// Shows a saving snackbar for optimistic save. + void showSavingSnackbar(BuildContext context, Place place) { SnackBarHelper.showLoading(context, 'Saving "${place.displayTitle}"...'); } /// Shows a success snackbar after place is saved. + void showSaveSuccessSnackbar(BuildContext context) { SnackBarHelper.showSuccess(context, 'Place saved successfully!'); } /// Shows an error snackbar when save fails. + void showSaveErrorSnackbar(BuildContext context, dynamic error) { SnackBarHelper.showError( context, @@ -45,6 +48,7 @@ void showSaveErrorSnackbar(BuildContext context, dynamic error) { /// Performs background save of a place with address lookup. /// Note: Context is passed through to PlacesService which handles mounted checks internally. /// If [encrypted] is true, saves to encrypted storage. + Future performBackgroundSave( Place originalPlace, BuildContext context, { @@ -67,14 +71,14 @@ Future performBackgroundSave( bool success; if (encrypted) { - // Save to encrypted storage + // Save to encrypted storage. success = await EncryptedPlacesService.addEncryptedPlace( updatedPlace, context, const GeoMapWidget(), ); } else { - // Save to regular storage + // Save to regular storage. success = await PlacesService.addPlace( updatedPlace, context, @@ -92,6 +96,7 @@ Future performBackgroundSave( /// Shows the add place dialog and returns the result. /// Returns null if user is not logged in or cancels. /// Returns AddPlaceResult with place and encryption flag. + Future showAddPlaceDialogIfLoggedIn({ required BuildContext context, double? latitude, @@ -116,12 +121,14 @@ Future showAddPlaceDialogIfLoggedIn({ } /// Zoom in the map by a fixed amount. + void zoomIn(MapController mapController) { final z = mapController.camera.zoom; mapController.move(mapController.camera.center, (z + 0.6).clamp(3.0, 18.0)); } /// Zoom out the map by a fixed amount. + void zoomOut(MapController mapController) { final z = mapController.camera.zoom; mapController.move(mapController.camera.center, (z - 0.6).clamp(3.0, 18.0)); diff --git a/lib/widgets/map/places_marker_layer.dart b/lib/widgets/map/places_marker_layer.dart index fddd4be..f2d87df 100644 --- a/lib/widgets/map/places_marker_layer.dart +++ b/lib/widgets/map/places_marker_layer.dart @@ -19,6 +19,7 @@ import 'package:geopod/widgets/map/marker_details_sheet.dart'; import 'package:geopod/widgets/map/marker_with_animation.dart'; /// Builds a marker layer for places. + MarkerLayer buildPlacesMarkerLayer({ required BuildContext context, required List markers, @@ -40,6 +41,7 @@ MarkerLayer buildPlacesMarkerLayer({ } /// Builds a single marker widget. + Marker _buildMarker({ required BuildContext context, required MarkerData marker, @@ -76,6 +78,7 @@ Marker _buildMarker({ } /// Builds marker with encryption indicator. + Widget _buildEncryptedMarker(Color color) { return Stack( alignment: Alignment.center, @@ -90,6 +93,7 @@ Widget _buildEncryptedMarker(Color color) { } /// Builds the saving state marker with rotating animation. + class _SavingMarker extends StatefulWidget { const _SavingMarker(); @@ -123,7 +127,8 @@ class _SavingMarkerState extends State<_SavingMarker> children: [ // Base marker icon - cyan color to distinguish from examples (orange) Icon(Icons.location_on, size: 40, color: Colors.cyan.shade400), - // Rotating ring around the marker + + // Rotating ring around the marker. Positioned( top: 2, child: AnimatedBuilder( @@ -155,7 +160,8 @@ class _SavingMarkerState extends State<_SavingMarker> ), ), ), - // Cloud upload icon in center + + // Cloud upload icon in center. const Positioned( top: 6, child: Icon(Icons.cloud_upload, size: 12, color: Colors.white), diff --git a/lib/widgets/map/user_location_marker_layer.dart b/lib/widgets/map/user_location_marker_layer.dart index 810dd07..8d3fbba 100644 --- a/lib/widgets/map/user_location_marker_layer.dart +++ b/lib/widgets/map/user_location_marker_layer.dart @@ -19,6 +19,7 @@ import 'package:latlong2/latlong.dart'; /// /// Displays a pulsing blue dot with a semi-transparent circle /// to indicate the user's current position on the map. + MarkerLayer? buildUserLocationMarkerLayer({required LatLng? userLocation}) { if (userLocation == null) return null; @@ -36,6 +37,7 @@ MarkerLayer? buildUserLocationMarkerLayer({required LatLng? userLocation}) { } /// Widget that displays the user location marker with animation. + class _UserLocationMarker extends StatefulWidget { const _UserLocationMarker(); @@ -53,7 +55,8 @@ class _UserLocationMarkerState extends State<_UserLocationMarker> void initState() { super.initState(); - // Create pulsing animation + // Create pulsing animation. + _controller = AnimationController( duration: const Duration(milliseconds: 1500), vsync: this, @@ -81,7 +84,7 @@ class _UserLocationMarkerState extends State<_UserLocationMarker> return Stack( alignment: Alignment.center, children: [ - // Pulsing outer circle + // Pulsing outer circle. AnimatedBuilder( animation: _controller, builder: (context, child) { @@ -106,7 +109,8 @@ class _UserLocationMarkerState extends State<_UserLocationMarker> ); }, ), - // Static accuracy circle + + // Static accuracy circle. Container( width: 40, height: 40, @@ -119,7 +123,8 @@ class _UserLocationMarkerState extends State<_UserLocationMarker> ), ), ), - // Inner blue dot + + // Inner blue dot. Container( width: 16, height: 16, diff --git a/lib/widgets/map_settings_dialog.dart b/lib/widgets/map_settings_dialog.dart index 23873d5..934924b 100644 --- a/lib/widgets/map_settings_dialog.dart +++ b/lib/widgets/map_settings_dialog.dart @@ -42,7 +42,8 @@ import 'package:geopod/widgets/settings/settings_sections.dart'; /// - Toggle visibility of local (canned) example places /// - Customize colors for user places and example places /// - Select map source -/// - Configure viewport settings +/// - Configure viewport settings. + class MapSettingsDialog extends StatefulWidget { const MapSettingsDialog({ super.key, @@ -74,7 +75,8 @@ class _MapSettingsDialogState extends State { late double _initialZoom; bool _isLoadingEncrypted = false; - // Snapshot of initial settings to detect actual changes + // Snapshot of initial settings to detect actual changes. + late MapSettings _initialSnapshot; @override @@ -95,6 +97,7 @@ class _MapSettingsDialogState extends State { } /// Check if current settings differ from initial snapshot. + bool _hasActualChanges() { return _showLocalPlaces != _initialSnapshot.showLocalPlaces || _showEncryptedPlaces != _initialSnapshot.showEncryptedPlaces || @@ -110,6 +113,7 @@ class _MapSettingsDialogState extends State { } /// Saves current settings and notifies parent. + void _saveAndNotify() { final newSettings = MapSettings( showLocalPlaces: _showLocalPlaces, @@ -126,13 +130,16 @@ class _MapSettingsDialogState extends State { ); // Save to SharedPreferences. + MapSettingsService.saveSettings(newSettings); // Notify parent widget. + widget.onSettingsChanged(newSettings); } /// Resets all settings to defaults. + void _resetToDefaults() { setState(() { _showLocalPlaces = true; @@ -152,7 +159,7 @@ class _MapSettingsDialogState extends State { @override Widget build(BuildContext context) { - // Use responsive width: larger on desktop/tablet, adapt on mobile + // Use responsive width: larger on desktop/tablet, adapt on mobile. final screenWidth = MediaQuery.of(context).size.width; final dialogWidth = screenWidth < 400 ? screenWidth * 0.9 : 380.0; @@ -171,7 +178,7 @@ class _MapSettingsDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Visibility section + // Visibility section. buildVisibilitySection( showLocalPlaces: _showLocalPlaces, showEncryptedPlaces: _showEncryptedPlaces, @@ -184,10 +191,10 @@ class _MapSettingsDialogState extends State { }, onShowEncryptedChanged: (value) async { if (value) { - // Enabling encrypted places - verify security key first + // Enabling encrypted places - verify security key first. setState(() => _isLoadingEncrypted = true); - // Check if security key is available, prompt if not + // Check if security key is available, prompt if not. final hasKey = await EncryptedPlacesService.ensureSecurityKey( context, @@ -197,19 +204,20 @@ class _MapSettingsDialogState extends State { if (!mounted) return; if (hasKey) { - // Security key verified, enable the setting + // Security key verified, enable the setting. setState(() { _showEncryptedPlaces = true; _isLoadingEncrypted = false; }); _saveAndNotify(); } else { - // User cancelled or key verification failed + // User cancelled or key verification failed. setState(() => _isLoadingEncrypted = false); - // Don't change _showEncryptedPlaces - it stays false + + // Don't change _showEncryptedPlaces - it stays false. } } else { - // Disabling encrypted places - no verification needed + // Disabling encrypted places - no verification needed. setState(() => _showEncryptedPlaces = false); _saveAndNotify(); } @@ -221,7 +229,7 @@ class _MapSettingsDialogState extends State { ), const Divider(height: 24), - // Viewport section + // Viewport section. buildViewportSection( rememberViewport: _rememberViewport, initialLat: _initialLat, @@ -242,7 +250,7 @@ class _MapSettingsDialogState extends State { ), const Divider(height: 24), - // Map source section + // Map source section. buildMapSourceSection( mapSource: _mapSource, onMapSourceChanged: (source) { @@ -253,7 +261,7 @@ class _MapSettingsDialogState extends State { const SizedBox(height: 12), const Divider(height: 24), - // Marker colors section + // Marker colors section. buildMarkerColorsSection( context: context, userPlacesColor: _userPlacesColor, @@ -269,7 +277,7 @@ class _MapSettingsDialogState extends State { ), const SizedBox(height: 20), - // Reset button + // Reset button. buildResetButton(onReset: _resetToDefaults), const SizedBox(height: 12), @@ -283,7 +291,9 @@ class _MapSettingsDialogState extends State { ElevatedButton( onPressed: () { Navigator.pop(context); - // Only sync to POD if there were actual changes + + // Only sync to POD if there were actual changes. + if (_hasActualChanges()) { unawaited(MapSettingsService.syncToPod()); } diff --git a/lib/widgets/pod/file_list_tile.dart b/lib/widgets/pod/file_list_tile.dart index 9174528..52360c7 100644 --- a/lib/widgets/pod/file_list_tile.dart +++ b/lib/widgets/pod/file_list_tile.dart @@ -16,6 +16,7 @@ import 'package:geopod/models/pod_file_item.dart'; import 'package:geopod/widgets/pod/file_type_helpers.dart'; /// Animated file/directory list tile with delete animation. + class AnimatedFileListTile extends StatefulWidget { /// The file item to display. final PodFileItem item; @@ -83,7 +84,7 @@ class _AnimatedFileListTileState extends State @override Widget build(BuildContext context) { - // Use single AnimatedBuilder for better performance + // Use single AnimatedBuilder for better performance. return AnimatedBuilder( animation: _animation, builder: (context, child) { @@ -108,6 +109,7 @@ class _AnimatedFileListTileState extends State } /// Single file/directory list tile content. + class FileListTileContent extends StatelessWidget { /// The file item to display. final PodFileItem item; @@ -151,7 +153,7 @@ class FileListTileContent extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Icon + // Icon. Container( width: 40, height: 40, @@ -167,7 +169,7 @@ class FileListTileContent extends StatelessWidget { ), const SizedBox(width: 12), - // File info + // File info. Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -193,7 +195,7 @@ class FileListTileContent extends StatelessWidget { ), ), - // Actions + // Actions. if (item.isDirectory) Icon(Icons.chevron_right, color: colorScheme.outline) else ...[ @@ -225,6 +227,7 @@ class FileListTileContent extends StatelessWidget { } /// Section header widget for grouping files/folders. + class SectionHeader extends StatelessWidget { /// The title of the section. final String title; diff --git a/lib/widgets/pod/file_type_helpers.dart b/lib/widgets/pod/file_type_helpers.dart index 3aef80b..bcb7bba 100644 --- a/lib/widgets/pod/file_type_helpers.dart +++ b/lib/widgets/pod/file_type_helpers.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:geopod/models/pod_file_item.dart'; /// Returns the appropriate icon for a file item based on its type and extension. + IconData getFileIcon(PodFileItem item) { if (item.isDirectory) return Icons.folder_rounded; @@ -55,6 +56,7 @@ IconData getFileIcon(PodFileItem item) { } /// Returns the icon color for a file item based on its type. + Color getFileIconColor(BuildContext context, PodFileItem item) { final colorScheme = Theme.of(context).colorScheme; @@ -68,11 +70,13 @@ Color getFileIconColor(BuildContext context, PodFileItem item) { } /// Returns the background color for the file icon. + Color getFileIconBackgroundColor(BuildContext context, PodFileItem item) { return getFileIconColor(context, item).withValues(alpha: 0.1); } /// Returns a human-readable type description for a file item. + String getFileTypeDescription(PodFileItem item) { if (item.isDirectory) return 'Folder'; diff --git a/lib/widgets/pod/pod_browser_layouts.dart b/lib/widgets/pod/pod_browser_layouts.dart index 83f591f..87345b8 100644 --- a/lib/widgets/pod/pod_browser_layouts.dart +++ b/lib/widgets/pod/pod_browser_layouts.dart @@ -19,6 +19,7 @@ import 'package:geopod/widgets/pod/pod_file_list.dart'; import 'package:geopod/widgets/pod/pod_file_preview.dart'; /// Toolbar widget for POD file browser. + class BrowserToolbar extends StatelessWidget { final bool canGoBack; final bool canGoHome; @@ -67,6 +68,7 @@ class BrowserToolbar extends StatelessWidget { } /// Breadcrumb navigation widget for POD file browser. + class BrowserBreadcrumb extends StatelessWidget { final String currentPath; final VoidCallback onNavigateToRoot; @@ -131,6 +133,7 @@ class BrowserBreadcrumb extends StatelessWidget { } /// A widget showing the file list and preview in wide layout (side by side). + class WideLayoutView extends StatelessWidget { final List items; final PodFileItem? selectedFile; @@ -176,6 +179,7 @@ class WideLayoutView extends StatelessWidget { ), ), const VerticalDivider(width: 1), + // Preview (right panel) Expanded( child: selectedFile != null @@ -188,6 +192,7 @@ class WideLayoutView extends StatelessWidget { } /// A widget showing the file list and preview in medium layout. + class MediumLayoutView extends StatelessWidget { final List items; final PodFileItem? selectedFile; @@ -233,7 +238,8 @@ class MediumLayoutView extends StatelessWidget { ), ), const VerticalDivider(width: 1), - // Preview + + // Preview. Expanded( child: selectedFile != null ? PodFilePreview(file: selectedFile!, onClose: onClearSelection) @@ -245,6 +251,7 @@ class MediumLayoutView extends StatelessWidget { } /// Mobile preview view with back button. + class MobilePreviewView extends StatelessWidget { final PodFileItem selectedFile; final VoidCallback onBack; @@ -259,7 +266,7 @@ class MobilePreviewView extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - // Back to list button - more prominent on mobile + // Back to list button - more prominent on mobile. Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( @@ -297,6 +304,7 @@ class MobilePreviewView extends StatelessWidget { } /// Empty preview placeholder. + class EmptyPreviewView extends StatelessWidget { const EmptyPreviewView({super.key}); @@ -336,6 +344,7 @@ class EmptyPreviewView extends StatelessWidget { } /// List content view with loading, error, and file list states. + class ListContentView extends StatelessWidget { final List items; final bool isLoading; diff --git a/lib/widgets/pod/pod_dialogs.dart b/lib/widgets/pod/pod_dialogs.dart index 97237e5..72902b5 100644 --- a/lib/widgets/pod/pod_dialogs.dart +++ b/lib/widgets/pod/pod_dialogs.dart @@ -13,6 +13,7 @@ library; import 'package:flutter/material.dart'; /// Show confirmation dialog for deleting the main places.json file. + Future showDeletePlacesConfirmation(BuildContext context) async { return await showDialog( context: context, @@ -40,6 +41,7 @@ Future showDeletePlacesConfirmation(BuildContext context) async { } /// Show snackbar for file operation result. + void showFileOperationSnackBar( BuildContext context, { required String message, @@ -54,6 +56,7 @@ void showFileOperationSnackBar( } /// Show snackbar for delete in progress. + void showDeletingSnackBar(BuildContext context, String fileName) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/widgets/pod/pod_file_browser.dart b/lib/widgets/pod/pod_file_browser.dart index 60ef9c8..a41b9fd 100644 --- a/lib/widgets/pod/pod_file_browser.dart +++ b/lib/widgets/pod/pod_file_browser.dart @@ -26,6 +26,7 @@ import 'package:geopod/widgets/pod/pod_dialogs.dart'; /// /// This is a standalone implementation that doesn't depend on /// solidui's complex security key system. + class PodFileBrowser extends StatefulWidget { /// Base path in the POD data directory (e.g., '' for root). final String basePath; @@ -44,21 +45,26 @@ class _PodFileBrowserState extends State { String _currentPath = ''; /// List of items in current directory. + List _items = []; /// Whether we're loading. + bool _isLoading = true; /// Error message if any. + String? _error; /// Currently selected file for preview. + PodFileItem? _selectedFile; /// Path history for navigation. final List _pathHistory = ['']; /// Flag to skip podFilesChangeNotifier during our own delete operations. + bool _skipFilesChangeNotification = false; @override @@ -67,7 +73,8 @@ class _PodFileBrowserState extends State { _currentPath = widget.basePath; _loadDirectory(); - // Listen for file system changes from other parts of the app + // Listen for file system changes from other parts of the app. + podFilesChangeNotifier.addListener(_onFilesChanged); } @@ -78,10 +85,13 @@ class _PodFileBrowserState extends State { } /// Called when files change elsewhere in the app. + void _onFilesChanged() { // Skip if we triggered this ourselves (during delete operations) if (_skipFilesChangeNotification) return; - // Force refresh the current directory + + // Force refresh the current directory. + _refreshDirectory(); } @@ -151,13 +161,15 @@ class _PodFileBrowserState extends State { } Future _deleteFile(PodFileItem item) async { - // Set flag to skip file change notifications during our delete operation + // Set flag to skip file change notifications during our delete operation. _skipFilesChangeNotification = true; try { if (PlacesService.isMainPlacesFile(item.path)) { if (!await showDeletePlacesConfirmation(context)) return; - // Use addPostFrameCallback to avoid blocking animation + + // Use addPostFrameCallback to avoid blocking animation. + SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { @@ -214,7 +226,8 @@ class _PodFileBrowserState extends State { return; } - // Regular file deletion + // Regular file deletion. + SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { @@ -237,7 +250,7 @@ class _PodFileBrowserState extends State { if (!success) _loadDirectory(); } } finally { - // Always reset the flag + // Always reset the flag. _skipFilesChangeNotification = false; } } @@ -337,6 +350,7 @@ class _PodFileBrowserState extends State { } /// Navigate to a specific path from breadcrumb. + void _navigateToPath(String targetPath) { _pathHistory.add(_currentPath); setState(() { @@ -350,6 +364,7 @@ class _PodFileBrowserState extends State { /// Check if an item can be deleted. /// Only items in the data directory can be deleted. + bool _canDeleteItem(PodFileItem item) { return PodPath.isDataPath(item.path); } diff --git a/lib/widgets/pod/pod_file_list.dart b/lib/widgets/pod/pod_file_list.dart index bb4f054..c3d534f 100644 --- a/lib/widgets/pod/pod_file_list.dart +++ b/lib/widgets/pod/pod_file_list.dart @@ -19,6 +19,7 @@ import 'package:geopod/models/pod_file_item.dart'; import 'package:geopod/widgets/pod/file_list_tile.dart'; /// Widget for displaying a list of POD files and directories with animations. + class PodFileList extends StatefulWidget { /// List of file items to display. final List items; @@ -71,14 +72,14 @@ class _PodFileListState extends State { return _buildEmptyState(context); } - // Separate directories and files + // Separate directories and files. final directories = widget.items.where((i) => i.isDirectory).toList(); final files = widget.items.where((i) => !i.isDirectory).toList(); return ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: [ - // Directories section + // Directories section. if (directories.isNotEmpty) ...[ const SectionHeader( @@ -94,7 +95,7 @@ class _PodFileListState extends State { icon: Icons.folder, ), - // Files section + // Files section. if (files.isNotEmpty) ...[ if (directories.isNotEmpty) const SizedBox(height: 8), SectionHeader( @@ -210,17 +211,20 @@ class _PodFileListState extends State { } /// Performs delete with animation. + void _performDelete(PodFileItem item) { - // Start delete animation + // Start delete animation. setState(() => _deletingItems.add(item.path)); - // Schedule cleanup and delete after animation frame + // Schedule cleanup and delete after animation frame. + SchedulerBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 200), () { if (mounted) { setState(() => _deletingItems.remove(item.path)); } - // Fire and forget - don't block on the async delete + + // Fire and forget - don't block on the async delete. final onDelete = widget.onDelete; if (onDelete != null) { unawaited(Future(() => onDelete(item))); diff --git a/lib/widgets/pod/pod_file_preview.dart b/lib/widgets/pod/pod_file_preview.dart index f725d52..bb89c4d 100644 --- a/lib/widgets/pod/pod_file_preview.dart +++ b/lib/widgets/pod/pod_file_preview.dart @@ -17,6 +17,7 @@ import 'package:geopod/models/pod_file_item.dart'; import 'package:geopod/services/pod/pod.dart'; /// Widget for previewing file contents from the POD. + class PodFilePreview extends StatefulWidget { /// The file item to preview. final PodFileItem file; @@ -48,7 +49,9 @@ class _PodFilePreviewState extends State { void initState() { super.initState(); _loadContent(); - // Listen for file changes to auto-refresh + + // Listen for file changes to auto-refresh. + podFilesChangeNotifier.addListener(_onFilesChanged); } @@ -59,7 +62,7 @@ class _PodFilePreviewState extends State { } void _onFilesChanged() { - // Reload content when files change + // Reload content when files change. if (mounted) { _loadContent(); } @@ -192,12 +195,13 @@ class _PodFilePreviewState extends State { return const Center(child: Text('No content available')); } - // Check if it's JSON and format it nicely + // Check if it's JSON and format it nicely. + if (widget.file.extension == 'json') { return _buildJsonPreview(); } - // Default text preview + // Default text preview. return _buildTextPreview(); } @@ -213,7 +217,7 @@ class _PodFilePreviewState extends State { Widget _buildJsonPreview() { try { - // Try to format JSON nicely + // Try to format JSON nicely. final formatted = _formatJson(_content!); return SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -228,7 +232,7 @@ class _PodFilePreviewState extends State { } String _formatJson(String json) { - // Simple JSON formatting + // Simple JSON formatting. var indent = 0; final result = StringBuffer(); var inString = false; @@ -263,7 +267,9 @@ class _PodFilePreviewState extends State { case '\n': case '\r': case '\t': - // Skip whitespace + + // Skip whitespace. + break; default: result.write(char); diff --git a/lib/widgets/settings/color_picker_tile.dart b/lib/widgets/settings/color_picker_tile.dart index a946ff9..551eb25 100644 --- a/lib/widgets/settings/color_picker_tile.dart +++ b/lib/widgets/settings/color_picker_tile.dart @@ -15,6 +15,7 @@ library; import 'package:flutter/material.dart'; /// A tile widget for displaying and selecting a color. + class ColorPickerTile extends StatelessWidget { const ColorPickerTile({ super.key, @@ -64,6 +65,7 @@ class ColorPickerTile extends StatelessWidget { ), ), const SizedBox(width: 16), + // Label and subtitle. Expanded( child: Column( @@ -83,6 +85,7 @@ class ColorPickerTile extends StatelessWidget { ], ), ), + // Edit icon. Icon(Icons.edit, color: Colors.grey.shade400, size: 20), ], diff --git a/lib/widgets/settings/encryption_key_operations.dart b/lib/widgets/settings/encryption_key_operations.dart index 39616d4..23d39a8 100644 --- a/lib/widgets/settings/encryption_key_operations.dart +++ b/lib/widgets/settings/encryption_key_operations.dart @@ -22,6 +22,7 @@ import 'package:solidpod/solidpod.dart'; /// - public-key.ttl (public key) /// /// WARNING: All encrypted data will become unreadable after this operation. + Future deleteEncryptionKeys(BuildContext context) async { final confirmed = await showDialog( context: context, @@ -56,14 +57,14 @@ Future deleteEncryptionKeys(BuildContext context) async { if (confirmed != true || !context.mounted) return; try { - // Show loading indicator + // Show loading indicator. showDialog( context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()), ); - // Get the webId to construct full URLs + // Get the webId to construct full URLs. final webId = await getWebId(); if (webId == null || webId.isEmpty) { throw Exception('User not logged in'); @@ -84,8 +85,9 @@ Future deleteEncryptionKeys(BuildContext context) async { for (final filePath in filesToDelete) { try { debugPrint('Attempting to delete: $filePath'); + // deleteFile with isKey=true only deletes the file without - // trying to revoke permissions or remove individual keys + // trying to revoke permissions or remove individual keys. await deleteFile(filePath, isKey: true); deletedFiles.add(filePath); debugPrint('Deleted: $filePath'); @@ -95,7 +97,7 @@ Future deleteEncryptionKeys(BuildContext context) async { } } - // Clear local key cache + // Clear local key cache. await KeyManager.clear(); if (context.mounted) { diff --git a/lib/widgets/settings/settings_actions.dart b/lib/widgets/settings/settings_actions.dart index 9a3685c..d07fa99 100644 --- a/lib/widgets/settings/settings_actions.dart +++ b/lib/widgets/settings/settings_actions.dart @@ -18,6 +18,7 @@ import 'package:solidui/solidui.dart'; import 'package:geopod/widgets/settings/encryption_key_operations.dart'; /// Builds the reset to defaults button. + Widget buildResetButton({required VoidCallback onReset}) { return Center( child: TextButton.icon( @@ -31,6 +32,7 @@ Widget buildResetButton({required VoidCallback onReset}) { /// Builds the logout and debug buttons section. /// Only visible when user is logged in. + Widget buildUserActionsSection(BuildContext context) { return FutureBuilder( future: getWebId(), @@ -43,9 +45,10 @@ Widget buildUserActionsSection(BuildContext context) { Center( child: TextButton.icon( onPressed: () async { - // Close settings dialog first + // Close settings dialog first. Navigator.pop(context); - // Then handle logout + + // Then handle logout. await SolidAuthHandler.instance.handleLogout(context); }, icon: const Icon(Icons.logout, size: 18), @@ -54,7 +57,8 @@ Widget buildUserActionsSection(BuildContext context) { ), ), const SizedBox(height: 8), - // DEBUG: Delete encryption keys from server + + // DEBUG: Delete encryption keys from server. Center( child: TextButton.icon( onPressed: () => deleteEncryptionKeys(context), diff --git a/lib/widgets/settings/settings_sections.dart b/lib/widgets/settings/settings_sections.dart index 7f3bb0e..f7a5560 100644 --- a/lib/widgets/settings/settings_sections.dart +++ b/lib/widgets/settings/settings_sections.dart @@ -19,6 +19,7 @@ import 'package:geopod/widgets/settings/color_picker_tile.dart'; import 'package:geopod/widgets/settings/viewport_selector.dart'; /// Shows color picker dialog for selecting a color. + Future showColorPickerDialog({ required BuildContext context, required String title, @@ -78,6 +79,7 @@ Future showColorPickerDialog({ } /// Builds the visibility section of settings. + Widget buildVisibilitySection({ required bool showLocalPlaces, required bool showEncryptedPlaces, @@ -121,7 +123,8 @@ Widget buildVisibilitySection({ color: showLocalPlaces ? Colors.green : Colors.grey, ), ), - // Only show encrypted places option when logged in + + // Only show encrypted places option when logged in. if (isLoggedIn) ...[ const SizedBox(height: 8), SwitchListTile( @@ -152,6 +155,7 @@ Widget buildVisibilitySection({ } /// Builds the viewport section of settings. + Widget buildViewportSection({ required bool rememberViewport, required double initialLat, @@ -196,6 +200,7 @@ Widget buildViewportSection({ } /// Builds the map source selection section. + Widget buildMapSourceSection({ required MapSource mapSource, required void Function(MapSource) onMapSourceChanged, @@ -268,6 +273,7 @@ Widget buildMapSourceSection({ } /// Builds the marker colors section. + Widget buildMarkerColorsSection({ required BuildContext context, required Color userPlacesColor, diff --git a/lib/widgets/settings/viewport_selector.dart b/lib/widgets/settings/viewport_selector.dart index 650d0d2..1682d6c 100644 --- a/lib/widgets/settings/viewport_selector.dart +++ b/lib/widgets/settings/viewport_selector.dart @@ -15,6 +15,7 @@ library; import 'package:flutter/material.dart'; /// Predefined viewport presets for quick selection. + enum ViewportPreset { australia('Australia', -25.2744, 133.7751, 4.0), sydney('Sydney', -33.8688, 151.2093, 11.0), @@ -35,6 +36,7 @@ enum ViewportPreset { } /// Widget for selecting initial viewport location. + class InitialViewportSelector extends StatelessWidget { const InitialViewportSelector({ super.key, @@ -50,7 +52,7 @@ class InitialViewportSelector extends StatelessWidget { final void Function(double lat, double lng, double zoom) onChanged; String get _currentLocationName { - // Find matching preset + // Find matching preset. for (final preset in ViewportPreset.values) { if ((preset.lat - lat).abs() < 0.01 && (preset.lng - lng).abs() < 0.01 && diff --git a/lib/widgets/weather/pdf_chart_painter.dart b/lib/widgets/weather/pdf_chart_painter.dart index 4ebdf2b..d306a6c 100644 --- a/lib/widgets/weather/pdf_chart_painter.dart +++ b/lib/widgets/weather/pdf_chart_painter.dart @@ -17,6 +17,7 @@ import 'package:pdf/widgets.dart' as pw; import 'weather_chart_sampling.dart'; /// Build line chart for PDF using simple drawing. + pw.Widget buildPdfChart( Map data, double minValue, @@ -27,12 +28,12 @@ pw.Widget buildPdfChart( if (data.isEmpty) return pw.SizedBox(); // Always sort entries by date in ascending order for PDF - // This ensures data points and X-axis labels are properly aligned + // This ensures data points and X-axis labels are properly aligned. final entries = data.entries.toList()..sort((a, b) => a.key.compareTo(b.key)); final valueRange = maxValue - minValue; // For temperature and humidity, use actual min value to show variation - // For precipitation and wind, start from 0 + // For precipitation and wind, start from 0. final effectiveMin = useActualRange ? minValue : (minValue >= 0 ? 0.0 : minValue); @@ -43,7 +44,7 @@ pw.Widget buildPdfChart( : maxValue; final effectiveRange = effectiveMax - effectiveMin; - // Sample data for PDF if too many points + // Sample data for PDF if too many points. final sampledEntries = entries.length > 20 ? sampleEntriesForPdf(entries, 20) : entries; @@ -51,7 +52,7 @@ pw.Widget buildPdfChart( return pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - // Chart area with grid and line + // Chart area with grid and line. pw.Container( height: 200, decoration: pw.BoxDecoration( @@ -80,7 +81,8 @@ pw.Widget buildPdfChart( ), ), ), - // Chart with grid and line + + // Chart with grid and line. pw.Positioned( left: 30, top: 20, @@ -91,7 +93,8 @@ pw.Widget buildPdfChart( final chartWidth = size.x; final chartHeight = size.y; - // Draw horizontal grid lines + // Draw horizontal grid lines. + for (var i = 0; i <= 4; i++) { final y = chartHeight * i / 4; canvas @@ -102,7 +105,8 @@ pw.Widget buildPdfChart( ..strokePath(); } - // Draw line chart + // Draw line chart. + if (sampledEntries.length >= 2) { final xStep = chartWidth / (sampledEntries.length - 1); @@ -111,19 +115,21 @@ pw.Widget buildPdfChart( ..setLineWidth(2); // Calculate points - // PDF coordinate system: origin at bottom-left, Y-axis goes upward + // PDF coordinate system: origin at bottom-left, Y-axis goes upward. final points = []; for (var i = 0; i < sampledEntries.length; i++) { final x = i * xStep; final normalizedY = (sampledEntries[i].value - effectiveMin) / effectiveRange; - // In PDF: y=0 is bottom, y=chartHeight is top + + // In PDF: y=0 is bottom, y=chartHeight is top. final y = normalizedY * chartHeight; points.add(PdfPoint(x, y)); } - // Draw smooth curve with Catmull-Rom spline + // Draw smooth curve with Catmull-Rom spline. + canvas.moveTo(points[0].x, points[0].y); if (points.length == 2) { @@ -144,7 +150,8 @@ pw.Widget buildPdfChart( // Clamp control points Y to prevent curve going below chartHeight (value < 0) // Important for non-negative values like precipitation and wind speed. - // Only apply this clamping when using actual range to avoid distorting curves + // Only apply this clamping when using actual range to avoid distorting curves. + if (!useActualRange && minValue >= 0) { if (cp1y > chartHeight) cp1y = chartHeight; if (cp1y < 0) cp1y = 0; @@ -158,7 +165,8 @@ pw.Widget buildPdfChart( canvas.strokePath(); - // Draw data points + // Draw data points. + for (final point in points) { canvas ..setFillColor(PdfColors.blue700) @@ -166,7 +174,8 @@ pw.Widget buildPdfChart( ..fillPath(); } - // Draw X-axis tick marks at the bottom + // Draw X-axis tick marks at the bottom. + canvas ..setStrokeColor(PdfColors.grey600) ..setLineWidth(1); @@ -181,7 +190,8 @@ pw.Widget buildPdfChart( }, ), ), - // X-axis date labels + + // X-axis date labels. pw.Positioned( left: 30, right: 5, @@ -213,7 +223,9 @@ pw.Widget buildPdfChart( ), ); } - // Always show the last label + + // Always show the last label. + if (sampledEntries.length > 1 && (sampledEntries.length - 1) % labelStep != 0) { final lastIndex = sampledEntries.length - 1; @@ -239,7 +251,8 @@ pw.Widget buildPdfChart( ), ), pw.SizedBox(height: 4), - // Info text + + // Info text. pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/widgets/weather/pdf_data_table.dart b/lib/widgets/weather/pdf_data_table.dart index c86dc66..b2e535f 100644 --- a/lib/widgets/weather/pdf_data_table.dart +++ b/lib/widgets/weather/pdf_data_table.dart @@ -15,6 +15,7 @@ import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; /// Build data table for PDF showing daily weather statistics. + pw.Widget buildPdfDataTable({ required Map dailyData, required Map dailyMinMax, @@ -44,12 +45,13 @@ pw.Widget buildPdfDataTable({ data: (dailyData.entries.toList()..sort((a, b) => a.key.compareTo(b.key))) .map((entry) { final date = entry.key; + // For precipitation: dailyData contains daily totals - // For other types: dailyData contains daily averages + // For other types: dailyData contains daily averages. final value = entry.value; final (dayMin, dayMax) = dailyMinMax[date] ?? (value, value); - // For precipitation, show hours with rain and max hourly rate + // For precipitation, show hours with rain and max hourly rate. final secondCol = dataType == 'precipitation' ? (precipitationHours?[date] ?? 0).toString() : dayMin.toStringAsFixed(1); diff --git a/lib/widgets/weather/pdf_document_builder.dart b/lib/widgets/weather/pdf_document_builder.dart index 17bae7a..295de52 100644 --- a/lib/widgets/weather/pdf_document_builder.dart +++ b/lib/widgets/weather/pdf_document_builder.dart @@ -22,6 +22,7 @@ import 'pdf_dual_chart_painter.dart'; import 'pdf_utils.dart'; /// Build complete PDF document for weather data report. + pw.Document buildWeatherPdfDocument({ required HourlyWeatherData data, required Map dailyData, @@ -49,7 +50,7 @@ pw.Document buildWeatherPdfDocument({ pageFormat: PdfPageFormat.a4, margin: const pw.EdgeInsets.all(32), build: (context) => [ - // Title + // Title. pw.Header( level: 0, child: pw.Text( @@ -59,7 +60,7 @@ pw.Document buildWeatherPdfDocument({ ), pw.SizedBox(height: 20), - // Location info + // Location info. if (address != null && address.isNotEmpty) ...[ pw.Text( 'Location: $address', @@ -95,14 +96,14 @@ pw.Document buildWeatherPdfDocument({ pw.SizedBox(height: 10), ], - // Date range + // Date range. pw.Text( 'Date Range: ${dateFormat.format(data.startDate)} - ${dateFormat.format(data.endDate)}', style: const pw.TextStyle(fontSize: 12), ), pw.SizedBox(height: 10), - // Data type + // Data type. pw.Text( 'Data Type: $title', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold), @@ -170,7 +171,7 @@ pw.Document buildWeatherPdfDocument({ ), pw.SizedBox(height: 20), - // Algorithm explanation + // Algorithm explanation. pw.Container( padding: const pw.EdgeInsets.all(12), decoration: pw.BoxDecoration( @@ -202,13 +203,14 @@ pw.Document buildWeatherPdfDocument({ ), pw.SizedBox(height: 20), - // Chart visualization + // Chart visualization. pw.Text( 'Data Visualization', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold), ), pw.SizedBox(height: 10), - // Use dual chart for temperature and wind_speed, single chart for others + + // Use dual chart for temperature and wind_speed, single chart for others. if ((dataType == 'temperature' || dataType == 'wind_speed') && dailyMaxData != null && dailyMinData != null) @@ -229,7 +231,7 @@ pw.Document buildWeatherPdfDocument({ ), pw.SizedBox(height: 20), - // Daily data table + // Daily data table. pw.Text( dataType == 'precipitation' ? 'Daily Total Data' @@ -246,7 +248,7 @@ pw.Document buildWeatherPdfDocument({ ), pw.SizedBox(height: 20), - // Footer + // Footer. pw.Text( 'Generated: ${DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now())} UTC${formatTimeZoneOffset(DateTime.now().timeZoneOffset)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600), diff --git a/lib/widgets/weather/pdf_download_stub.dart b/lib/widgets/weather/pdf_download_stub.dart index 1176d61..2d07fe8 100644 --- a/lib/widgets/weather/pdf_download_stub.dart +++ b/lib/widgets/weather/pdf_download_stub.dart @@ -14,6 +14,7 @@ library; /// /// This function is not used on non-web platforms as PDF handling /// is done through the printing package. + void downloadPdfWeb(List bytes, String filename) { throw UnsupportedError('downloadPdfWeb is only supported on web platform'); } diff --git a/lib/widgets/weather/pdf_download_web.dart b/lib/widgets/weather/pdf_download_web.dart index b35f6ce..add3b11 100644 --- a/lib/widgets/weather/pdf_download_web.dart +++ b/lib/widgets/weather/pdf_download_web.dart @@ -16,8 +16,9 @@ import 'dart:typed_data'; import 'package:web/web.dart' as web; /// Download PDF file on web platform. + void downloadPdfWeb(List bytes, String filename) { - // Convert Uint8List to JSUint8Array + // Convert Uint8List to JSUint8Array. final jsBytes = (bytes as Uint8List).toJS; final blob = web.Blob( [jsBytes].toJS, diff --git a/lib/widgets/weather/pdf_dual_chart_painter.dart b/lib/widgets/weather/pdf_dual_chart_painter.dart index 8a9dcb2..abfd701 100644 --- a/lib/widgets/weather/pdf_dual_chart_painter.dart +++ b/lib/widgets/weather/pdf_dual_chart_painter.dart @@ -17,6 +17,7 @@ import 'package:pdf/widgets.dart' as pw; import 'weather_chart_sampling.dart'; /// Build dual-line chart for PDF (e.g., max/min temperature). + pw.Widget buildPdfDualChart( Map maxData, Map minData, @@ -26,7 +27,7 @@ pw.Widget buildPdfDualChart( ) { if (maxData.isEmpty || minData.isEmpty) return pw.SizedBox(); - // Always sort entries by date in ascending order for PDF + // Always sort entries by date in ascending order for PDF. final maxEntries = maxData.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); final minEntries = minData.entries.toList() @@ -34,12 +35,12 @@ pw.Widget buildPdfDualChart( final valueRange = maxValue - minValue; - // Handle flat lines + // Handle flat lines. final effectiveMin = minValue; final effectiveMax = valueRange < 0.01 ? minValue * 1.1 : maxValue; final effectiveRange = effectiveMax - effectiveMin; - // Sample data for PDF if too many points + // Sample data for PDF if too many points. final sampledMaxEntries = maxEntries.length > 20 ? sampleEntriesForPdf(maxEntries, 20) : maxEntries; @@ -64,7 +65,7 @@ pw.Widget buildPdfDualChart( ), pw.SizedBox(height: 10), - // Chart area + // Chart area. pw.Container( height: 200, decoration: pw.BoxDecoration( @@ -72,7 +73,7 @@ pw.Widget buildPdfDualChart( ), child: pw.Stack( children: [ - // Y-axis labels + // Y-axis labels. pw.Positioned( left: 0, top: 17, @@ -92,7 +93,8 @@ pw.Widget buildPdfDualChart( ), ), ), - // Chart with grid and lines + + // Chart with grid and lines. pw.Positioned( left: 30, top: 20, @@ -103,7 +105,8 @@ pw.Widget buildPdfDualChart( final chartWidth = size.x; final chartHeight = size.y; - // Draw grid + // Draw grid. + for (var i = 0; i <= 4; i++) { final y = chartHeight * i / 4; canvas @@ -114,7 +117,8 @@ pw.Widget buildPdfDualChart( ..strokePath(); } - // Draw max temperature line + // Draw max temperature line. + if (sampledMaxEntries.length >= 2) { final xStep = chartWidth / (sampledMaxEntries.length - 1); canvas @@ -154,7 +158,8 @@ pw.Widget buildPdfDualChart( canvas.strokePath(); } - // Draw min temperature line + // Draw min temperature line. + if (sampledMinEntries.length >= 2) { final xStep = chartWidth / (sampledMinEntries.length - 1); canvas @@ -193,7 +198,8 @@ pw.Widget buildPdfDualChart( } canvas.strokePath(); - // Draw data points for min temperature + // Draw data points for min temperature. + for (final point in minPoints) { canvas ..setFillColor(PdfColors.blue700) @@ -203,6 +209,7 @@ pw.Widget buildPdfDualChart( } // Draw data points for max temperature (after drawing both lines) + if (sampledMaxEntries.length >= 2) { final xStep = chartWidth / (sampledMaxEntries.length - 1); for (var i = 0; i < sampledMaxEntries.length; i++) { @@ -218,7 +225,8 @@ pw.Widget buildPdfDualChart( } } - // Draw X-axis tick marks at the bottom + // Draw X-axis tick marks at the bottom. + if (sampledMaxEntries.length >= 2) { final xStep = chartWidth / (sampledMaxEntries.length - 1); canvas @@ -235,7 +243,8 @@ pw.Widget buildPdfDualChart( }, ), ), - // X-axis labels + + // X-axis labels. pw.Positioned( left: 30, right: 5, @@ -272,7 +281,8 @@ pw.Widget buildPdfDualChart( ); } - // Always show the last label + // Always show the last label. + if (sampledMaxEntries.length > 1 && (sampledMaxEntries.length - 1) % labelStep != 0) { final lastIndex = sampledMaxEntries.length - 1; @@ -299,7 +309,8 @@ pw.Widget buildPdfDualChart( ), ), pw.SizedBox(height: 5), - // Info text + + // Info text. pw.Text( 'Curve: Catmull-Rom spline interpolation', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700), diff --git a/lib/widgets/weather/pdf_export_handler.dart b/lib/widgets/weather/pdf_export_handler.dart index a8c065e..249d7fc 100644 --- a/lib/widgets/weather/pdf_export_handler.dart +++ b/lib/widgets/weather/pdf_export_handler.dart @@ -21,16 +21,16 @@ import 'package:intl/intl.dart'; import 'package:geopod/utils/ui_utils.dart'; -// Conditional import for platform-specific PDF download import 'pdf_download_stub.dart' if (dart.library.html) 'pdf_download_web.dart'; /// Handle PDF export with platform-specific save dialog. + Future handlePdfExport(BuildContext context, Uint8List pdfBytes) async { final filename = 'weather_report_${DateFormat('yyyyMMdd_HHmmss').format(DateTime.now())}'; if (kIsWeb) { - // For Web: Download PDF file directly + // For Web: Download PDF file directly. downloadPdfWeb(pdfBytes, '$filename.pdf'); if (context.mounted) { @@ -41,7 +41,7 @@ Future handlePdfExport(BuildContext context, Uint8List pdfBytes) async { ); } } else { - // For mobile/desktop: Let user choose save location + // For mobile/desktop: Let user choose save location. final outputPath = await FilePicker.platform.saveFile( dialogTitle: 'Save PDF Report', fileName: '$filename.pdf', @@ -50,7 +50,7 @@ Future handlePdfExport(BuildContext context, Uint8List pdfBytes) async { ); if (outputPath != null) { - // Save the file to the chosen location + // Save the file to the chosen location. final file = File(outputPath); await file.writeAsBytes(pdfBytes); @@ -62,7 +62,7 @@ Future handlePdfExport(BuildContext context, Uint8List pdfBytes) async { ); } } else { - // User cancelled the save dialog + // User cancelled the save dialog. if (context.mounted) { SnackBarHelper.showInfo( context, diff --git a/lib/widgets/weather/pdf_utils.dart b/lib/widgets/weather/pdf_utils.dart index 52cd8f2..99cab6b 100644 --- a/lib/widgets/weather/pdf_utils.dart +++ b/lib/widgets/weather/pdf_utils.dart @@ -11,6 +11,7 @@ library; /// Format timezone offset for PDF display (e.g., "+1100", "-0500", "+0000"). + String formatTimeZoneOffset(Duration offset) { final hours = offset.inHours; final minutes = offset.inMinutes.remainder(60).abs(); diff --git a/lib/widgets/weather/weather_chart_builder.dart b/lib/widgets/weather/weather_chart_builder.dart index f9f9bbc..5b72956 100644 --- a/lib/widgets/weather/weather_chart_builder.dart +++ b/lib/widgets/weather/weather_chart_builder.dart @@ -16,6 +16,7 @@ import 'weather_chart_dual_painter.dart'; import 'weather_chart_painter.dart'; /// Build a dual-line chart widget. + Widget buildDualLineChart({ required Map chartMaxData, required Map chartMinData, @@ -23,7 +24,7 @@ Widget buildDualLineChart({ required double dataMax, required String dataType, }) { - // Handle flat data: if max == min for all points, don't draw chart + // Handle flat data: if max == min for all points, don't draw chart. final allFlat = chartMaxData.values.every((v) => v == dataMax) && chartMinData.values.every((v) => v == dataMin) && @@ -58,13 +59,14 @@ Widget buildDualLineChart({ } /// Build a simple single-line chart widget. + Widget buildSimpleChart({ required Map chartData, required double dataMin, required double dataMax, required String dataType, }) { - // Determine color based on data type + // Determine color based on data type. Color lineColor = Colors.blue; // Default color if (dataType == 'humidity') { lineColor = Colors.red; // Red for humidity diff --git a/lib/widgets/weather/weather_chart_config.dart b/lib/widgets/weather/weather_chart_config.dart index c461ef9..339ff44 100644 --- a/lib/widgets/weather/weather_chart_config.dart +++ b/lib/widgets/weather/weather_chart_config.dart @@ -11,16 +11,20 @@ library; /// Daily data card width. + const double dailyCardWidth = 80.0; /// Daily data card spacing. + const double dailyCardSpacing = 6.0; /// Chart tooltip message for sampling algorithms. + const String chartSamplingTooltip = 'Catmull-Rom spline: Smooth curve algorithm that passes through data points\n' 'Ramer-Douglas-Peucker: Smart sampling to preserve key features'; /// Chart sampling info text. + const String chartSamplingInfo = 'Curve fitting: Catmull-Rom spline | Data sampling: RDP algorithm'; diff --git a/lib/widgets/weather/weather_chart_data_card.dart b/lib/widgets/weather/weather_chart_data_card.dart index 611141b..9972ffe 100644 --- a/lib/widgets/weather/weather_chart_data_card.dart +++ b/lib/widgets/weather/weather_chart_data_card.dart @@ -18,6 +18,7 @@ import 'weather_chart_config.dart'; import 'weather_chart_helpers.dart'; /// A card widget displaying daily weather data summary. + class WeatherDataCard extends StatelessWidget { const WeatherDataCard({ required this.date, @@ -69,7 +70,8 @@ class WeatherDataCard extends StatelessWidget { style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 4), - // For wind_speed: show max wind in main position + + // For wind_speed: show max wind in main position. if (dataType == 'wind_speed') ...[ Text( '${dayMax.toStringAsFixed(1)}${getDataUnit(dataType)}', @@ -89,7 +91,8 @@ class WeatherDataCard extends StatelessWidget { ), ), const SizedBox(height: 2), - // Show average wind speed for wind_speed type + + // Show average wind speed for wind_speed type. if (dataType == 'wind_speed') ...[ Row( mainAxisAlignment: MainAxisAlignment.center, @@ -109,7 +112,7 @@ class WeatherDataCard extends StatelessWidget { ], ), ] else if (dataType == 'precipitation') ...[ - // For precipitation: show hours with rain + // For precipitation: show hours with rain. Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -125,7 +128,8 @@ class WeatherDataCard extends StatelessWidget { ), ], ), - // Show max hourly rate + + // Show max hourly rate. Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -144,7 +148,7 @@ class WeatherDataCard extends StatelessWidget { ], ), ] else if (dataType == 'temperature' || dataType == 'humidity') ...[ - // For temperature and humidity: show min/max + // For temperature and humidity: show min/max. Row( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/lib/widgets/weather/weather_chart_data_processor.dart b/lib/widgets/weather/weather_chart_data_processor.dart index 027f593..207b95e 100644 --- a/lib/widgets/weather/weather_chart_data_processor.dart +++ b/lib/widgets/weather/weather_chart_data_processor.dart @@ -16,6 +16,7 @@ import 'weather_chart_helpers.dart'; import 'weather_chart_sampling.dart'; /// Processed weather chart data. + class ProcessedChartData { ProcessedChartData({ required this.dailyData, @@ -53,6 +54,7 @@ class ProcessedChartData { } /// Process hourly weather data for chart display. + ProcessedChartData processWeatherData({ required HourlyWeatherData data, required String dataType, @@ -62,17 +64,18 @@ ProcessedChartData processWeatherData({ final (axisMin, axisMax) = getDataRange(dataType, data); var dailyData = getDailyData(dataType, data); - // Get daily min/max values for each day + // Get daily min/max values for each day. final dailyMinMax = data.getDailyMinMax(dataType); - // For temperature and wind_speed, extract separate max and min/avg data + // For temperature and wind_speed, extract separate max and min/avg data. + Map? dailyMaxData; Map? dailyMinData; if (dataType == 'temperature') { dailyMaxData = dailyMinMax.map((date, values) => MapEntry(date, values.$2)); dailyMinData = dailyMinMax.map((date, values) => MapEntry(date, values.$1)); } else if (dataType == 'wind_speed') { - // For wind speed: max wind speed and average wind speed + // For wind speed: max wind speed and average wind speed. dailyMaxData = dailyMinMax.map((date, values) => MapEntry(date, values.$2)); dailyMinData = dailyData; // Average wind speed } @@ -82,12 +85,12 @@ ProcessedChartData processWeatherData({ ? data.getDailyPrecipitationHours() : null; - // Keep original unsorted data for PDF export + // Keep original unsorted data for PDF export. final originalDailyData = dailyData; final originalDailyMaxData = dailyMaxData; final originalDailyMinData = dailyMinData; - // Sort data based on sortAscending parameter for UI display + // Sort data based on sortAscending parameter for UI display. final sortedEntries = dailyData.entries.toList() ..sort( (a, b) => sortAscending @@ -96,7 +99,8 @@ ProcessedChartData processWeatherData({ ); // Descending: new to old dailyData = Map.fromEntries(sortedEntries); - // Sort max/min data for temperature and wind_speed + // Sort max/min data for temperature and wind_speed. + if (dailyMaxData != null && dailyMinData != null) { final sortedMaxEntries = dailyMaxData.entries.toList() ..sort( @@ -113,7 +117,8 @@ ProcessedChartData processWeatherData({ dailyMinData = Map.fromEntries(sortedMinEntries); } - // Calculate actual data range for display and track dates + // Calculate actual data range for display and track dates. + double dataMin = axisMin; double dataMax = axisMax; DateTime? minDate; @@ -122,9 +127,9 @@ ProcessedChartData processWeatherData({ if ((dataType == 'temperature' || dataType == 'wind_speed') && dailyMinData != null && dailyMaxData != null) { - // For temperature and wind_speed, use actual min/avg and max values + // For temperature and wind_speed, use actual min/avg and max values. if (dailyMinData.isNotEmpty && dailyMaxData.isNotEmpty) { - // Find min value and its date + // Find min value and its date. var minEntry = dailyMinData.entries.first; for (final entry in dailyMinData.entries) { if (entry.value < minEntry.value) { @@ -134,7 +139,7 @@ ProcessedChartData processWeatherData({ dataMin = minEntry.value; minDate = minEntry.key; - // Find max value and its date + // Find max value and its date. var maxEntry = dailyMaxData.entries.first; for (final entry in dailyMaxData.entries) { if (entry.value > maxEntry.value) { @@ -145,7 +150,7 @@ ProcessedChartData processWeatherData({ maxDate = maxEntry.key; } } else if (dailyData.isNotEmpty) { - // Find min value and its date + // Find min value and its date. var minEntry = dailyData.entries.first; for (final entry in dailyData.entries) { if (entry.value < minEntry.value) { @@ -155,7 +160,7 @@ ProcessedChartData processWeatherData({ dataMin = minEntry.value; minDate = minEntry.key; - // Find max value and its date + // Find max value and its date. var maxEntry = dailyData.entries.first; for (final entry in dailyData.entries) { if (entry.value > maxEntry.value) { @@ -167,6 +172,7 @@ ProcessedChartData processWeatherData({ } // Sample data for chart if too many points (but keep full data for cards display) + Map chartData = dailyData; Map? chartMaxData = dailyMaxData; Map? chartMinData = dailyMinData; @@ -175,7 +181,9 @@ ProcessedChartData processWeatherData({ chartData = sampleData(dailyData, maxChartDataPoints); if (dailyMaxData != null && dailyMinData != null) { chartMaxData = sampleData(dailyMaxData, maxChartDataPoints); - // For wind_speed, dailyMinData is already dailyData (average), so sample it + + // For wind_speed, dailyMinData is already dailyData (average), so sample it. + if (dataType == 'wind_speed') { chartMinData = chartData; } else { diff --git a/lib/widgets/weather/weather_chart_dual_painter.dart b/lib/widgets/weather/weather_chart_dual_painter.dart index d7a81c4..a519c62 100644 --- a/lib/widgets/weather/weather_chart_dual_painter.dart +++ b/lib/widgets/weather/weather_chart_dual_painter.dart @@ -15,6 +15,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; /// Custom painter for temperature chart with max and min lines. + class WeatherChartDualPainter extends CustomPainter { WeatherChartDualPainter({ required this.dailyMaxValues, @@ -47,13 +48,15 @@ class WeatherChartDualPainter extends CustomPainter { final chartWidth = size.width - chartLeft; final xStep = chartWidth / (maxEntries.length - 1); - // Draw Y-axis grid lines and labels + // Draw Y-axis grid lines and labels. + _drawYAxisAndGrid(canvas, size, chartLeft); - // Reserve space for X-axis labels at bottom + // Reserve space for X-axis labels at bottom. final chartHeight = size.height - 20; // Reserve 20px for X-axis labels - // Draw max temperature line + // Draw max temperature line. + _drawCurveLine( canvas, maxEntries, @@ -64,7 +67,8 @@ class WeatherChartDualPainter extends CustomPainter { valueRange, ); - // Draw min temperature line + // Draw min temperature line. + _drawCurveLine( canvas, minEntries, @@ -75,7 +79,8 @@ class WeatherChartDualPainter extends CustomPainter { valueRange, ); - // Draw points for both lines + // Draw points for both lines. + _drawPoints( canvas, maxEntries, @@ -96,6 +101,7 @@ class WeatherChartDualPainter extends CustomPainter { ); // Draw X-axis labels (dates) + _drawXAxisLabels(canvas, size, maxEntries, chartLeft, xStep); } @@ -114,7 +120,7 @@ class WeatherChartDualPainter extends CustomPainter { ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; - // Calculate points + // Calculate points. final points = []; for (var i = 0; i < entries.length; i++) { final value = entries[i].value; @@ -123,7 +129,8 @@ class WeatherChartDualPainter extends CustomPainter { points.add(Offset(x, y)); } - // Draw smooth curve + // Draw smooth curve. + if (points.length >= 2) { final path = Path(); path.moveTo(points[0].dx, points[0].dy); @@ -131,7 +138,7 @@ class WeatherChartDualPainter extends CustomPainter { if (points.length == 2) { path.lineTo(points[1].dx, points[1].dy); } else { - // Catmull-Rom spline for smooth curves + // Catmull-Rom spline for smooth curves. for (var i = 0; i < points.length - 1; i++) { final p0 = i > 0 ? points[i - 1] : points[i]; final p1 = points[i]; @@ -184,13 +191,14 @@ class WeatherChartDualPainter extends CustomPainter { ..strokeWidth = 1 ..style = PaintingStyle.stroke; - // Calculate nice step size for Y-axis + // Calculate nice step size for Y-axis. final valueRange = maxValue - minValue; final rawStep = valueRange / 5; // Aim for ~5 grid lines final magnitude = pow(10, (log(rawStep) / ln10).floor()).toDouble(); final normalizedStep = rawStep / magnitude; // Round to nice numbers (1, 2, 5, 10) + double niceStep; if (normalizedStep <= 1) { niceStep = magnitude.toDouble(); @@ -202,14 +210,15 @@ class WeatherChartDualPainter extends CustomPainter { niceStep = (10 * magnitude).toDouble(); } - // Draw Y-axis + // Draw Y-axis. + canvas.drawLine( Offset(chartLeft, 0), Offset(chartLeft, chartHeight), axisPaint, ); - // Draw grid lines and labels + // Draw grid lines and labels. final startValue = (minValue / niceStep).ceil() * niceStep; var currentValue = startValue; @@ -217,17 +226,19 @@ class WeatherChartDualPainter extends CustomPainter { final y = chartHeight - ((currentValue - minValue) / valueRange) * chartHeight; - // Draw grid line + // Draw grid line. + canvas.drawLine(Offset(chartLeft, y), Offset(size.width, y), gridPaint); - // Draw tick mark + // Draw tick mark. + canvas.drawLine( Offset(chartLeft - 5, y), Offset(chartLeft, y), axisPaint, ); - // Draw label + // Draw label. final textSpan = TextSpan( text: currentValue.toStringAsFixed(1), style: TextStyle(color: Colors.grey[700], fontSize: 10), @@ -259,7 +270,8 @@ class WeatherChartDualPainter extends CustomPainter { ..strokeWidth = 1 ..style = PaintingStyle.stroke; - // Draw X-axis line + // Draw X-axis line. + canvas.drawLine( Offset(chartLeft, chartHeight), Offset(size.width, chartHeight), @@ -267,7 +279,8 @@ class WeatherChartDualPainter extends CustomPainter { ); // Calculate label step based on number of points - // Allow showing 10-14 labels for better readability + // Allow showing 10-14 labels for better readability. + int labelStep; if (entries.length <= 14) { labelStep = 1; // Show all labels (up to 14) @@ -276,25 +289,28 @@ class WeatherChartDualPainter extends CustomPainter { } else if (entries.length <= 42) { labelStep = 3; // Show every 3rd label (~10-14 labels) } else { - // For many points, aim for 10-14 labels + // For many points, aim for 10-14 labels. labelStep = (entries.length / 12).ceil(); } - // Draw date labels and tick marks + // Draw date labels and tick marks. + for (var i = 0; i < entries.length; i++) { final date = entries[i].key; final x = chartLeft + (i * xStep); - // Always draw tick marks for all points + // Always draw tick marks for all points. + canvas.drawLine( Offset(x, chartHeight), Offset(x, chartHeight + 5), axisPaint, ); - // Only draw labels at intervals + // Only draw labels at intervals. + if (i % labelStep == 0 || i == entries.length - 1) { - // Format date as MM/DD + // Format date as MM/DD. final dateText = '${date.month}/${date.day}'; final textSpan = TextSpan( text: dateText, @@ -306,7 +322,8 @@ class WeatherChartDualPainter extends CustomPainter { ); textPainter.layout(); - // Draw label centered under the tick, with rotation for better fit + // Draw label centered under the tick, with rotation for better fit. + canvas.save(); canvas.translate(x, chartHeight + 8); canvas.rotate(-0.3); // Slight rotation for better readability diff --git a/lib/widgets/weather/weather_chart_empty_state.dart b/lib/widgets/weather/weather_chart_empty_state.dart index d9b9c38..306ae5e 100644 --- a/lib/widgets/weather/weather_chart_empty_state.dart +++ b/lib/widgets/weather/weather_chart_empty_state.dart @@ -13,6 +13,7 @@ library; import 'package:flutter/material.dart'; /// Widget displayed when there is no data available for the chart. + class WeatherChartEmptyState extends StatelessWidget { const WeatherChartEmptyState({required this.dataTitle, super.key}); diff --git a/lib/widgets/weather/weather_chart_header.dart b/lib/widgets/weather/weather_chart_header.dart index 56bde9c..c849fd3 100644 --- a/lib/widgets/weather/weather_chart_header.dart +++ b/lib/widgets/weather/weather_chart_header.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; /// Header widget for the weather chart showing title and date range. + class WeatherChartHeader extends StatelessWidget { const WeatherChartHeader({ required this.title, diff --git a/lib/widgets/weather/weather_chart_helpers.dart b/lib/widgets/weather/weather_chart_helpers.dart index 59880d7..790dc65 100644 --- a/lib/widgets/weather/weather_chart_helpers.dart +++ b/lib/widgets/weather/weather_chart_helpers.dart @@ -16,6 +16,7 @@ import 'package:geopod/models/hourly_weather_data.dart'; /// Get data range based on data type. /// For precipitation, returns the range of daily totals, not hourly values. + (double, double) getDataRange(String dataType, HourlyWeatherData data) { switch (dataType) { case 'humidity': @@ -31,6 +32,7 @@ import 'package:geopod/models/hourly_weather_data.dart'; } /// Get daily data based on data type. + Map getDailyData(String dataType, HourlyWeatherData data) { switch (dataType) { case 'humidity': @@ -46,6 +48,7 @@ Map getDailyData(String dataType, HourlyWeatherData data) { } /// Get data title based on data type. + String getDataTitle(String dataType) { switch (dataType) { case 'humidity': @@ -61,6 +64,7 @@ String getDataTitle(String dataType) { } /// Get data unit based on data type. + String getDataUnit(String dataType) { switch (dataType) { case 'humidity': @@ -76,6 +80,7 @@ String getDataUnit(String dataType) { } /// Get data icon based on data type. + IconData getDataIcon(String dataType) { switch (dataType) { case 'humidity': @@ -91,6 +96,7 @@ IconData getDataIcon(String dataType) { } /// Get color based on value position in range. + Color getValueColor(double value, double min, double max) { final range = max - min; if (range == 0) return Colors.blue; diff --git a/lib/widgets/weather/weather_chart_painter.dart b/lib/widgets/weather/weather_chart_painter.dart index 2a0566b..95e9941 100644 --- a/lib/widgets/weather/weather_chart_painter.dart +++ b/lib/widgets/weather/weather_chart_painter.dart @@ -16,6 +16,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; /// Custom painter for temperature/weather chart. + class WeatherChartPainter extends CustomPainter { WeatherChartPainter({ required this.dailyAverages, @@ -43,13 +44,14 @@ class WeatherChartPainter extends CustomPainter { final chartWidth = size.width - chartLeft; final xStep = chartWidth / (entries.length - 1); - // Draw Y-axis grid lines and labels + // Draw Y-axis grid lines and labels. + _drawYAxisAndGrid(canvas, size, chartLeft); - // Reserve space for X-axis labels at bottom + // Reserve space for X-axis labels at bottom. final chartHeight = size.height - 20; // Reserve 20px for X-axis labels - // Draw smooth curve using cubic Bezier interpolation + // Draw smooth curve using cubic Bezier interpolation. final curvePaint = Paint() ..color = color.withValues(alpha: 0.8) ..strokeWidth = 2.5 @@ -60,7 +62,7 @@ class WeatherChartPainter extends CustomPainter { ..color = color ..style = PaintingStyle.fill; - // Calculate control points for smooth curve + // Calculate control points for smooth curve. final points = []; for (var i = 0; i < entries.length; i++) { final value = entries[i].value; @@ -69,23 +71,24 @@ class WeatherChartPainter extends CustomPainter { points.add(Offset(x, y)); } - // Draw smooth curve using Catmull-Rom spline + // Draw smooth curve using Catmull-Rom spline. + if (points.length >= 2) { final path = Path(); path.moveTo(points[0].dx, points[0].dy); if (points.length == 2) { - // Simple line for 2 points + // Simple line for 2 points. path.lineTo(points[1].dx, points[1].dy); } else { - // Catmull-Rom spline for smooth curves + // Catmull-Rom spline for smooth curves. for (var i = 0; i < points.length - 1; i++) { final p0 = i > 0 ? points[i - 1] : points[i]; final p1 = points[i]; final p2 = points[i + 1]; final p3 = i < points.length - 2 ? points[i + 2] : points[i + 1]; - // Calculate control points using Catmull-Rom to Bezier conversion + // Calculate control points using Catmull-Rom to Bezier conversion. var cp1x = p1.dx + (p2.dx - p0.dx) / 6; var cp1y = p1.dy + (p2.dy - p0.dy) / 6; var cp2x = p2.dx - (p3.dx - p1.dx) / 6; @@ -95,6 +98,7 @@ class WeatherChartPainter extends CustomPainter { // Important for non-negative values like precipitation and wind speed. // Only apply this clamping when the data domain is non-negative (minValue >= 0) // to avoid distorting curves for data types that can be negative (e.g. temperature). + if (minValue >= 0) { cp1y = cp1y.clamp(0.0, chartHeight); cp2y = cp2y.clamp(0.0, chartHeight); @@ -104,7 +108,8 @@ class WeatherChartPainter extends CustomPainter { } } - // Clip the path to ensure it doesn't go below the chart area + // Clip the path to ensure it doesn't go below the chart area. + canvas.save(); canvas.clipRect( Rect.fromLTWH(chartLeft, 0, size.width - chartLeft, chartHeight), @@ -113,10 +118,13 @@ class WeatherChartPainter extends CustomPainter { canvas.restore(); } - // Draw data points + // Draw data points. + for (final point in points) { canvas.drawCircle(point, 4, pointPaint); - // Draw white border for better visibility + + // Draw white border for better visibility. + canvas.drawCircle( point, 4, @@ -127,10 +135,12 @@ class WeatherChartPainter extends CustomPainter { ); } - // Draw function info + // Draw function info. + _drawFunctionInfo(canvas, size, chartLeft); // Draw X-axis labels (dates) + _drawXAxisLabels(canvas, size, chartLeft, chartHeight, xStep); } @@ -146,13 +156,14 @@ class WeatherChartPainter extends CustomPainter { ..strokeWidth = 1 ..style = PaintingStyle.stroke; - // Calculate nice step size for Y-axis + // Calculate nice step size for Y-axis. final valueRange = maxValue - minValue; final rawStep = valueRange / 5; // Aim for ~5 grid lines final magnitude = pow(10, (log(rawStep) / ln10).floor()).toDouble(); final normalizedStep = rawStep / magnitude; // Round to nice numbers (1, 2, 5, 10) + double niceStep; if (normalizedStep <= 1) { niceStep = magnitude.toDouble(); @@ -164,14 +175,15 @@ class WeatherChartPainter extends CustomPainter { niceStep = (10 * magnitude).toDouble(); } - // Draw Y-axis + // Draw Y-axis. + canvas.drawLine( Offset(chartLeft, 0), Offset(chartLeft, chartHeight), axisPaint, ); - // Draw grid lines and labels + // Draw grid lines and labels. final startValue = (minValue / niceStep).ceil() * niceStep; var currentValue = startValue; @@ -179,17 +191,19 @@ class WeatherChartPainter extends CustomPainter { final y = chartHeight - ((currentValue - minValue) / valueRange) * chartHeight; - // Draw grid line + // Draw grid line. + canvas.drawLine(Offset(chartLeft, y), Offset(size.width, y), gridPaint); - // Draw tick mark + // Draw tick mark. + canvas.drawLine( Offset(chartLeft - 5, y), Offset(chartLeft, y), axisPaint, ); - // Draw label + // Draw label. final textSpan = TextSpan( text: currentValue.toStringAsFixed(1), style: TextStyle(color: Colors.grey[700], fontSize: 10), @@ -209,7 +223,7 @@ class WeatherChartPainter extends CustomPainter { } void _drawFunctionInfo(Canvas canvas, Size size, double chartLeft) { - // Draw function info at top-right + // Draw function info at top-right. final infoText = 'f(x) = Catmull-Rom spline'; final textSpan = TextSpan( text: infoText, @@ -226,7 +240,8 @@ class WeatherChartPainter extends CustomPainter { ); textPainter.layout(); - // Position at top-right with padding + // Position at top-right with padding. + textPainter.paint(canvas, Offset(size.width - textPainter.width - 8, 4)); } @@ -243,7 +258,8 @@ class WeatherChartPainter extends CustomPainter { ..strokeWidth = 1 ..style = PaintingStyle.stroke; - // Draw X-axis line + // Draw X-axis line. + canvas.drawLine( Offset(chartLeft, chartHeight), Offset(size.width, chartHeight), @@ -251,7 +267,8 @@ class WeatherChartPainter extends CustomPainter { ); // Calculate label step based on number of points - // Allow showing 10-14 labels for better readability + // Allow showing 10-14 labels for better readability. + int labelStep; if (entries.length <= 14) { labelStep = 1; // Show all labels (up to 14) @@ -260,25 +277,28 @@ class WeatherChartPainter extends CustomPainter { } else if (entries.length <= 42) { labelStep = 3; // Show every 3rd label (~10-14 labels) } else { - // For many points, aim for 10-14 labels + // For many points, aim for 10-14 labels. labelStep = (entries.length / 12).ceil(); } - // Draw date labels and tick marks + // Draw date labels and tick marks. + for (var i = 0; i < entries.length; i++) { final date = entries[i].key; final x = chartLeft + (i * xStep); - // Always draw tick marks for all points + // Always draw tick marks for all points. + canvas.drawLine( Offset(x, chartHeight), Offset(x, chartHeight + 5), axisPaint, ); - // Only draw labels at intervals + // Only draw labels at intervals. + if (i % labelStep == 0 || i == entries.length - 1) { - // Format date as MM/DD + // Format date as MM/DD. final dateText = '${date.month}/${date.day}'; final textSpan = TextSpan( text: dateText, @@ -290,7 +310,8 @@ class WeatherChartPainter extends CustomPainter { ); textPainter.layout(); - // Draw label centered under the tick, with rotation for better fit + // Draw label centered under the tick, with rotation for better fit. + canvas.save(); canvas.translate(x, chartHeight + 8); canvas.rotate(-0.3); // Slight rotation for better readability diff --git a/lib/widgets/weather/weather_chart_pdf.dart b/lib/widgets/weather/weather_chart_pdf.dart index 1acc156..97e118f 100644 --- a/lib/widgets/weather/weather_chart_pdf.dart +++ b/lib/widgets/weather/weather_chart_pdf.dart @@ -25,6 +25,7 @@ export 'pdf_export_handler.dart'; export 'pdf_utils.dart'; /// Export weather data to PDF. + Future exportWeatherChartToPdf( BuildContext context, { required HourlyWeatherData data, @@ -46,7 +47,7 @@ Future exportWeatherChartToPdf( String? dataSource, }) async { try { - // Build PDF document + // Build PDF document. final pdf = buildWeatherPdfDocument( data: data, dailyData: dailyData, @@ -67,15 +68,16 @@ Future exportWeatherChartToPdf( dataSource: dataSource, ); - // Save PDF and get bytes + // Save PDF and get bytes. final bytes = await pdf.save(); - // Handle platform-specific export + // Handle platform-specific export. + if (context.mounted) { await handlePdfExport(context, bytes); } } catch (e) { - // Show error message if PDF export fails + // Show error message if PDF export fails. if (context.mounted) { SnackBarHelper.showError( context, diff --git a/lib/widgets/weather/weather_chart_range_indicator.dart b/lib/widgets/weather/weather_chart_range_indicator.dart index b8e0699..08d48e4 100644 --- a/lib/widgets/weather/weather_chart_range_indicator.dart +++ b/lib/widgets/weather/weather_chart_range_indicator.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; /// Widget displaying the min and max values of the data range with dates. + class WeatherChartRangeIndicator extends StatelessWidget { const WeatherChartRangeIndicator({ required this.dataMin, @@ -40,6 +41,7 @@ class WeatherChartRangeIndicator extends StatelessWidget { final dateFormat = DateFormat('dd/MM'); // For wind_speed, only show max wind (since it's max + average, not max + min) + if (dataType == 'wind_speed') { return Row( children: [ @@ -64,7 +66,8 @@ class WeatherChartRangeIndicator extends StatelessWidget { ); } - // Determine label prefix based on data type + // Determine label prefix based on data type. + String getLabel(bool isMax) { if (dataType == 'temperature') { return isMax ? 'Max Temp' : 'Min Temp'; diff --git a/lib/widgets/weather/weather_chart_sampling.dart b/lib/widgets/weather/weather_chart_sampling.dart index 9fb2570..f13616f 100644 --- a/lib/widgets/weather/weather_chart_sampling.dart +++ b/lib/widgets/weather/weather_chart_sampling.dart @@ -16,37 +16,39 @@ import 'dart:math'; /// Sample data using Ramer-Douglas-Peucker algorithm to preserve curve characteristics. /// This algorithm keeps points that are important for maintaining the shape of the curve. + Map sampleData(Map data, int targetCount) { if (data.length <= targetCount) return data; final entries = data.entries.toList(); - // Use Douglas-Peucker algorithm for smart sampling + // Use Douglas-Peucker algorithm for smart sampling. final sampled = douglasPeucker(entries, targetCount); - // Convert back to map + // Convert back to map. return Map.fromEntries(sampled); } /// Ramer-Douglas-Peucker algorithm implementation. /// Reduces number of points while preserving the overall shape. + List> douglasPeucker( List> points, int targetCount, ) { if (points.length <= targetCount) return points; - // Calculate appropriate epsilon (tolerance) based on data range + // Calculate appropriate epsilon (tolerance) based on data range. final values = points.map((e) => e.value).toList(); final minVal = values.reduce((a, b) => a < b ? a : b); final maxVal = values.reduce((a, b) => a > b ? a : b); final range = maxVal - minVal; - // Start with a small epsilon and increase until we reach target count + // Start with a small epsilon and increase until we reach target count. var epsilon = range * 0.01; var result = rdpRecursive(points, epsilon); - // Adjust epsilon to get closer to target count + // Adjust epsilon to get closer to target count. var iterations = 0; while (result.length > targetCount && iterations < 10) { epsilon *= 1.5; @@ -54,7 +56,8 @@ List> douglasPeucker( iterations++; } - // If still too many points, fall back to uniform sampling + // If still too many points, fall back to uniform sampling. + if (result.length > targetCount) { final step = result.length / targetCount; final uniformSampled = >[]; @@ -71,13 +74,14 @@ List> douglasPeucker( } /// Recursive RDP algorithm. + List> rdpRecursive( List> points, double epsilon, ) { if (points.length < 3) return points; - // Find the point with maximum distance from line segment + // Find the point with maximum distance from line segment. var maxDistance = 0.0; var maxIndex = 0; @@ -97,7 +101,8 @@ List> rdpRecursive( } } - // If max distance is greater than epsilon, recursively simplify + // If max distance is greater than epsilon, recursively simplify. + if (maxDistance > epsilon) { final left = rdpRecursive(points.sublist(0, maxIndex + 1), epsilon); final right = rdpRecursive(points.sublist(maxIndex), epsilon); @@ -105,19 +110,20 @@ List> rdpRecursive( // Combine results (remove duplicate middle point) return [...left.sublist(0, left.length - 1), ...right]; } else { - // If max distance is less than epsilon, keep only endpoints + // If max distance is less than epsilon, keep only endpoints. return [firstPoint, lastPoint]; } } /// Calculate perpendicular distance from point to line segment. + double perpendicularDistance( MapEntry point, MapEntry lineStart, MapEntry lineEnd, int totalPoints, ) { - // Normalize time to 0-1 range for distance calculation + // Normalize time to 0-1 range for distance calculation. final x0 = point.key.millisecondsSinceEpoch.toDouble(); final y0 = point.value; @@ -127,7 +133,7 @@ double perpendicularDistance( final x2 = lineEnd.key.millisecondsSinceEpoch.toDouble(); final y2 = lineEnd.value; - // Calculate perpendicular distance + // Calculate perpendicular distance. final dx = x2 - x1; final dy = y2 - y1; final numerator = ((dy * (x0 - x1)) - (dx * (y0 - y1))).abs(); @@ -137,6 +143,7 @@ double perpendicularDistance( } /// Sample entries for PDF to reduce clutter. + List> sampleEntriesForPdf( List> entries, int targetCount, @@ -151,7 +158,8 @@ List> sampleEntriesForPdf( sampled.add(entries[index]); } - // Always include the last entry + // Always include the last entry. + if (sampled.last.key != entries.last.key) { sampled.add(entries.last); } diff --git a/lib/widgets/weather/weather_date_selector.dart b/lib/widgets/weather/weather_date_selector.dart index a101463..45560f2 100644 --- a/lib/widgets/weather/weather_date_selector.dart +++ b/lib/widgets/weather/weather_date_selector.dart @@ -17,6 +17,7 @@ import 'package:intl/intl.dart'; import 'package:geopod/utils/ui_utils.dart'; /// Build date range selector for historical weather. + Widget buildDateRangeSelector({ required BuildContext context, required DateTime? historicalStartDate, diff --git a/lib/widgets/weather/weather_detail_builders.dart b/lib/widgets/weather/weather_detail_builders.dart index 9c206a2..3fa93ca 100644 --- a/lib/widgets/weather/weather_detail_builders.dart +++ b/lib/widgets/weather/weather_detail_builders.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:geopod/models/weather_data.dart'; /// Build a generic weather detail row. + Widget buildWeatherDetail({ required IconData icon, required String label, @@ -39,6 +40,7 @@ Widget buildWeatherDetail({ } /// Build wind direction detail with tooltip. + Widget buildWindDirectionDetail(WeatherData weather) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), @@ -52,7 +54,7 @@ Widget buildWindDirectionDetail(WeatherData weather) { Builder( builder: (context) => GestureDetector( onTap: () { - // Show info dialog when tapped + // Show info dialog when tapped. showDialog( context: context, builder: (context) => AlertDialog( @@ -108,13 +110,14 @@ Widget buildWindDirectionDetail(WeatherData weather) { } /// Build precipitation detail with hourly/daily toggle. + Widget buildPrecipitationDetail({ required WeatherData weather, required bool showDailyPrecipitation, required VoidCallback onToggle, }) { // API returns precipitation for the past hour (mm) - // User can toggle to see today's accumulated total + // User can toggle to see today's accumulated total. final precipValue = showDailyPrecipitation ? (weather.todayTotalPrecipitation ?? weather.precipitation) // Today's total accumulated @@ -154,6 +157,7 @@ Widget buildPrecipitationDetail({ } /// Build data type selector (temperature, humidity, wind, rain). + Widget buildDataTypeSelector({ required String selectedDataType, required void Function(String) onSelectionChanged, diff --git a/lib/widgets/weather/weather_view_widgets.dart b/lib/widgets/weather/weather_view_widgets.dart index 9128d18..521b370 100644 --- a/lib/widgets/weather/weather_view_widgets.dart +++ b/lib/widgets/weather/weather_view_widgets.dart @@ -21,6 +21,7 @@ import 'package:geopod/widgets/hourly_weather_chart.dart'; import 'weather_detail_builders.dart'; /// Build error view for failed weather loading. + Widget buildErrorView(BuildContext context, String? errorMessage) { return Column( mainAxisSize: MainAxisSize.min, @@ -42,6 +43,7 @@ Widget buildErrorView(BuildContext context, String? errorMessage) { } /// Build current weather view. + Widget buildCurrentWeatherView({ required BuildContext context, required WeatherData weatherData, @@ -59,7 +61,7 @@ Widget buildCurrentWeatherView({ mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Location + // Location. if (address != null && address.isNotEmpty) ...[ Text(address, style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: 4), @@ -74,7 +76,7 @@ Widget buildCurrentWeatherView({ ), const SizedBox(height: 12), - // Main weather display + // Main weather display. Center( child: Column( children: [ @@ -94,7 +96,8 @@ Widget buildCurrentWeatherView({ fontWeight: FontWeight.bold, ), ), - // Show today's high/low if available + + // Show today's high/low if available. if (weatherData.dailyMaxTemp != null && weatherData.dailyMinTemp != null) ...[ const SizedBox(height: 4), @@ -110,7 +113,7 @@ Widget buildCurrentWeatherView({ ), const SizedBox(height: 16), - // Weather details + // Weather details. buildWeatherDetail( icon: Icons.water_drop, label: 'Humidity', @@ -142,6 +145,7 @@ Widget buildCurrentWeatherView({ } /// Build past weather view. + Widget buildPastWeatherView({ required BuildContext context, required bool isLoading, @@ -191,7 +195,7 @@ Widget buildPastWeatherView({ HourlyWeatherChart( data: pastWeatherData, dataType: selectedDataType, - sortAscending: false, // Past: newest to oldest + sortAscending: true, // Past: oldest to newest latitude: latitude, longitude: longitude, address: address, @@ -203,6 +207,7 @@ Widget buildPastWeatherView({ } /// Build forecast weather view. + Widget buildForecastWeatherView({ required BuildContext context, required bool isLoading, @@ -264,6 +269,7 @@ Widget buildForecastWeatherView({ } /// Build historical weather view. + Widget buildHistoricalWeatherView({ required BuildContext context, required bool isLoading, @@ -347,7 +353,7 @@ Widget buildHistoricalWeatherView({ HourlyWeatherChart( data: historicalWeatherData, dataType: selectedDataType, - sortAscending: false, // Historical: newest to oldest + sortAscending: true, // Historical: oldest to newest latitude: latitude, longitude: longitude, address: address, diff --git a/lib/widgets/weather_dialog.dart b/lib/widgets/weather_dialog.dart index 5b4be1a..67a8c24 100644 --- a/lib/widgets/weather_dialog.dart +++ b/lib/widgets/weather_dialog.dart @@ -22,6 +22,7 @@ import 'weather/weather_date_selector.dart'; import 'weather/weather_view_widgets.dart'; /// Shows a weather info dialog for the specified location. + Future showWeatherDialog({ required BuildContext context, required double latitude, @@ -39,6 +40,7 @@ Future showWeatherDialog({ } /// Dialog displaying current weather information. + class WeatherDialog extends StatefulWidget { const WeatherDialog({ required this.latitude, @@ -175,7 +177,7 @@ class _WeatherDialogState extends State }); try { - // ERA5 archive data has 5-7 days delay, so end date must be at least 7 days ago + // ERA5 archive data has 5-7 days delay, so end date must be at least 7 days ago. final now = DateTime.now(); final selectedEndDate = endDate ?? now.subtract(const Duration(days: 7)); final selectedStartDate = @@ -249,20 +251,20 @@ class _WeatherDialogState extends State child: TabBarView( controller: _tabController, children: [ - // Current weather tab + // Current weather tab. _isLoading ? const Center(child: CircularProgressIndicator()) : _errorMessage != null ? _buildErrorView() : _buildWeatherView(), - // Past weather tab + // Past weather tab. _buildPastWeatherView(), - // Forecast weather tab + // Forecast weather tab. _buildForecastWeatherView(), - // Historical weather tab + // Historical weather tab. _buildHistoricalWeatherView(), ], ),