feat: complete academic symbol lexicon heal, 2-column Cooper extraction, and updated assets

This commit is contained in:
2026-10-09 15:26:58 +05:00
parent 7f91366201
commit 0b12e4583b
244 changed files with 611461 additions and 1617 deletions
+323
View File
@@ -0,0 +1,323 @@
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<String, WordOfDayRecord> _records = {};
bool _isInitialized = false;
bool _globalTriptychMode = false;
bool get isInitialized => _isInitialized;
bool get globalTriptychMode => _globalTriptychMode;
Future<void> 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<String, dynamic>;
_records.clear();
for (final entry in decoded.entries) {
if (entry.value is Map<String, dynamic>) {
_records[entry.key] = WordOfDayRecord.fromMap(
entry.value as Map<String, dynamic>,
);
}
}
}
} catch (e) {
debugPrint('Error initializing WordOfTheDayService: $e');
}
_isInitialized = true;
notifyListeners();
}
Future<void> 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<WordOfDayRecord> 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<WordOfDayRecord> 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<WordOfDayRecord> 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<WordOfDayRecord> 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<WordOfDayRecord> 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<void> _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');
}
}
}