feat: complete academic symbol lexicon heal, 2-column Cooper extraction, and updated assets
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user