324 lines
10 KiB
Dart
324 lines
10 KiB
Dart
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');
|
|
}
|
|
}
|
|
}
|