import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'version_service.dart'; import '../app_state.dart'; import '../migrations.dart'; /// Service for managing changelog data and first launch detection class ChangelogService { static final ChangelogService _instance = ChangelogService._internal(); factory ChangelogService() => _instance; ChangelogService._internal(); static const String _lastSeenVersionKey = 'last_seen_version'; static const String _hasSeenWelcomeKey = 'has_seen_welcome'; static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide'; static const String _hasCompletedPositioningTutorialKey = 'has_completed_positioning_tutorial'; Map? _changelogData; bool _initialized = false; /// Parse changelog content from either string or array format String? _parseChangelogContent(dynamic content) { if (content == null) return null; if (content is String) { // Legacy format: single string with \n return content.isEmpty ? null : content; } else if (content is List) { // New format: array of strings final lines = content.whereType().where((line) => line.isNotEmpty).toList(); return lines.isEmpty ? null : lines.join('\n'); } return null; } /// Initialize the service by loading changelog data Future init() async { if (_initialized) return; try { final String jsonString = await rootBundle.loadString('assets/changelog.json'); _changelogData = json.decode(jsonString); _initialized = true; debugPrint('[ChangelogService] Loaded changelog with ${_changelogData?.keys.length ?? 0} versions'); } catch (e) { debugPrint('[ChangelogService] Failed to load changelog: $e'); _changelogData = {}; _initialized = true; // Mark as initialized even on failure to prevent repeated attempts } } /// Check if this is the first app launch ever Future isFirstLaunch() async { final prefs = await SharedPreferences.getInstance(); return !prefs.containsKey(_lastSeenVersionKey); } /// Check if user has seen the welcome popup (separate from version tracking) Future hasSeenWelcome() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_hasSeenWelcomeKey) ?? false; } /// Mark that user has seen the welcome popup Future markWelcomeSeen() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_hasSeenWelcomeKey, true); } /// Check if user has seen the submission guide popup Future hasSeenSubmissionGuide() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_hasSeenSubmissionGuideKey) ?? false; } /// Mark that user has seen the submission guide popup Future markSubmissionGuideSeen() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_hasSeenSubmissionGuideKey, true); } /// Check if user has completed the positioning tutorial Future hasCompletedPositioningTutorial() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_hasCompletedPositioningTutorialKey) ?? false; } /// Mark that user has completed the positioning tutorial Future markPositioningTutorialCompleted() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_hasCompletedPositioningTutorialKey, true); } /// Check if app version has changed since last launch Future hasVersionChanged() async { final prefs = await SharedPreferences.getInstance(); final lastSeenVersion = prefs.getString(_lastSeenVersionKey); final currentVersion = VersionService().version; return lastSeenVersion != currentVersion; } /// Update the stored version to current version Future updateLastSeenVersion() async { final prefs = await SharedPreferences.getInstance(); final currentVersion = VersionService().version; await prefs.setString(_lastSeenVersionKey, currentVersion); debugPrint('[ChangelogService] Updated last seen version to: $currentVersion'); } /// Get the last seen version (for migration purposes) Future getLastSeenVersion() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_lastSeenVersionKey); } /// Get changelog content for the current version String? getChangelogForCurrentVersion() { if (!_initialized || _changelogData == null) { debugPrint('[ChangelogService] Not initialized or no changelog data'); return null; } final currentVersion = VersionService().version; final versionData = _changelogData![currentVersion] as Map?; if (versionData == null) { debugPrint('[ChangelogService] No changelog entry found for version: $currentVersion'); return null; } return _parseChangelogContent(versionData['content']); } /// Get the changelog content that should be displayed (may be combined from multiple versions) /// This is the method home_screen should use to get content for the changelog popup Future getChangelogContentForDisplay() async { return await getCombinedChangelogContent(); } /// Complete the version change workflow - call this after showing popups /// This updates the last seen version so migrations don't run again Future completeVersionChange() async { await updateLastSeenVersion(); } /// Get changelog content for a specific version String? getChangelogForVersion(String version) { if (!_initialized || _changelogData == null) return null; final versionData = _changelogData![version] as Map?; if (versionData == null) return null; return _parseChangelogContent(versionData['content']); } /// Get all changelog entries (for settings page) Map getAllChangelogs() { if (!_initialized || _changelogData == null) return {}; final Map result = {}; for (final entry in _changelogData!.entries) { final version = entry.key; final versionData = entry.value as Map?; final content = _parseChangelogContent(versionData?['content']); // Only include versions with non-empty content if (content != null && content.isNotEmpty) { result[version] = content; } } return result; } /// Determine what popup (if any) should be shown Future getPopupType() async { // Ensure services are initialized if (!_initialized) await init(); final isFirstLaunch = await this.isFirstLaunch(); final hasSeenWelcome = await this.hasSeenWelcome(); final hasVersionChanged = await this.hasVersionChanged(); // First launch and haven't seen welcome if (isFirstLaunch || !hasSeenWelcome) { return PopupType.welcome; } // Version changed and there's changelog content if (hasVersionChanged) { final changelogContent = await getCombinedChangelogContent(); if (changelogContent != null) { return PopupType.changelog; } } return PopupType.none; } /// Check if version-change migrations need to be run /// Returns list of version strings that need migrations Future> getVersionsNeedingMigration() async { final lastSeenVersion = await getLastSeenVersion(); final currentVersion = VersionService().version; if (lastSeenVersion == null) return []; // First launch, no migrations needed final versionsNeedingMigration = []; // Check each version that could need migration if (needsMigration(lastSeenVersion, currentVersion, '1.3.1')) { versionsNeedingMigration.add('1.3.1'); } if (needsMigration(lastSeenVersion, currentVersion, '1.5.3')) { versionsNeedingMigration.add('1.5.3'); } if (needsMigration(lastSeenVersion, currentVersion, '1.6.3')) { versionsNeedingMigration.add('1.6.3'); } if (needsMigration(lastSeenVersion, currentVersion, '1.8.0')) { versionsNeedingMigration.add('1.8.0'); } if (needsMigration(lastSeenVersion, currentVersion, '2.1.0')) { versionsNeedingMigration.add('2.1.0'); } if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) { versionsNeedingMigration.add('2.7.3'); } if (needsMigration(lastSeenVersion, currentVersion, '2.10.0')) { versionsNeedingMigration.add('2.10.0'); } if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) { versionsNeedingMigration.add('2.10.5'); } // Future versions can be added here // if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) { // versionsNeedingMigration.add('2.0.0'); // } return versionsNeedingMigration; } /// Get combined changelog content for all versions between last seen and current /// Returns null if no changelog content exists for any intermediate version Future getCombinedChangelogContent() async { if (!_initialized || _changelogData == null) return null; final lastSeenVersion = await getLastSeenVersion(); final currentVersion = VersionService().version; if (lastSeenVersion == null) { // First launch - just return current version changelog return getChangelogForCurrentVersion(); } final intermediateVersions = []; // Collect all relevant versions between lastSeen and current (exclusive of lastSeen, inclusive of current) for (final entry in _changelogData!.entries) { final version = entry.key; final versionData = entry.value as Map?; final content = _parseChangelogContent(versionData?['content']); // Skip versions with empty content if (content == null || content.isEmpty) continue; // Include versions where: lastSeenVersion < version <= currentVersion if (needsMigration(lastSeenVersion, currentVersion, version)) { intermediateVersions.add(version); } } // Sort versions in descending order (newest first) intermediateVersions.sort((a, b) => compareVersions(b, a)); // Build changelog content final intermediateChangelogs = intermediateVersions.map((version) { final versionData = _changelogData![version] as Map; final content = _parseChangelogContent(versionData['content'])!; // Safe to use ! here since we filtered empty content above return '**Version $version:**\n$content'; }).toList(); return intermediateChangelogs.isNotEmpty ? intermediateChangelogs.join('\n\n---\n\n') : null; } /// Check if the service is properly initialized bool get isInitialized => _initialized; /// Run a specific migration by version number Future runMigration(String version, AppState appState, BuildContext? context) async { debugPrint('[ChangelogService] Running $version migration'); await OneTimeMigrations.runMigration(version, appState, context); } /// Check if a migration should run /// Migration runs if: lastSeenVersion < migrationVersion <= currentVersion bool needsMigration(String lastSeenVersion, String currentVersion, String migrationVersion) { final lastVsMigration = compareVersions(lastSeenVersion, migrationVersion); final migrationVsCurrent = compareVersions(migrationVersion, currentVersion); return lastVsMigration < 0 && migrationVsCurrent <= 0; } /// Compare two version strings /// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 /// Versions are expected in format "major.minor.patch" (e.g., "1.3.1") int compareVersions(String v1, String v2) { try { final v1Parts = v1.split('.').map(int.parse).toList(); final v2Parts = v2.split('.').map(int.parse).toList(); // Ensure we have at least 3 parts (major.minor.patch) while (v1Parts.length < 3) { v1Parts.add(0); } while (v2Parts.length < 3) { v2Parts.add(0); } // Compare major version first if (v1Parts[0] < v2Parts[0]) return -1; if (v1Parts[0] > v2Parts[0]) return 1; // Major versions equal, compare minor version if (v1Parts[1] < v2Parts[1]) return -1; if (v1Parts[1] > v2Parts[1]) return 1; // Major and minor equal, compare patch version if (v1Parts[2] < v2Parts[2]) return -1; if (v1Parts[2] > v2Parts[2]) return 1; // All parts equal return 0; } catch (e) { debugPrint('[ChangelogService] Error comparing versions "$v1" vs "$v2": $e'); // Safe fallback: assume they're different so we run migrations return v1 == v2 ? 0 : -1; } } } /// Types of popups that can be shown enum PopupType { none, welcome, changelog, }