app/lib/storage/local_storage.dart
John Mizerek 498a1534ed Add prompt to continue from most recent saved run
When tapping Start with existing saved runs, shows dialog asking
whether to continue from last run or start fresh. Continuing pre-fills
all profile and behavior inputs from the saved run.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-21 20:49:37 -08:00

310 lines
8.7 KiB
Dart

import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:uuid/uuid.dart';
import '../models/models.dart';
class LocalStorage {
static const _dbName = 'add_months.db';
static const _tableName = 'user_data';
static const _savedRunsTable = 'saved_runs';
static const _version = 2;
static Database? _database;
static const _uuid = Uuid();
static Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
static Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, _dbName);
return await openDatabase(
path,
version: _version,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE $_tableName (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
''');
await db.execute('''
CREATE TABLE $_savedRunsTable (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
result TEXT NOT NULL,
profile TEXT NOT NULL,
behaviors TEXT NOT NULL,
created_at INTEGER NOT NULL
)
''');
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('''
CREATE TABLE $_savedRunsTable (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
result TEXT NOT NULL,
profile TEXT NOT NULL,
behaviors TEXT NOT NULL,
created_at INTEGER NOT NULL
)
''');
}
},
);
}
// Generic key-value operations
static Future<void> _put(String key, Map<String, dynamic> value) async {
final db = await database;
await db.insert(
_tableName,
{
'key': key,
'value': jsonEncode(value),
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
static Future<Map<String, dynamic>?> _get(String key) async {
final db = await database;
final results = await db.query(
_tableName,
where: 'key = ?',
whereArgs: [key],
);
if (results.isEmpty) return null;
return jsonDecode(results.first['value'] as String) as Map<String, dynamic>;
}
// Profile operations
static Future<void> saveProfile(UserProfile profile) async {
await _put('profile', profile.toJson());
}
static Future<UserProfile?> getProfile() async {
final json = await _get('profile');
if (json == null) return null;
return UserProfile.fromJson(json);
}
// Behavioral inputs operations
static Future<void> saveBehaviors(BehavioralInputs behaviors) async {
await _put('behaviors', behaviors.toJson());
}
static Future<BehavioralInputs?> getBehaviors() async {
final json = await _get('behaviors');
if (json == null) return null;
return BehavioralInputs.fromJson(json);
}
// Result operations
static Future<void> saveResult(CalculationResult result) async {
await _put('lastResult', result.toJson());
}
static Future<CalculationResult?> getLastResult() async {
final json = await _get('lastResult');
if (json == null) return null;
return CalculationResult.fromJson(json);
}
// Check if user has completed setup
static Future<bool> hasCompletedSetup() async {
final profile = await getProfile();
final behaviors = await getBehaviors();
return profile != null && behaviors != null;
}
// Delete all data (including saved runs)
static Future<void> deleteAllData() async {
final db = await database;
await db.delete(_tableName);
await db.delete(_savedRunsTable);
}
// Get last updated timestamp
static Future<DateTime?> getLastUpdated() async {
final db = await database;
final results = await db.query(
_tableName,
columns: ['updated_at'],
orderBy: 'updated_at DESC',
limit: 1,
);
if (results.isEmpty) return null;
return DateTime.fromMillisecondsSinceEpoch(
results.first['updated_at'] as int,
);
}
// ============================================
// Saved Runs operations
// ============================================
static Future<String> saveSavedRun({
required String label,
required CalculationResult result,
required UserProfile profile,
required BehavioralInputs behaviors,
}) async {
final db = await database;
final id = _uuid.v4();
final now = DateTime.now();
await db.insert(_savedRunsTable, {
'id': id,
'label': label,
'result': jsonEncode(result.toJson()),
'profile': jsonEncode(profile.toJson()),
'behaviors': jsonEncode(behaviors.toJson()),
'created_at': now.millisecondsSinceEpoch,
});
return id;
}
static Future<List<SavedRun>> getSavedRuns() async {
final db = await database;
final results = await db.query(
_savedRunsTable,
orderBy: 'created_at DESC',
);
return results.map((row) => SavedRun(
id: row['id'] as String,
label: row['label'] as String,
result: CalculationResult.fromJson(
jsonDecode(row['result'] as String) as Map<String, dynamic>,
),
profile: UserProfile.fromJson(
jsonDecode(row['profile'] as String) as Map<String, dynamic>,
),
behaviors: BehavioralInputs.fromJson(
jsonDecode(row['behaviors'] as String) as Map<String, dynamic>,
),
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int),
)).toList();
}
static Future<SavedRun?> getSavedRun(String id) async {
final db = await database;
final results = await db.query(
_savedRunsTable,
where: 'id = ?',
whereArgs: [id],
);
if (results.isEmpty) return null;
final row = results.first;
return SavedRun(
id: row['id'] as String,
label: row['label'] as String,
result: CalculationResult.fromJson(
jsonDecode(row['result'] as String) as Map<String, dynamic>,
),
profile: UserProfile.fromJson(
jsonDecode(row['profile'] as String) as Map<String, dynamic>,
),
behaviors: BehavioralInputs.fromJson(
jsonDecode(row['behaviors'] as String) as Map<String, dynamic>,
),
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int),
);
}
static Future<void> updateSavedRunLabel(String id, String newLabel) async {
final db = await database;
await db.update(
_savedRunsTable,
{'label': newLabel},
where: 'id = ?',
whereArgs: [id],
);
}
static Future<void> deleteSavedRun(String id) async {
final db = await database;
await db.delete(
_savedRunsTable,
where: 'id = ?',
whereArgs: [id],
);
}
static Future<void> deleteAllSavedRuns() async {
final db = await database;
await db.delete(_savedRunsTable);
}
static Future<int> getSavedRunsCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM $_savedRunsTable');
return result.first['count'] as int;
}
static Future<SavedRun?> getMostRecentSavedRun() async {
final db = await database;
final results = await db.query(
_savedRunsTable,
orderBy: 'created_at DESC',
limit: 1,
);
if (results.isEmpty) return null;
final row = results.first;
return SavedRun(
id: row['id'] as String,
label: row['label'] as String,
result: CalculationResult.fromJson(
jsonDecode(row['result'] as String) as Map<String, dynamic>,
),
profile: UserProfile.fromJson(
jsonDecode(row['profile'] as String) as Map<String, dynamic>,
),
behaviors: BehavioralInputs.fromJson(
jsonDecode(row['behaviors'] as String) as Map<String, dynamic>,
),
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int),
);
}
// ============================================
// Unit preference operations
// ============================================
static Future<void> setUseMetricUnits(bool useMetric) async {
await _put('useMetricUnits', {'value': useMetric});
}
static Future<bool> getUseMetricUnits() async {
final json = await _get('useMetricUnits');
if (json == null) return false; // Default to imperial (US)
return json['value'] as bool;
}
static Future<void> setSkipForever(bool skip) async {
await _put('skipForever', {'value': skip});
}
static Future<bool> getSkipForever() async {
final json = await _get('skipForever');
if (json == null) return false;
return json['value'] as bool;
}
}