Files
life-helper/app/lib/data/db/daos/habit_dao.dart
joungmin 8fe6a8f378 [Developer] #204 Phase 1 MVP — Flutter app skeleton complete
- Drift 21 tables (8 catalog + 11 user + habit_dose_variants + meta_kv)
  with R1~R10 CHECK constraints and 19 indexes
- 8 hand-crafted seed JSON catalogs in app/assets/seed/
  (refs 84, protocols 34, methodologies 21, frame_patterns 30,
   reward_menu_items 30, break_protocols 8, common_frames 5, diet_patterns 5)
- 6 domain functions: recommend_variant, compute_streak,
  validate_frame_level, active_habit_quota, weekly_minimum_ratio,
  seed_importer (transactional, idempotent)
- 4 vertical-slice Riverpod screens: HabitList, HabitCreate, CheckIn, Streak
- 31 unit tests passing; flutter analyze clean
- OQ-5 streak semantics: missing entry ≠ explicit blank
  (missing = end of history; only TrackerValue.blank triggers Never-miss-twice)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 10:33:03 +09:00

148 lines
4.2 KiB
Dart

import 'dart:convert';
import 'package:drift/drift.dart';
import '../../../core/id.dart';
import '../../../domain/models/habit.dart';
import '../../../domain/rules/xor_protocol.dart';
import '../app_database.dart';
import '../tables/user_tables.dart';
part 'habit_dao.g.dart';
class HabitDraft {
final String userId;
final HabitType type;
final String title;
final String? protocolId;
final String? breakProtocolId;
final FrameLevel frameLevel;
final String frameFramedText;
final String? frameOriginalText;
final String? anchorWhen;
final String? anchorAfterWhat;
final String? anchorWhere;
final String startedAt;
final String? phaseId;
final List<VariantDraft> variants;
const HabitDraft({
required this.userId,
required this.type,
required this.title,
required this.frameLevel,
required this.frameFramedText,
required this.startedAt,
this.protocolId,
this.breakProtocolId,
this.frameOriginalText,
this.anchorWhen,
this.anchorAfterWhat,
this.anchorWhere,
this.phaseId,
this.variants = const [],
});
}
class VariantDraft {
final String label;
final String doseText;
final List<String> contextTags;
final List<String> conditionTags;
final bool isMinimum;
final int sortOrder;
const VariantDraft({
required this.label,
required this.doseText,
this.contextTags = const [],
this.conditionTags = const [],
this.isMinimum = false,
this.sortOrder = 0,
});
}
@DriftAccessor(tables: [Habits, HabitDoseVariants])
class HabitDao extends DatabaseAccessor<AppDatabase> with _$HabitDaoMixin {
HabitDao(super.db);
/// Insert habit + variants atomically.
Future<String> insertWithVariants(HabitDraft draft) async {
assertXorProtocol(
type: draft.type,
protocolId: draft.protocolId,
breakProtocolId: draft.breakProtocolId,
);
final habitId = generateUlid('hb');
await transaction(() async {
await into(habits).insert(HabitsCompanion.insert(
id: habitId,
userId: draft.userId,
type: draft.type.dbValue,
status: 'active',
title: draft.title,
protocolId: Value(draft.protocolId),
breakProtocolId: Value(draft.breakProtocolId),
frameLevel: draft.frameLevel.dbValue,
frameFramedText: draft.frameFramedText,
frameOriginalText: Value(draft.frameOriginalText),
anchorWhen: Value(draft.anchorWhen),
anchorAfterWhat: Value(draft.anchorAfterWhat),
anchorWhere: Value(draft.anchorWhere),
startedAt: draft.startedAt,
phaseId: Value(draft.phaseId),
));
for (final v in draft.variants) {
await into(habitDoseVariants).insert(HabitDoseVariantsCompanion.insert(
variantId: generateUlid('dv'),
habitId: habitId,
label: v.label,
doseText: v.doseText,
contextTagsJson: Value(jsonEncode(v.contextTags)),
conditionTagsJson: Value(jsonEncode(v.conditionTags)),
isMinimum: Value(v.isMinimum),
sortOrder: Value(v.sortOrder),
));
}
});
return habitId;
}
Future<int> countActive({
required String userId,
required HabitType type,
String? excludeHabitId,
}) async {
final query = select(habits)
..where((t) => t.userId.equals(userId))
..where((t) => t.status.equals('active'))
..where((t) => t.type.equals(type.dbValue));
if (excludeHabitId != null) {
query.where((t) => t.id.isNotValue(excludeHabitId));
}
final rows = await query.get();
return rows.length;
}
Future<List<Habit>> activeHabitsForUser(String userId) {
return (select(habits)
..where((t) => t.userId.equals(userId))
..where((t) => t.status.equals('active')))
.get();
}
Future<List<HabitDoseVariant>> variantsForHabit(String habitId) {
return (select(habitDoseVariants)
..where((t) => t.habitId.equals(habitId))
..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
.get();
}
Future<List<HabitDoseVariant>> variantsByIds(Set<String> ids) {
if (ids.isEmpty) return Future.value(const []);
return (select(habitDoseVariants)
..where((t) => t.variantId.isIn(ids)))
.get();
}
}