[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>
This commit is contained in:
163
app/lib/data/db/tables/catalog_tables.dart
Normal file
163
app/lib/data/db/tables/catalog_tables.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
// 8 catalog tables, read-only. Source: schema/*.schema.json.
|
||||
// Nested objects + arrays stored as JSON TEXT for read-only simplicity.
|
||||
|
||||
class Protocols extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get category => text().check(const CustomExpression<bool>(
|
||||
"category IN ('health','meditation','motivation','habit','learning','diet')"))();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get titleEn => text().nullable()();
|
||||
TextColumn get what => text()();
|
||||
TextColumn get whenText => text().named('when_text')();
|
||||
TextColumn get dose => text()();
|
||||
TextColumn get why => text()();
|
||||
TextColumn get howJson => text().named('how_json')();
|
||||
TextColumn get checkText => text().named('check_text')();
|
||||
TextColumn get caution => text().nullable()();
|
||||
TextColumn get defaultAnchorJson => text().named('default_anchor_json').nullable()();
|
||||
TextColumn get minDoseForStart => text().named('min_dose_for_start').nullable()();
|
||||
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
|
||||
TextColumn get evidenceStrength => text().named('evidence_strength').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')"))();
|
||||
TextColumn get sourceDoc => text().named('source_doc').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"source_doc IS NULL OR source_doc IN ('huberman-protocols.md','diet-protocols.md')"))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class BreakProtocols extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get category => text().check(const CustomExpression<bool>(
|
||||
"category IN ('alcohol','nicotine','porn_masturbation','social_media','sugar','caffeine','cannabis','behavioral')"))();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get hubermanSummary => text().named('huberman_summary')();
|
||||
TextColumn get frameExamplesJson => text().named('frame_examples_json').nullable()();
|
||||
TextColumn get phasesJson => text().named('phases_json')();
|
||||
TextColumn get defaultCommonFramesJson => text().named('default_common_frames_json')();
|
||||
TextColumn get toolsJson => text().named('tools_json').nullable()();
|
||||
TextColumn get medicalWarning => text().named('medical_warning').nullable()();
|
||||
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CommonFrames extends Table {
|
||||
TextColumn get id => text().check(const CustomExpression<bool>(
|
||||
"id IN ('dopamine_reset','urge_surf','environment_design','relapse_recovery','recovery_stack')"))();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get what => text()();
|
||||
TextColumn get why => text()();
|
||||
TextColumn get dose => text().nullable()();
|
||||
TextColumn get howJson => text().named('how_json').nullable()();
|
||||
TextColumn get checkText => text().named('check_text')();
|
||||
TextColumn get applicableBreakCategoriesJson =>
|
||||
text().named('applicable_break_categories_json').nullable()();
|
||||
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class Methodologies extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get originator => text()();
|
||||
TextColumn get oneLineDefinition => text().named('one_line_definition')();
|
||||
TextColumn get coreUnit => text().named('core_unit')();
|
||||
TextColumn get procedureJson => text().named('procedure_json').nullable()();
|
||||
TextColumn get toolsJson => text().named('tools_json').nullable()();
|
||||
TextColumn get strengthsJson => text().named('strengths_json').nullable()();
|
||||
TextColumn get weaknessesJson => text().named('weaknesses_json').nullable()();
|
||||
TextColumn get goodFor => text().named('good_for').nullable()();
|
||||
IntColumn get hubermanFitScore => integer().named('huberman_fit_score').check(
|
||||
const CustomExpression<bool>("huberman_fit_score BETWEEN 1 AND 5"))();
|
||||
BoolColumn get isCoreEngine =>
|
||||
boolean().named('is_core_engine').withDefault(const Constant(false))();
|
||||
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class FramePatterns extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get domain => text().nullable().check(const CustomExpression<bool>(
|
||||
"domain IS NULL OR domain IN ('food','drink','smoking','screen','porn','sleep','exercise','general')"))();
|
||||
TextColumn get avoidanceKeyword => text().named('avoidance_keyword')();
|
||||
TextColumn get l0Example => text().named('l0_example')();
|
||||
TextColumn get l1SimpleReplace => text().named('l1_simple_replace').nullable()();
|
||||
TextColumn get l2Suggestion => text().named('l2_suggestion')();
|
||||
TextColumn get l3Identity => text().named('l3_identity').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class RewardMenuItems extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get tierRecommended => text().named('tier_recommended').check(
|
||||
const CustomExpression<bool>("tier_recommended IN ('T0','T1','T2','T3','T4')"))();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
IntColumn get estimatedCostKrwMin => integer().named('estimated_cost_krw_min').nullable()();
|
||||
IntColumn get estimatedCostKrwMax => integer().named('estimated_cost_krw_max').nullable()();
|
||||
BoolColumn get isEffortTied => boolean().named('is_effort_tied').nullable()();
|
||||
TextColumn get tagsJson => text().named('tags_json').nullable()();
|
||||
TextColumn get avoidForBreakHabitsJson =>
|
||||
text().named('avoid_for_break_habits_json').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
@DataClassName('ReferenceRow')
|
||||
class References extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get kind => text().check(const CustomExpression<bool>(
|
||||
"kind IN ('paper','podcast_episode','book','url','korean_explainer')"))();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get authorsJson => text().named('authors_json').nullable()();
|
||||
IntColumn get year => integer().nullable().check(
|
||||
const CustomExpression<bool>("year IS NULL OR (year BETWEEN 1900 AND 2100)"))();
|
||||
TextColumn get journal => text().nullable()();
|
||||
TextColumn get doi => text().nullable()();
|
||||
TextColumn get url => text().nullable()();
|
||||
IntColumn get episodeNumber => integer().named('episode_number').nullable()();
|
||||
TextColumn get publisher => text().nullable()();
|
||||
TextColumn get evidenceStrength => text().named('evidence_strength').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')"))();
|
||||
BoolColumn get verified => boolean().nullable()();
|
||||
TextColumn get note => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class DietPatterns extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get core => text()();
|
||||
TextColumn get strengthsJson => text().named('strengths_json').nullable()();
|
||||
TextColumn get weaknessesJson => text().named('weaknesses_json').nullable()();
|
||||
TextColumn get evidenceStrength => text().named('evidence_strength').check(
|
||||
const CustomExpression<bool>(
|
||||
"evidence_strength IN ('strong','moderate','mixed','weak')"))();
|
||||
TextColumn get koreanContextFit => text().named('korean_context_fit').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"korean_context_fit IS NULL OR korean_context_fit IN ('high','medium','low')"))();
|
||||
TextColumn get starterLeversJson => text().named('starter_levers_json').nullable()();
|
||||
TextColumn get medicalWarning => text().named('medical_warning').nullable()();
|
||||
TextColumn get linkedProtocolIdsJson =>
|
||||
text().named('linked_protocol_ids_json').nullable()();
|
||||
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
265
app/lib/data/db/tables/user_tables.dart
Normal file
265
app/lib/data/db/tables/user_tables.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
// 11 user-data tables + habit_dose_variants (normalized child per ADR-0002).
|
||||
|
||||
class Users extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get displayName => text().named('display_name').nullable()();
|
||||
TextColumn get locale => text().withDefault(const Constant('ko-KR'))();
|
||||
TextColumn get timezone => text().withDefault(const Constant('Asia/Seoul'))();
|
||||
TextColumn get createdAt => text().named('created_at')();
|
||||
TextColumn get preferencesJson => text().named('preferences_json').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class Phases extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId =>
|
||||
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
|
||||
TextColumn get title => text().nullable()();
|
||||
TextColumn get startedAt => text().named('started_at')();
|
||||
TextColumn get endedAt => text().named('ended_at').nullable()();
|
||||
IntColumn get durationWeeks => integer()
|
||||
.named('duration_weeks')
|
||||
.withDefault(const Constant(6))
|
||||
.check(const CustomExpression<bool>("duration_weeks >= 1"))();
|
||||
TextColumn get status => text().check(const CustomExpression<bool>(
|
||||
"status IN ('active','completed','abandoned')"))();
|
||||
TextColumn get intentionText => text().named('intention_text').nullable()();
|
||||
BoolColumn get rewardDeclarationsLocked => boolean()
|
||||
.named('reward_declarations_locked')
|
||||
.withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class Habits extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId =>
|
||||
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
|
||||
TextColumn get phaseId => text()
|
||||
.named('phase_id')
|
||||
.nullable()
|
||||
.customConstraint('NULL REFERENCES phases(id) ON DELETE SET NULL')();
|
||||
TextColumn get type =>
|
||||
text().check(const CustomExpression<bool>("type IN ('build','break')"))();
|
||||
TextColumn get status => text().check(const CustomExpression<bool>(
|
||||
"status IN ('active','paused','completed','abandoned')"))();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get protocolId => text()
|
||||
.named('protocol_id')
|
||||
.nullable()
|
||||
.customConstraint('NULL REFERENCES protocols(id)')();
|
||||
TextColumn get breakProtocolId => text()
|
||||
.named('break_protocol_id')
|
||||
.nullable()
|
||||
.customConstraint('NULL REFERENCES break_protocols(id)')();
|
||||
TextColumn get commonFrameIdsJson =>
|
||||
text().named('common_frame_ids_json').nullable()();
|
||||
TextColumn get frameLevel => text().named('frame_level').check(
|
||||
const CustomExpression<bool>("frame_level IN ('L2','L3')"))(); // R3
|
||||
TextColumn get frameOriginalText => text().named('frame_original_text').nullable()();
|
||||
TextColumn get frameFramedText => text().named('frame_framed_text')();
|
||||
TextColumn get anchorWhen => text().named('anchor_when').nullable()();
|
||||
TextColumn get anchorAfterWhat => text().named('anchor_after_what').nullable()();
|
||||
TextColumn get anchorWhere => text().named('anchor_where').nullable()();
|
||||
IntColumn get stackPosition => integer().named('stack_position').nullable().check(
|
||||
const CustomExpression<bool>("stack_position IS NULL OR stack_position >= 1"))();
|
||||
TextColumn get minDose => text().named('min_dose').nullable()();
|
||||
TextColumn get targetDose => text().named('target_dose').nullable()();
|
||||
TextColumn get startedAt => text().named('started_at')();
|
||||
TextColumn get endedAt => text().named('ended_at').nullable()();
|
||||
TextColumn get tagsJson => text().named('tags_json').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
// XOR: build → protocol_id, break → break_protocol_id
|
||||
"CHECK ((type = 'build' AND protocol_id IS NOT NULL AND break_protocol_id IS NULL) "
|
||||
"OR (type = 'break' AND break_protocol_id IS NOT NULL AND protocol_id IS NULL))",
|
||||
];
|
||||
}
|
||||
|
||||
class HabitDoseVariants extends Table {
|
||||
TextColumn get variantId => text().named('variant_id')();
|
||||
TextColumn get habitId => text()
|
||||
.named('habit_id')
|
||||
.customConstraint('REFERENCES habits(id) ON DELETE CASCADE NOT NULL')();
|
||||
TextColumn get label => text()();
|
||||
TextColumn get doseText => text().named('dose_text')();
|
||||
TextColumn get contextTagsJson => text().named('context_tags_json').nullable()();
|
||||
TextColumn get conditionTagsJson => text().named('condition_tags_json').nullable()();
|
||||
BoolColumn get isMinimum =>
|
||||
boolean().named('is_minimum').withDefault(const Constant(false))();
|
||||
IntColumn get sortOrder =>
|
||||
integer().named('sort_order').withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {variantId};
|
||||
}
|
||||
|
||||
class IfThenRules extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text()
|
||||
.named('habit_id')
|
||||
.customConstraint('REFERENCES habits(id) ON DELETE CASCADE NOT NULL')();
|
||||
TextColumn get ifCondition => text().named('if_condition')();
|
||||
TextColumn get thenAction => text().named('then_action')();
|
||||
TextColumn get triggerType => text().named('trigger_type').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"trigger_type IS NULL OR trigger_type IN ('time','location','emotion','preceding_action','urge')"))();
|
||||
IntColumn get priority => integer()
|
||||
.withDefault(const Constant(1))
|
||||
.check(const CustomExpression<bool>("priority BETWEEN 1 AND 3"))();
|
||||
TextColumn get createdAt => text().named('created_at').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class TrackerEntries extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text()
|
||||
.named('habit_id')
|
||||
.customConstraint('REFERENCES habits(id) ON DELETE RESTRICT NOT NULL')();
|
||||
TextColumn get date => text()();
|
||||
TextColumn get value =>
|
||||
text().check(const CustomExpression<bool>("value IN ('done','blank')"))(); // R5
|
||||
TextColumn get loggedAt => text().named('logged_at').nullable()();
|
||||
TextColumn get note => text().nullable().check(
|
||||
const CustomExpression<bool>("note IS NULL OR length(note) <= 200"))();
|
||||
TextColumn get variantId => text()
|
||||
.named('variant_id')
|
||||
.nullable()
|
||||
.customConstraint(
|
||||
'NULL REFERENCES habit_dose_variants(variant_id) ON DELETE SET NULL')();
|
||||
TextColumn get ctxLocation => text().named('ctx_location').nullable()();
|
||||
TextColumn get ctxCondition => text().named('ctx_condition').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class LapseLogs extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text()
|
||||
.named('habit_id')
|
||||
.customConstraint('REFERENCES habits(id) NOT NULL')();
|
||||
TextColumn get date => text()();
|
||||
TextColumn get labelText => text().named('label_text')();
|
||||
TextColumn get examineHaltJson => text().named('examine_halt_json')();
|
||||
TextColumn get antecedentJson => text().named('antecedent_json')();
|
||||
TextColumn get replan => text()();
|
||||
TextColumn get nextAction => text().named('next_action')();
|
||||
TextColumn get createdAt => text().named('created_at').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class UrgeLogs extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text()
|
||||
.named('habit_id')
|
||||
.customConstraint('REFERENCES habits(id) NOT NULL')();
|
||||
TextColumn get occurredAt => text().named('occurred_at')();
|
||||
IntColumn get intensityBefore => integer().named('intensity_before').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"intensity_before IS NULL OR intensity_before BETWEEN 0 AND 10"))();
|
||||
IntColumn get intensityAfter => integer().named('intensity_after').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"intensity_after IS NULL OR intensity_after BETWEEN 0 AND 10"))();
|
||||
IntColumn get durationSeconds => integer().named('duration_seconds').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"duration_seconds IS NULL OR duration_seconds >= 0"))();
|
||||
TextColumn get bodyLocationJson => text().named('body_location_json').nullable()();
|
||||
BoolColumn get passed => boolean()();
|
||||
TextColumn get methodUsed => text().named('method_used').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"method_used IS NULL OR method_used IN ('cyclic_sighing','walk','water','social_contact','if_then_action','other')"))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class RewardDeclarations extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get phaseId => text()
|
||||
.named('phase_id')
|
||||
.customConstraint('REFERENCES phases(id) NOT NULL')();
|
||||
TextColumn get habitId => text()
|
||||
.named('habit_id')
|
||||
.customConstraint('REFERENCES habits(id) NOT NULL')();
|
||||
TextColumn get tier => text().check(
|
||||
const CustomExpression<bool>("tier IN ('T0','T1','T2','T3','T4')"))();
|
||||
TextColumn get milestoneRule => text().named('milestone_rule')();
|
||||
TextColumn get milestoneMachineRuleJson =>
|
||||
text().named('milestone_machine_rule_json').nullable()();
|
||||
TextColumn get rewardText => text().named('reward_text')();
|
||||
TextColumn get rewardMenuItemId => text()
|
||||
.named('reward_menu_item_id')
|
||||
.nullable()
|
||||
.customConstraint('NULL REFERENCES reward_menu_items(id)')();
|
||||
IntColumn get estimatedCostKrw => integer().named('estimated_cost_krw').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"estimated_cost_krw IS NULL OR estimated_cost_krw >= 0"))();
|
||||
BoolColumn get isEffortTied => boolean().named('is_effort_tied').nullable()();
|
||||
TextColumn get declaredAt => text().named('declared_at')();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class RewardClaims extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get declarationId => text()
|
||||
.named('declaration_id')
|
||||
.customConstraint('REFERENCES reward_declarations(id) NOT NULL')();
|
||||
TextColumn get milestoneReachedAt => text().named('milestone_reached_at')();
|
||||
BoolColumn get fulfilled => boolean()();
|
||||
TextColumn get fulfilledAt => text().named('fulfilled_at').nullable()();
|
||||
TextColumn get reflection => text().nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"reflection IS NULL OR length(reflection) <= 500"))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class Reflections extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId =>
|
||||
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
|
||||
TextColumn get scope => text().check(const CustomExpression<bool>(
|
||||
"scope IN ('weekly','monthly','phase_end')"))();
|
||||
TextColumn get periodStart => text().named('period_start')();
|
||||
TextColumn get periodEnd => text().named('period_end')();
|
||||
TextColumn get phaseId => text()
|
||||
.named('phase_id')
|
||||
.nullable()
|
||||
.customConstraint('NULL REFERENCES phases(id) ON DELETE SET NULL')();
|
||||
TextColumn get kept => text().nullable()();
|
||||
TextColumn get missed => text().nullable()();
|
||||
TextColumn get adjust => text().nullable()();
|
||||
TextColumn get identityNote => text().named('identity_note').nullable()();
|
||||
RealColumn get minimumRatio => real().named('minimum_ratio').nullable().check(
|
||||
const CustomExpression<bool>(
|
||||
"minimum_ratio IS NULL OR (minimum_ratio BETWEEN 0 AND 1)"))();
|
||||
TextColumn get createdAt => text().named('created_at').nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class MetaKv extends Table {
|
||||
TextColumn get key => text()();
|
||||
TextColumn get value => text()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {key};
|
||||
}
|
||||
Reference in New Issue
Block a user