import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../data/words_data.dart'; import '../models/word_model.dart'; import '../models/word_of_day_model.dart'; import 'words_repository.dart'; class WordOfTheDayService extends ChangeNotifier { static final WordOfTheDayService _instance = WordOfTheDayService._internal(); factory WordOfTheDayService() => _instance; WordOfTheDayService._internal(); static const String _kRecordsPrefsKey = 'oracle_word_of_day_records_v1'; static const String _kGlobalTriptychKey = 'oracle_word_of_day_is_triptych_v1'; final Map _records = {}; bool _isInitialized = false; bool _globalTriptychMode = false; bool get isInitialized => _isInitialized; bool get globalTriptychMode => _globalTriptychMode; Future init() async { if (_isInitialized) return; try { final prefs = await SharedPreferences.getInstance(); _globalTriptychMode = prefs.getBool(_kGlobalTriptychKey) ?? false; final raw = prefs.getString(_kRecordsPrefsKey); if (raw != null) { final decoded = json.decode(raw) as Map; _records.clear(); for (final entry in decoded.entries) { if (entry.value is Map) { _records[entry.key] = WordOfDayRecord.fromMap( entry.value as Map, ); } } } } catch (e) { debugPrint('Error initializing WordOfTheDayService: $e'); } _isInitialized = true; notifyListeners(); } Future setGlobalTriptychMode(bool enabled) async { _globalTriptychMode = enabled; try { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kGlobalTriptychKey, enabled); } catch (_) {} notifyListeners(); } static String formatDateKey(DateTime date) { final y = date.year.toString().padLeft(4, '0'); final m = date.month.toString().padLeft(2, '0'); final d = date.day.toString().padLeft(2, '0'); return '$y-$m-$d'; } static DateTime parseDateKey(String key) { final parts = key.split('-'); if (parts.length == 3) { final y = int.tryParse(parts[0]) ?? 2026; final m = int.tryParse(parts[1]) ?? 1; final d = int.tryParse(parts[2]) ?? 1; return DateTime(y, m, d); } return DateTime.now(); } /// High-dispersion deterministic hash that maps any calendar date to a consistent word (0..N-1). /// Every device on the same date receives the exact same resonant words. static int calculateWordIndexForDate(DateTime date, int totalWords, {String phase = 'main'}) { if (totalWords <= 0) return 0; final y = date.year; final m = date.month; final d = date.day; int phaseSalt; switch (phase) { case 'morning': phaseSalt = 0x11111111; break; case 'day': phaseSalt = 0x22222222; break; case 'evening': phaseSalt = 0x33333333; break; case 'main': default: phaseSalt = 0x5A5A5A5A; break; } final rawHash = ((y * 10000 + m * 100 + d) * 2654435761) & 0x7FFFFFFF; final rng = Random(rawHash ^ phaseSalt); var index = rng.nextInt(totalWords); // Guaranteed distinctness between morning, day, and evening if (phase == 'day') { final morningIdx = calculateWordIndexForDate(date, totalWords, phase: 'morning'); if (index == morningIdx) { index = (index + 1) % totalWords; } } else if (phase == 'evening') { final morningIdx = calculateWordIndexForDate(date, totalWords, phase: 'morning'); final dayIdx = calculateWordIndexForDate(date, totalWords, phase: 'day'); while (index == morningIdx || index == dayIdx) { index = (index + 1) % totalWords; } } return index; } /// Returns the deterministic WordItem for the given date and phase ('main', 'morning', 'day', 'evening'). WordItem getWordForDate(DateTime date, {String phase = 'main'}) { final repoWords = WordsRepository().allWords; final wordsPool = repoWords.isNotEmpty ? repoWords : kRussianTop1000Words; final index = calculateWordIndexForDate(date, wordsPool.length, phase: phase); return wordsPool[index]; } /// Returns the 3 words of the day triptych (Morning, Day, Evening). ({WordItem morning, WordItem day, WordItem evening}) getTriptychWordsForDate(DateTime date) { return ( morning: getWordForDate(date, phase: 'morning'), day: getWordForDate(date, phase: 'day'), evening: getWordForDate(date, phase: 'evening'), ); } /// Retrieves the record for the given date (cached or initialized). WordOfDayRecord getRecord(DateTime date) { final key = formatDateKey(date); final mainWord = getWordForDate(date, phase: 'main'); final triptych = getTriptychWordsForDate(date); if (_records.containsKey(key)) { final existing = _records[key]!; // Ensure triptych word IDs are set if they were 0 if (existing.morningWordId == 0 || existing.dayWordId == 0 || existing.eveningWordId == 0) { return existing.copyWith( morningWordId: triptych.morning.id, dayWordId: triptych.day.id, eveningWordId: triptych.evening.id, ); } return existing; } return WordOfDayRecord( dateKey: key, wordId: mainWord.id, isRevealed: false, isStepCompleted: false, diaryNote: '', isTriptychMode: _globalTriptychMode, morningWordId: triptych.morning.id, dayWordId: triptych.day.id, eveningWordId: triptych.evening.id, isMorningRevealed: false, isDayRevealed: false, isEveningRevealed: false, isDayStepCompleted: false, ); } /// Sets the mode (1-Word vs 3-Words Triptych) for the given date record. Future setRecordTriptychMode(DateTime date, bool isTriptych) async { final key = formatDateKey(date); final current = getRecord(date); final updated = current.copyWith(isTriptychMode: isTriptych); _records[key] = updated; await _saveRecordsToPrefs(); notifyListeners(); return updated; } /// Reveals the seal for the given phase: 'main', 'morning', 'day', 'evening', or 'all'. Future revealWord(DateTime date, {String phase = 'main'}) async { final key = formatDateKey(date); final current = getRecord(date); final now = DateTime.now(); WordOfDayRecord updated; switch (phase) { case 'morning': updated = current.copyWith( isMorningRevealed: true, morningRevealedAt: current.morningRevealedAt ?? now, ); break; case 'day': updated = current.copyWith( isDayRevealed: true, dayRevealedAt: current.dayRevealedAt ?? now, ); break; case 'evening': updated = current.copyWith( isEveningRevealed: true, eveningRevealedAt: current.eveningRevealedAt ?? now, ); break; case 'all': updated = current.copyWith( isRevealed: true, isMorningRevealed: true, isDayRevealed: true, isEveningRevealed: true, revealedAt: current.revealedAt ?? now, morningRevealedAt: current.morningRevealedAt ?? now, dayRevealedAt: current.dayRevealedAt ?? now, eveningRevealedAt: current.eveningRevealedAt ?? now, ); break; case 'main': default: updated = current.copyWith( isRevealed: true, revealedAt: current.revealedAt ?? now, ); break; } _records[key] = updated; await _saveRecordsToPrefs(); notifyListeners(); return updated; } /// Toggles whether the practical step was completed. Future toggleStepCompleted( DateTime date, bool completed, { bool isDayStep = false, }) async { final key = formatDateKey(date); final current = getRecord(date); final now = DateTime.now(); final updated = isDayStep ? current.copyWith( isDayRevealed: true, isDayStepCompleted: completed, completedAt: completed ? (current.completedAt ?? now) : null, ) : current.copyWith( isRevealed: true, isStepCompleted: completed, completedAt: completed ? (current.completedAt ?? now) : null, ); _records[key] = updated; await _saveRecordsToPrefs(); notifyListeners(); return updated; } /// Saves the user's evening reflection note for this date. Future saveDiaryNote(DateTime date, String note) async { final key = formatDateKey(date); final current = getRecord(date); final updated = current.copyWith(diaryNote: note.trim()); _records[key] = updated; await _saveRecordsToPrefs(); notifyListeners(); return updated; } /// Returns all stored records ordered chronologically (newest first). List getAllRecords() { final list = _records.values.toList(); list.sort((a, b) => b.dateKey.compareTo(a.dateKey)); return List.unmodifiable(list); } /// Calculates the current streak of consecutive active days. int calculateStreak() { int streak = 0; var checkDate = DateTime.now(); final todayRecord = _records[formatDateKey(checkDate)]; if (todayRecord == null || (!todayRecord.isAnyRevealed && !todayRecord.isAnyStepCompleted)) { checkDate = checkDate.subtract(const Duration(days: 1)); } while (true) { final key = formatDateKey(checkDate); final rec = _records[key]; if (rec != null && (rec.isAnyRevealed || rec.isAnyStepCompleted)) { streak++; checkDate = checkDate.subtract(const Duration(days: 1)); } else { break; } } return streak; } int get totalCompletedPractices => _records.values.where((r) => r.isAnyStepCompleted).length; int get totalRevealedDays => _records.values.where((r) => r.isAnyRevealed).length; Future _saveRecordsToPrefs() async { try { final prefs = await SharedPreferences.getInstance(); final mapToSave = _records.map((k, v) => MapEntry(k, v.toMap())); await prefs.setString(_kRecordsPrefsKey, json.encode(mapToSave)); } catch (e) { debugPrint('Error saving WordOfTheDay records: $e'); } } }