commit dd160f84517ad39cd066d659c1d2f0d1d31bc95c Author: Johan Jongsma Date: Sun Feb 1 08:03:12 2026 +0000 Initial commit from dev diff --git a/._.DS_Store b/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/._.DS_Store differ diff --git a/._inou.db b/._inou.db new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._inou.db differ diff --git a/._start.sh b/._start.sh new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._start.sh differ diff --git a/._status.sh b/._status.sh new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._status.sh differ diff --git a/._stop.sh b/._stop.sh new file mode 100644 index 0000000..31c22c1 Binary files /dev/null and b/._stop.sh differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afa1ec8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +*.log +.DS_Store +api/api + diff --git a/api/prompts/consultation.md b/api/prompts/consultation.md new file mode 100644 index 0000000..7babf7b --- /dev/null +++ b/api/prompts/consultation.md @@ -0,0 +1,59 @@ +# Consultation Prompt + +Handle user input related to medical consultations, appointments, and follow-ups. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} +Category: {{CATEGORY}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +For consultations, use `input_config.fields`. If creating a record, use the `entries` array. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "snake_case_identifier", + "input_type": "form|checkbox", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": {"fields": [{"key": "...", "type": "...", "label": "..."}]}, + "entries": [{"value": "...", "data": {...}}] +} +``` + +## Rules +- Create a `schedule` for future appointments mentioned with a day or date. Default time is "09:00". +- Create a follow-up `question` for the scheduled appointment. +- A simple past event with no follow-up should have `schedule: null`. +- `question` and UI labels must be in {{LANGUAGE}}. +- If the user provides data for a new event, populate the `entries` array with a single entry. + +## Examples + +Input: "I went to the dentist" +Language: en +Category: consultation +```json +{ + "question": null, + "type": "dentist_visit", + "input_type": null, + "schedule": null, + "input_config": null, + "entries": [{"value": "Dentist visit", "data": {"provider": "dentist"}}] +} +``` + +Input: "I have an appointment with my PCP for Friday" +Language: en +Category: consultation +```json +{ + "question": "Did you attend your PCP appointment today?", + "type": "pcp_appointment", + "input_type": "checkbox", + "schedule": [{"days": ["fri"], "time": "09:00"}], + "input_config": {"fields": [{"key": "completed", "type": "checkbox", "label": "Yes"}]}, + "entries": [{"value": "PCP Appointment", "data": {"provider": "PCP", "date": "Friday"}}] +} +``` \ No newline at end of file diff --git a/api/prompts/default.md b/api/prompts/default.md new file mode 100644 index 0000000..ffd7c23 --- /dev/null +++ b/api/prompts/default.md @@ -0,0 +1,55 @@ +# Default Prompt + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} +Category: {{CATEGORY}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +For complex activities, use `input_config.groups`. For simple forms, use `input_config.fields`. +If creating a record, use the `entries` array. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "snake_case_identifier", + "input_type": "form|checkbox", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": { + "fields": [{"key": "...", "type": "...", "label": "..."}], + "groups": [{"title": "...", "fields": [{"key": "...", "type": "...", "label": "..."}]}] + }, + "entries": [{"value": "...", "data": {...}}] +} +``` + +## Rules +- If the user provides data for a new event, populate the `entries` array. +- If the input contains multiple distinct items, create a separate object for each in the `entries` array. +- `schedule` should be `null` for one-time past events with no follow-up. +- `question` and all UI labels must be in {{LANGUAGE}}. + +## Example (Supplement) + +Input: "I take 2 capsules of Vit D and 500mg of Vit C everyday" +Language: en +Category: supplement +```json +{ + "question": "Did you take your supplements today?", + "category": "supplement", + "type": "daily_supplements", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": { + "fields": [ + {"key": "vitamin_d", "label": "Vitamin D", "type": "checkbox"}, + {"key": "vitamin_c", "label": "Vitamin C", "type": "checkbox"} + ] + }, + "entries": [ + {"value": "2 capsules Vit D", "data": {"supplement": "vitamin d", "amount": 2, "unit": "capsules"}}, + {"value": "500mg Vit C", "data": {"supplement": "vitamin c", "amount": 500, "unit": "mg"}} + ] +} +``` \ No newline at end of file diff --git a/api/prompts/exercise.md b/api/prompts/exercise.md new file mode 100644 index 0000000..60bd5e2 --- /dev/null +++ b/api/prompts/exercise.md @@ -0,0 +1,105 @@ +# Exercise Prompt + +Extract exercise/activity details. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +For complex activities with multiple parts, use the `input_config.groups` structure to group related fields. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "exercise_type", + "input_type": "form", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": { + "groups": [ + { + "title": "Group Title", + "fields": [{"key": "...", "type": "...", "label": "..."}] + } + ] + }, + "entries": [{"value": "...", "data": {...}}] +} +``` + +## Schedule format +- `days`: array of "mon", "tue", "wed", "thu", "fri", "sat", "sun" +- `time`: 24h format "08:00", "12:00", "18:00" etc. +- Daily = all 7 days + +## Standard fields +- `duration`: number (minutes) +- `distance`: number (km, miles) +- `intensity`: select [Light, Moderate, Vigorous] + +## Rules +- If the activity has multiple distinct parts (e.g. left and right leg), create a `group` in `input_config` for each part. +- `entries` should be used for recording initial data. If multiple parts, create multiple entries. + +## Examples + +Input: "walked 30 minutes" +```json +{ + "question": "Did you walk today?", + "type": "walk", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": {"fields": [{"key": "duration", "type": "number", "label": "Duration", "unit": "min"}]}, + "entries": [{"value": "30 min walk", "data": {"duration": 30, "unit": "min"}}] +} +``` + +Input: "For my daughter, we have a leg trainer exercise we do twice a day. The right leg is for 20 minutes at 40 degrees and speed 4. The left leg is for 20 minutes at 30 degrees and speed 4." +```json +{ + "question": "Did you do the leg trainer exercises today?", + "category": "exercise", + "type": "leg_trainer", + "input_type": "form", + "schedule": [ + {"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "09:00"}, + {"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "17:00"} + ], + "input_config": { + "groups": [ + { + "title": "Right Leg", + "fields": [ + { "key": "right_leg_duration", "type": "number", "label": "Duration (min)" }, + { "key": "right_leg_angle", "type": "number", "label": "Angle (°)" }, + { "key": "right_leg_speed", "type": "number", "label": "Speed" } + ] + }, + { + "title": "Left Leg", + "fields": [ + { "key": "left_leg_duration", "type": "number", "label": "Duration (min)" }, + { "key": "left_leg_angle", "type": "number", "label": "Angle (°)" }, + { "key": "left_leg_speed", "type": "number", "label": "Speed" } + ] + }, + { + "title": "Notes", + "fields": [ + { "key": "notes", "type": "text", "label": "Session Notes" } + ] + } + ] + }, + "entries": [ + { + "value": "Leg trainer session", + "data": { + "right_leg": { "duration_min": 20, "degrees": 40, "speed": 4 }, + "left_leg": { "duration_min": 20, "degrees": 30, "speed": 4 } + } + } + ] +} +``` \ No newline at end of file diff --git a/api/prompts/family_history.md b/api/prompts/family_history.md new file mode 100644 index 0000000..ef90544 --- /dev/null +++ b/api/prompts/family_history.md @@ -0,0 +1,111 @@ +# Family History Prompt + +Extract details about a relative's health condition. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "confirmation question in {{LANGUAGE}}", + "type": "relation_type (mother, father, etc)", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [...]}, + "entry": {"value": "human readable", "data": {...}} +} +``` + +## Schedule + +Always `null` for family history — these are one-time records, not recurring prompts. + +## Type mapping (by relation) + +| Relation | type | +|----------|------| +| mother, mom | mother | +| father, dad | father | +| grandmother, grandma | grandmother | +| grandfather, grandpa | grandfather | +| sister | sister | +| brother | brother | +| aunt | aunt | +| uncle | uncle | +| son | son | +| daughter | daughter | +| spouse, husband, wife | spouse | + +## Entry data structure + +```json +{ + "relation": "mother", + "relation_detail": "maternal grandmother", + "condition": "breast cancer", + "age_at_diagnosis": 58, + "outcome": "survived", + "treatment": "mastectomy + chemo", + "current_status": "in remission", + "notes": "..." +} +``` + +Include only fields that can be inferred from input. + +## Rules + +- entry.data must be a JSON OBJECT, not a string +- schedule is always null (no recurring prompt) + +## Examples + +Input: "My mother had breast cancer at 58, survived" +```json +{ + "question": "Mother's breast cancer recorded. Is she still in remission?", + "type": "mother", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [{"key": "current_status", "type": "select", "label": "Current status", "options": ["In remission", "Cancer-free", "Recurrence", "Deceased"]}]}, + "entry": {"value": "Mother: breast cancer at 58, survived", "data": {"relation": "mother", "condition": "breast cancer", "age_at_diagnosis": 58, "outcome": "survived"}} +} +``` + +Input: "Father has type 2 diabetes" +```json +{ + "question": "Father's diabetes recorded. How is it managed?", + "type": "father", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [{"key": "management", "type": "select", "label": "Management", "options": ["Diet controlled", "Oral medication", "Insulin", "Unknown"]}]}, + "entry": {"value": "Father: type 2 diabetes", "data": {"relation": "father", "condition": "type 2 diabetes"}} +} +``` + +Input: "Maternal grandmother had Alzheimer's" +```json +{ + "question": "Grandmother's Alzheimer's recorded. Age of onset?", + "type": "grandmother", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [{"key": "age_onset", "type": "number", "label": "Age of onset"}, {"key": "outcome", "type": "select", "label": "Outcome", "options": ["Living with condition", "Deceased"]}]}, + "entry": {"value": "Maternal grandmother: Alzheimer's", "data": {"relation": "grandmother", "relation_detail": "maternal", "condition": "Alzheimer's"}} +} +``` + +Input: "Brother is healthy" +```json +{ + "question": "Brother recorded as healthy.", + "type": "brother", + "input_type": "form", + "schedule": null, + "input_config": {"fields": []}, + "entry": {"value": "Brother: healthy", "data": {"relation": "brother", "condition": "healthy"}} +} +``` diff --git a/api/prompts/fertility.md b/api/prompts/fertility.md new file mode 100644 index 0000000..ebc2b55 --- /dev/null +++ b/api/prompts/fertility.md @@ -0,0 +1,113 @@ +# Fertility Prompt + +Extract menstrual/fertility cycle details. Also handles new pregnancy announcements. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} +Existing Types: {{EXISTING_TYPES}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "fertility_type", + "input_type": "form|checkbox", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": {"fields": [...]}, + "entries": [{"value": "...", "data": {...}}] +} +``` + +## Special Handling for Pregnancy +If the user announces a pregnancy (e.g., "I'm pregnant"), do NOT create an `entries` array immediately. The goal is to ask a follow-up question to get more details. +- Set `schedule` to `null`. +- Set `question` to ask for the estimated due date. +- The `input_config` should be a form with a single `date` field. + +## Handling Contradictions (e.g., Period during Pregnancy) +If the user reports a period and "pregnancy" is listed in the `Existing Types`, respond with care and offer gentle options. +- Set `question` to "I'm so sorry, I know this can be a difficult time. I've noted you're tracking a pregnancy and have logged a new period. How would you like me to update your status?". +- Set `category` to `fertility`. +- Set `type` to `pregnancy_inconsistency`. +- Set `input_type` to `form`. +- Set `input_config` to offer choices to update pregnancy status. +- Set `schedule` to `null`. +- `entries` should be null. + +## Schedule format +- Period/cycle tracking is typically daily. +- A new pregnancy announcement is a one-time event (`schedule: null`). + +## Fertility types +period, menstruation, ovulation, cycle, fertility, pms, cramps, pregnancy + +## Standard fields +- flow: select [Spotting, Light, Medium, Heavy] +- pain: scale 0-5 +- mood: select [Good, Okay, Low, Anxious, Irritable] + +## Examples + +Input: "My period started today" +Existing Types: (none yet) +```json +{ + "question": "How is your period today?", + "type": "period", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "20:00"}], + "input_config": {"fields": [ + {"key": "flow", "type": "select", "label": "Flow", "options": ["Spotting", "Light", "Medium", "Heavy"]}, + {"key": "pain", "type": "scale", "label": "Pain (0-5)"} + ]}, + "entries": [{"value": "Period started", "data": {"started": true}}] +} +``` + +Input: "I'm pregnant!" +Existing Types: (none yet) +```json +{ + "question": "Congratulations! What is your estimated due date?", + "category": "fertility", + "type": "pregnancy", + "input_type": "form", + "schedule": null, + "input_config": { + "fields": [ + { + "key": "estimated_due_date", + "type": "date", + "label": "Estimated Due Date" + } + ] + } +} +``` + +Input: "I just had my period" +Existing Types: - fertility: [pregnancy] +```json +{ + "question": "I'm so sorry, I know this can be a difficult time. I've noted you're tracking a pregnancy and have logged a new period. How would you like me to update your status?", + "category": "fertility", + "type": "pregnancy_inconsistency", + "input_type": "form", + "schedule": null, + "input_config": { + "fields": [ + { + "key": "pregnancy_status", + "type": "select", + "label": "Update my status", + "options": [ + "End pregnancy tracking", + "This was spotting or something else", + "Keep tracking pregnancy" + ] + } + ] + } +} +``` \ No newline at end of file diff --git a/api/prompts/history.md b/api/prompts/history.md new file mode 100644 index 0000000..ed0fdc6 --- /dev/null +++ b/api/prompts/history.md @@ -0,0 +1,94 @@ +# History Prompt + +Extract details from a past medical event (user's own history, not documented). + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "friendly confirmation question in {{LANGUAGE}}", + "type": "snake_case_identifier", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [...]}, + "entry": {"value": "human readable", "data": {...}} +} +``` + +## Schedule + +Always `null` for history — these are one-time records, not recurring prompts. + +## Type mapping + +| Input pattern | type | +|---------------|------| +| appendix, appendectomy | appendectomy | +| tonsils, tonsillectomy | tonsillectomy | +| broke, fracture, broken | fracture | +| chickenpox, measles, mumps | childhood_illness | +| surgery (general) | past_surgery | +| allergy | allergy | +| injury | past_injury | + +## Entry data structure + +```json +{ + "condition": "appendectomy", + "age": 12, + "year": 2005, + "body_part": "left arm", + "severity": "normal", + "complications": "none", + "notes": "..." +} +``` + +Include only fields that can be inferred from input. + +## Rules + +- entry.data must be a JSON OBJECT, not a string +- schedule is always null (no recurring prompt) + +## Examples + +Input: "I had my appendix removed when I was 12" +```json +{ + "question": "Appendectomy recorded. Any complications?", + "type": "appendectomy", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [{"key": "complications", "type": "text", "label": "Complications"}]}, + "entry": {"value": "Appendectomy at age 12", "data": {"condition": "appendectomy", "age": 12}} +} +``` + +Input: "Broke my left arm in 2015" +```json +{ + "question": "Left arm fracture recorded. How did it heal?", + "type": "fracture", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [{"key": "healing", "type": "select", "label": "Healing outcome", "options": ["Full recovery", "Some limitations", "Ongoing issues"]}]}, + "entry": {"value": "Left arm fracture, 2015", "data": {"condition": "fracture", "body_part": "left arm", "year": 2015}} +} +``` + +Input: "had chickenpox as a child" +```json +{ + "question": "Chickenpox recorded. Any complications?", + "type": "childhood_illness", + "input_type": "form", + "schedule": null, + "input_config": {"fields": [{"key": "complications", "type": "text", "label": "Complications (if any)"}]}, + "entry": {"value": "Chickenpox in childhood", "data": {"condition": "chickenpox", "age_period": "childhood"}} +} +``` diff --git a/api/prompts/medication.md b/api/prompts/medication.md new file mode 100644 index 0000000..c1e53d1 --- /dev/null +++ b/api/prompts/medication.md @@ -0,0 +1,69 @@ +# Medication Prompt + +Extract medication tracking details. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +**If the user mentions multiple medications, create one entry for each in an `entries` array. The `input_config` for the follow-up prompt should also contain a separate checkbox field for each medication identified.** + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "medication_name_snake_case", + "input_type": "form", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": {"fields": [{"key": "med_name", "type": "checkbox", "label": "Med Name"}]}, + "entries": [{"value": "...", "data": {...}}] +} +``` + +## Schedule format +- `days`: array of "mon", "tue", "wed", "thu", "fri", "sat", "sun" +- `time`: 24h format "08:00", "12:00", "18:00" etc. +- Daily = all 7 days +- "once" or one-time event = null (no schedule, no recurring prompt) + +## Rules +- `entry.data` must be a JSON OBJECT, not a string. +- If multiple medications are mentioned, create a separate entry for each in the `entries` array. +- The `input_config.fields` should contain a checkbox for each medication found. The `key` should be the snake_case name of the medication, and the `label` should be the proper name. +- If only one medication is mentioned, use the `entries` array with a single element and a single checkbox in `input_config`. + +## Examples + +Input: "took ibuprofen 400mg" +```json +{ + "question": "Did you take your ibuprofen?", + "type": "ibuprofen", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": {"fields": [ + {"key": "ibuprofen", "type": "checkbox", "label": "Ibuprofen"} + ]}, + "entries": [{"value": "Ibuprofen 400mg", "data": {"medication": "ibuprofen", "dose": 400, "unit": "mg"}}] +} +``` + +Input: "I took my morning pills: 10mg of Lisinopril and a multivitamin." +```json +{ + "question": "Did you take your morning pills?", + "type": "morning_pills", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": { + "fields": [ + {"key": "lisinopril", "type": "checkbox", "label": "Lisinopril"}, + {"key": "multivitamin", "type": "checkbox", "label": "Multivitamin"} + ] + }, + "entries": [ + {"value": "10mg Lisinopril", "data": {"medication": "lisinopril", "dose": 10, "unit": "mg"}}, + {"value": "Multivitamin", "data": {"medication": "multivitamin"}} + ] +} +``` diff --git a/api/prompts/nutrition.md b/api/prompts/nutrition.md new file mode 100644 index 0000000..18a1f5f --- /dev/null +++ b/api/prompts/nutrition.md @@ -0,0 +1,65 @@ +# Nutrition Prompt + +Extract nutrition/dietary details. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +**If the user mentions multiple foods or meals, create one entry for each in an `entries` array.** + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "nutrition_type", + "input_type": "form|checkbox", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": {"fields": [...]}, + "entries": [{"value": "...", "data": {...}}] +} +``` + +## Schedule format +- `days`: array of "mon", "tue", "wed", "thu", "fri", "sat", "sun" +- `time`: 24h format "08:00", "12:00", "18:00" etc. +- Daily = all 7 days +- "once" or one-time event = null (no schedule, no recurring prompt) + +## Nutrition types +coffee, alcohol, water, caffeine, sugar, breakfast, lunch, dinner, snack, fasting + +## Rules +- entry.data must be a JSON OBJECT, not a string +- If multiple items are mentioned, create a separate entry for each in the `entries` array. +- If only one item is mentioned, use the `entries` array with a single element. +- For abstinence tracking (e.g., "stopped coffee"), create a prompt to track if they avoided it. + +## Examples + +Input: "I stopped drinking coffee" +```json +{ + "question": "Did you have coffee today?", + "type": "coffee_abstinence", + "input_type": "checkbox", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "20:00"}], + "input_config": {"fields": [{"key": "had_coffee", "type": "checkbox", "label": "Yes"}]}, + "entries": [{"value": "Tracking coffee abstinence", "data": {"abstinence": true}}] +} +``` + +Input: "Today for lunch I had a chicken salad and a glass of water, and for dinner I had a steak with a side of potatoes." +```json +{ + "question": "What did you eat today?", + "type": "daily_food_log", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "20:00"}], + "input_config": {"fields": [{"key": "food", "type": "text", "label": "Food"}]}, + "entries": [ + {"value": "Chicken salad and water for lunch", "data": {"meal": "lunch", "food": "chicken salad", "drink": "water"}}, + {"value": "Steak and potatoes for dinner", "data": {"meal": "dinner", "food": "steak", "side": "potatoes"}} + ] +} +``` \ No newline at end of file diff --git a/api/prompts/supplement.md b/api/prompts/supplement.md new file mode 100644 index 0000000..cb7a130 --- /dev/null +++ b/api/prompts/supplement.md @@ -0,0 +1,98 @@ +# Supplement Prompt + +Extract supplement tracking details. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "supplement_name_snake_case", + "input_type": "form|checkbox", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": {"fields": [...]}, + "entry": {"value": "...", "data": {...}} +} +``` + +## Schedule format + +- `days`: array of "mon", "tue", "wed", "thu", "fri", "sat", "sun" +- `time`: 24h format "08:00", "12:00", "18:00" etc. +- Daily = all 7 days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +- "with meals" = 3 entries: 08:00, 12:00, 18:00 +- "twice a day" = 2 entries: 08:00, 20:00 +- "once" or one-time event = null (no schedule, no recurring prompt) + +## Common supplements + +vitamin_d, vitamin_c, omega_3, fish_oil, magnesium, zinc, iron, probiotics, multivitamin, calcium, b12, folate, melatonin, collagen + +## Standard fields + +- `amount`: number with unit (mg, IU, mcg, tbsp, capsules) + +## Rules + +- entry.data must be a JSON OBJECT, not a string +- Only include fields that have values from user input +- If one-time (e.g. "got flu shot"), set schedule to null + +## Examples + +Input: "taking omega-3 fish oil" +```json +{ + "question": "Did you take your Omega-3 today?", + "type": "omega_3", + "input_type": "checkbox", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": {"fields": [{"key": "taken", "type": "checkbox", "label": "Taken"}]} +} +``` + +Input: "I take 1 tablespoon of fish oil daily" +```json +{ + "question": "Did you take your fish oil today?", + "type": "fish_oil", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": {"fields": [ + {"key": "amount", "type": "number", "label": "Amount", "unit": "tbsp"} + ]}, + "entry": {"value": "1 tbsp fish oil", "data": {"amount": 1}} +} +``` + +Input: "vitamin D 2000 IU with breakfast and dinner" +```json +{ + "question": "Did you take your Vitamin D?", + "type": "vitamin_d", + "input_type": "form", + "schedule": [ + {"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}, + {"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "18:00"} + ], + "input_config": {"fields": [ + {"key": "amount", "type": "number", "label": "Amount", "unit": "IU"} + ]}, + "entry": {"value": "Vitamin D 2000 IU", "data": {"amount": 2000}} +} +``` + +Input: "got my flu shot today" +```json +{ + "question": null, + "type": "flu_shot", + "input_type": null, + "schedule": null, + "input_config": null, + "entry": {"value": "Flu shot", "data": {"type": "flu_shot"}} +} +``` diff --git a/api/prompts/symptom.md b/api/prompts/symptom.md new file mode 100644 index 0000000..2df38ac --- /dev/null +++ b/api/prompts/symptom.md @@ -0,0 +1,92 @@ +# Symptom Prompt + +Extract symptom details for tracking. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "snake_case_symptom", + "input_type": "form", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": {"fields": [...]}, + "entry": {"value": "...", "data": {...}} +} +``` + +## Schedule format + +- `days`: array of "mon", "tue", "wed", "thu", "fri", "sat", "sun" +- `time`: 24h format "08:00", "12:00", "18:00" etc. +- Daily = all 7 days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +- Symptoms typically track daily until resolved + +## Common symptom types + +headache, migraine, back_pain, knee_pain, joint_pain, fatigue, nausea, dizziness, insomnia, anxiety, cough, congestion, fever, rash, swelling + +## Standard fields for symptoms + +- `severity`: scale 0-5 +- `duration`: number with unit (hours, days) +- `location`: text or select +- `triggers`: text +- `notes`: text + +## Rules + +- entry.data must be a JSON OBJECT, not a string +- Only include fields that have values from user input +- IMPORTANT: For injuries (cut finger, twisted ankle), track HEALING not the injury event +- Use scale (0-5) for severity/pain, not 1-10 + +## Examples + +Input: "headache today" +```json +{ + "question": "How is your headache?", + "type": "headache", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "20:00"}], + "input_config": {"fields": [ + {"key": "severity", "type": "scale", "label": "Severity (0-5)"}, + {"key": "location", "type": "select", "label": "Location", "options": ["Forehead", "Temples", "Back of head", "One side", "All over"]} + ]}, + "entry": {"value": "Headache reported", "data": {"symptom": "headache"}} +} +``` + +Input: "my knee hurts when climbing stairs" +```json +{ + "question": "How is your knee pain today?", + "type": "knee_pain", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "20:00"}], + "input_config": {"fields": [ + {"key": "severity", "type": "scale", "label": "Pain level (0-5)"}, + {"key": "trigger", "type": "select", "label": "Worse with", "options": ["Stairs", "Walking", "Standing", "Sitting", "All activities"]} + ]}, + "entry": {"value": "Knee pain, worse on stairs", "data": {"symptom": "knee_pain", "trigger": "stairs"}} +} +``` + +Input: "twisted my ankle yesterday" +```json +{ + "question": "How is your ankle healing?", + "type": "ankle_injury", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "20:00"}], + "input_config": {"fields": [ + {"key": "pain", "type": "scale", "label": "Pain (0-5)"}, + {"key": "swelling", "type": "select", "label": "Swelling", "options": ["None", "Mild", "Moderate", "Severe"]} + ]}, + "entry": {"value": "Twisted ankle", "data": {"injury": "ankle", "type": "twist"}} +} +``` diff --git a/api/prompts/triage.md b/api/prompts/triage.md new file mode 100644 index 0000000..7e02e55 --- /dev/null +++ b/api/prompts/triage.md @@ -0,0 +1,69 @@ +# Triage Prompt + +Your most important task is to first determine if the user input is a valid health entry or an invalid query. + +**DO NOT categorize requests for medical advice as 'symptom' or 'question'. They are invalid queries.** + +- If the input is not health-related, respond with: `{"error": "not_health_related"}` +- If the input asks for medical advice (e.g., contains "what should I do?", "how do I treat?"), respond with: `{"error": "no_medical_advice"}` + +--- + +User said: "{{INPUT}}" + +If, and only if, the input is a valid health entry, classify it into a category and extract basic metadata. + +IMPORTANT: Respond with ONLY the JSON object below. No explanations, no markdown, no text before or after. + +```json +{ + "category": "one of the categories below", + "language": "en|nl|de|hi|...", + "has_data": true/false, + "summary": "brief normalized summary" +} +``` + +## Invalid Query Examples + +Input: "What is the capital of Mexico?" +```json +{"error": "not_health_related"} +``` + +Input: "I have a headache, what should I do?" +```json +{"error": "no_medical_advice"} +``` + +## Categories + +| Category | Use when | Examples | +|----------|----------|----------| +| vital | Measurements: weight, blood pressure, temperature, heart rate | "I weigh 80kg", "BP 120/80" | +| exercise | Physical activity, workouts, steps | "walked 30 min", "did yoga" | +| medication | Taking/tracking medicines | "took ibuprofen", "started metformin" | +| supplement | Vitamins, supplements | "taking omega-3", "vitamin D" | +| nutrition | Food, drink, dietary habits, abstinence | "stopped coffee", "no alcohol", "ate breakfast" | +| fertility | Menstrual cycle, period, ovulation, pregnancy | "period started", "ovulating", "I'm pregnant" | +| symptom | Current symptoms, pain, discomfort | "headache today", "knee hurts" | +| note | General health notes | "feeling good", "stressed" | +| history | YOUR past medical events (no documentation) | "appendectomy at 12", "had chickenpox", "broke arm 2015" | +| family_history | Relatives' health conditions | "mother: breast cancer", "father: diabetes" | +| surgery | Documented surgical procedures | "scheduled for knee surgery" | +| hospitalization | Hospital stays | "admitted for pneumonia" | +| consultation | Doctor visits, appointments | "saw cardiologist today" | +| diagnosis | Active conditions being managed | "diagnosed with epilepsy" | +| device | Medical devices, implants | "VP shunt placed", "pacemaker settings" | +| therapy | Therapeutic treatments | "started physical therapy" | +| assessment | Evaluations, tests | "developmental assessment scheduled" | +| birth | Birth-related data | "born at 32 weeks" | +| imaging_finding | Imaging results | "MRI showed..." | +| eeg_finding | EEG results | "EEG normal" | +| provider | Doctors, hospitals, clinics | "Dr. Smith is my neurologist" | +| question | Open questions | "should I worry about..." | + +## Rules + +- `has_data`: true if user provided actual values/facts, false if just describing intent +- `summary`: normalize to third person, e.g. "walked 30 minutes" → "30 min walk" diff --git a/api/prompts/vital.md b/api/prompts/vital.md new file mode 100644 index 0000000..d29760e --- /dev/null +++ b/api/prompts/vital.md @@ -0,0 +1,105 @@ +# Vital Prompt + +Extract vital sign measurements. + +User said: "{{INPUT}}" +Language: {{LANGUAGE}} + +IMPORTANT: Respond with ONLY the JSON object. No explanations, no markdown fences, no text before or after. + +```json +{ + "question": "tracking question in {{LANGUAGE}}", + "type": "vital_type", + "input_type": "form", + "schedule": [{"days": [...], "time": "HH:MM"}], + "input_config": {"fields": [...]}, + "entry": {"value": "...", "data": {...}} +} +``` + +## Schedule format + +- `days`: array of "mon", "tue", "wed", "thu", "fri", "sat", "sun" +- `time`: 24h format "08:00", "12:00", "18:00" etc. +- Daily = all 7 days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +- "once" or one-time measurement = null (no schedule, no recurring prompt) + +## Vital types and units + +| Type | Fields | Units | +|------|--------|-------| +| weight | weight | kg, lbs | +| blood_pressure | systolic, diastolic | mmHg | +| heart_rate | bpm | bpm | +| temperature | temp | °C, °F | +| blood_glucose | glucose | mg/dL, mmol/L | +| oxygen_saturation | spo2 | % | +| sleep | hours | hours | +| steps | steps | steps | + +## Rules + +- entry.data must be a JSON OBJECT, not a string +- Only include fields that have values from user input + +## Examples + +Input: "I weigh 80kg" +```json +{ + "question": "What is your weight today?", + "type": "weight", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": {"fields": [{"key": "weight", "type": "number", "label": "Weight", "unit": "kg"}]}, + "entry": {"value": "80 kg", "data": {"weight": 80, "unit": "kg"}} +} +``` + +Input: "blood pressure 120/80" +```json +{ + "question": "What is your blood pressure?", + "type": "blood_pressure", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": {"fields": [ + {"key": "systolic", "type": "number", "label": "Systolic", "unit": "mmHg"}, + {"key": "diastolic", "type": "number", "label": "Diastolic", "unit": "mmHg"} + ]}, + "entry": {"value": "120/80 mmHg", "data": {"systolic": 120, "diastolic": 80, "unit": "mmHg"}} +} +``` + +Input: "slept 7 hours last night" +```json +{ + "question": "How many hours did you sleep?", + "type": "sleep", + "input_type": "form", + "schedule": [{"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}], + "input_config": {"fields": [ + {"key": "hours", "type": "number", "label": "Hours", "unit": "hrs", "step": 0.5}, + {"key": "quality", "type": "scale", "label": "Quality (0-5)"} + ]}, + "entry": {"value": "7 hours sleep", "data": {"hours": 7}} +} +``` + +Input: "check BP morning and evening" +```json +{ + "question": "What is your blood pressure?", + "type": "blood_pressure", + "input_type": "form", + "schedule": [ + {"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "08:00"}, + {"days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "time": "20:00"} + ], + "input_config": {"fields": [ + {"key": "systolic", "type": "number", "label": "Systolic", "unit": "mmHg"}, + {"key": "diastolic", "type": "number", "label": "Diastolic", "unit": "mmHg"} + ]} +} +``` diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..29a3a50 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 0000000..d044da8 --- /dev/null +++ b/app/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: android + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: ios + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: linux + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: macos + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: web + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + - platform: windows + create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/FLUTTER_TASK.md b/app/FLUTTER_TASK.md new file mode 100644 index 0000000..1b92881 --- /dev/null +++ b/app/FLUTTER_TASK.md @@ -0,0 +1,105 @@ +# Flutter Landing Page + i18n Task + +## Goal +Match Go version landing page, add i18n (EN, NL, RU first) + +## Visual Changes Needed + +### Layout (match Go version) +- [ ] Wrap sections in `.landing-card` style (white bg, border, rounded, padding 48px) +- [ ] Hero: centered text, not left-aligned +- [ ] Hero text: "inou organizes and shares your health dossier with your AI — securely and privately." +- [ ] Hero tagline: "Your health, understood." +- [ ] CTA button: "Sign in" (or "Invite a friend" if logged in) + +### Content Sections (3 cards in Go) +1. **Hero card** - tagline + CTA +2. **"You need AI for your health"** - warm prose style +3. **"The challenge"** - Data/Reality pairs format: + - "Your MRI has 4,000 slices." / "It was read in 10 minutes." + - "Your genome has millions of variants." / "All you learned was your eye color..." + - etc. +4. **"Why we built this"** - prose paragraphs +5. **Trust section** - 4-column grid: + - Never used for training + - Never shared + - Military-grade encryption + - Delete anytime + +### CSS Values (from Go) +``` +--bg: #F8F7F6 +--bg-card: #FFFFFF +--border: #E5E2DE +--text: #1C1917 +--text-muted: #78716C +--accent: #B45309 +--accent-hover: #92400E + +.landing-card { + padding: 48px; + border-radius: 8px; + border: 1px solid var(--border); + margin-bottom: 24px; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 32px; +} +``` + +## i18n Setup + +### Packages +```yaml +dependencies: + flutter_localizations: + sdk: flutter + intl: ^0.18.0 +``` + +### Structure +``` +lib/ +├── l10n/ +│ ├── app_en.arb # English +│ ├── app_nl.arb # Dutch +│ └── app_ru.arb # Russian +└── main.dart # Add localization delegates +``` + +### Key Strings to Port (landing only) +From `lang/en.yaml`: +- data_yours: "Your data stays yours" +- never_training: "Never used for training" +- never_training_desc: "Your images are never used to train AI models." +- never_shared: "Never shared" +- never_shared_desc: "We never share your data with anyone." +- encrypted: "Military-grade encryption" +- encrypted_desc: "At rest and in transit..." +- delete: "Delete anytime" +- delete_desc: "Your data, your control." +- get_started: "Get started" + +### Language Switcher +- Add to InouHeader +- Dropdown with: English, Nederlands, Русский +- Store preference (SharedPreferences) + +## Source Files +- Go templates: `~/dev/inou/templates/` +- Go CSS: `~/dev/inou/static/style.css` +- Go translations: `~/dev/inou/lang/*.yaml` + +## Translation Strategy +- Port EN strings first (manual) +- Use cheap model to translate EN → NL, RU +- Human review later + +## Priority +1. Match visual layout first +2. Add i18n scaffolding +3. Port EN strings +4. Generate NL, RU translations diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..3f559e1 --- /dev/null +++ b/app/README.md @@ -0,0 +1,16 @@ +# inou_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/app/android/.gitignore b/app/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/app/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle new file mode 100644 index 0000000..33524aa --- /dev/null +++ b/app/android/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.inou.inou_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.inou.inou_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f44f6b8 --- /dev/null +++ b/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/inou/inou_app/MainActivity.kt b/app/android/app/src/main/kotlin/com/inou/inou_app/MainActivity.kt new file mode 100644 index 0000000..1ef32de --- /dev/null +++ b/app/android/app/src/main/kotlin/com/inou/inou_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.inou.inou_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/android/build.gradle b/app/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/app/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties new file mode 100644 index 0000000..2597170 --- /dev/null +++ b/app/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7bb2df6 --- /dev/null +++ b/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/app/android/settings.gradle b/app/android/settings.gradle new file mode 100644 index 0000000..b9e43bd --- /dev/null +++ b/app/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/app/fonts/Sora-Bold.ttf b/app/fonts/Sora-Bold.ttf new file mode 100644 index 0000000..ef4a017 Binary files /dev/null and b/app/fonts/Sora-Bold.ttf differ diff --git a/app/fonts/Sora-ExtraBold.ttf b/app/fonts/Sora-ExtraBold.ttf new file mode 100644 index 0000000..d9c724c Binary files /dev/null and b/app/fonts/Sora-ExtraBold.ttf differ diff --git a/app/fonts/Sora-ExtraLight.ttf b/app/fonts/Sora-ExtraLight.ttf new file mode 100644 index 0000000..cbc5ba7 Binary files /dev/null and b/app/fonts/Sora-ExtraLight.ttf differ diff --git a/app/fonts/Sora-Light.ttf b/app/fonts/Sora-Light.ttf new file mode 100644 index 0000000..c64d462 Binary files /dev/null and b/app/fonts/Sora-Light.ttf differ diff --git a/app/fonts/Sora-Regular.ttf b/app/fonts/Sora-Regular.ttf new file mode 100644 index 0000000..cc0103a Binary files /dev/null and b/app/fonts/Sora-Regular.ttf differ diff --git a/app/fonts/Sora-SemiBold.ttf b/app/fonts/Sora-SemiBold.ttf new file mode 100644 index 0000000..66820ae Binary files /dev/null and b/app/fonts/Sora-SemiBold.ttf differ diff --git a/app/fonts/Sora-Thin.ttf b/app/fonts/Sora-Thin.ttf new file mode 100644 index 0000000..1d59088 Binary files /dev/null and b/app/fonts/Sora-Thin.ttf differ diff --git a/app/ios/.gitignore b/app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..83a496f --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..8e3ca5d --- /dev/null +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/app/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist new file mode 100644 index 0000000..ebeee69 --- /dev/null +++ b/app/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Inou App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + inou_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/l10n.yaml b/app/l10n.yaml new file mode 100644 index 0000000..1437ccc --- /dev/null +++ b/app/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/app/lib/core/locale_provider.dart b/app/lib/core/locale_provider.dart new file mode 100644 index 0000000..47394b4 --- /dev/null +++ b/app/lib/core/locale_provider.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Manages app locale with persistence +class LocaleProvider extends ChangeNotifier { + static const String _localeKey = 'app_locale'; + + Locale _locale = const Locale('en'); + + Locale get locale => _locale; + + /// Supported locales + static const List supportedLocales = [ + Locale('en'), // English + Locale('nl'), // Dutch + Locale('ru'), // Russian + ]; + + /// Locale display names + static const Map localeNames = { + 'en': 'English', + 'nl': 'Nederlands', + 'ru': 'Русский', + }; + + /// Short codes for display in header + static const Map localeCodes = { + 'en': 'EN', + 'nl': 'NL', + 'ru': 'RU', + }; + + LocaleProvider() { + _loadLocale(); + } + + /// Load saved locale from preferences + Future _loadLocale() async { + try { + final prefs = await SharedPreferences.getInstance(); + final localeCode = prefs.getString(_localeKey); + if (localeCode != null && supportedLocales.any((l) => l.languageCode == localeCode)) { + _locale = Locale(localeCode); + notifyListeners(); + } + } catch (e) { + // Fallback to default if loading fails + debugPrint('Failed to load locale: $e'); + } + } + + /// Set and persist locale + Future setLocale(Locale locale) async { + if (!supportedLocales.contains(locale)) return; + + _locale = locale; + notifyListeners(); + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_localeKey, locale.languageCode); + } catch (e) { + debugPrint('Failed to save locale: $e'); + } + } + + /// Get current locale name for display + String get currentLocaleName => localeNames[_locale.languageCode] ?? 'English'; + + /// Get current locale code for header display + String get currentLocaleCode => localeCodes[_locale.languageCode] ?? 'EN'; +} diff --git a/app/lib/core/router.dart b/app/lib/core/router.dart new file mode 100644 index 0000000..b167c7e --- /dev/null +++ b/app/lib/core/router.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +// Feature screens +import 'package:inou_app/features/static/landing_page.dart'; +import 'package:inou_app/features/static/security_page.dart'; +import 'package:inou_app/features/static/privacy_page.dart'; +import 'package:inou_app/features/static/faq_page.dart'; +import 'package:inou_app/features/static/dpa_page.dart'; +import 'package:inou_app/features/static/connect_page.dart'; +import 'package:inou_app/features/static/invite_page.dart'; +import 'package:inou_app/features/auth/login_page.dart'; +import 'package:inou_app/features/auth/signup_page.dart'; +import 'package:inou_app/features/dashboard/dashboard.dart'; +import 'package:inou_app/design/screens/styleguide_screen.dart'; + +/// App route definitions +class AppRoutes { + // Static pages + static const String landing = '/'; + static const String security = '/security'; + static const String privacy = '/privacy'; + static const String faq = '/faq'; + static const String dpa = '/dpa'; + static const String connect = '/connect'; + static const String invite = '/invite'; + + // Auth pages + static const String login = '/login'; + static const String signup = '/signup'; + static const String forgotPassword = '/forgot-password'; + + // Authenticated pages (deep pages) + static const String dashboard = '/dashboard'; + static const String dossier = '/dossier/:id'; + static const String profile = '/profile'; + + // Dev + static const String styleguide = '/styleguide'; +} + +/// GoRouter configuration +final GoRouter appRouter = GoRouter( + initialLocation: '/', + routes: [ + // Static pages + GoRoute( + path: '/', + builder: (context, state) => const LandingPage(), + ), + GoRoute( + path: '/security', + builder: (context, state) => const SecurityPage(), + ), + GoRoute( + path: '/privacy', + builder: (context, state) => const PrivacyPage(), + ), + GoRoute( + path: '/faq', + builder: (context, state) => const FaqPage(), + ), + GoRoute( + path: '/dpa', + builder: (context, state) => const DpaPage(), + ), + GoRoute( + path: '/connect', + builder: (context, state) => const ConnectPage(), + ), + GoRoute( + path: '/invite', + builder: (context, state) => const InvitePage(), + ), + + // Auth pages + GoRoute( + path: '/login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/signup', + builder: (context, state) => const SignupPage(), + ), + + // Authenticated pages + GoRoute( + path: '/dashboard', + builder: (context, state) => const DashboardPage(), + ), + GoRoute( + path: '/dossier/:id', + builder: (context, state) { + final dossierId = state.pathParameters['id']!; + return DossierPage(dossierId: dossierId); + }, + ), + + // Dev + GoRoute( + path: '/styleguide', + builder: (context, state) => const StyleguideScreen(), + ), + ], + errorBuilder: (context, state) => const LandingPage(), +); + +/// Legacy route generator for MaterialApp (deprecated, use GoRouter) +@Deprecated('Use appRouter with GoRouter instead') +Route? generateRoute(RouteSettings settings) { + // Keep for backwards compatibility if needed + return MaterialPageRoute( + builder: (_) => const LandingPage(), + settings: settings, + ); +} diff --git a/app/lib/design/inou_text.dart b/app/lib/design/inou_text.dart new file mode 100644 index 0000000..3ed6d6e --- /dev/null +++ b/app/lib/design/inou_text.dart @@ -0,0 +1,496 @@ +/// inou Text Styles & Typography Widgets +/// +/// RULES: +/// - Pages MUST use InouText.* styles or widgets +/// - NO raw TextStyle() in page code +/// - NO fontSize: or fontWeight: in page code +/// +/// Usage: +/// Text('Hello', style: InouText.pageTitle) +/// InouText.pageTitle('Hello') +/// InouText.body('Paragraph', color: InouTheme.textMuted) + +import 'package:flutter/material.dart'; +import 'inou_theme.dart'; + +/// Typography system for inou +/// +/// Base: 16px (1rem), Sora font, line-height 1.5 +class InouText { + InouText._(); + + // =========================================== + // FONT FAMILY + // =========================================== + static const String fontFamily = 'Sora'; + + // =========================================== + // TEXT STYLES + // =========================================== + + /// Page title: 2.5rem (40px), weight 800 (ExtraBold) + /// Use for: Main page headings like "Style Guide", "Privacy Policy" + static const TextStyle pageTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 40.0, // 2.5 * 16 + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + height: 1.2, + color: InouTheme.text, + ); + + /// Hero title: 2.25rem (36px), weight 300 (Light) + /// Use for: Large hero text like "Your data. Your rules." + static const TextStyle heroTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 36.0, // 2.25 * 16 + fontWeight: FontWeight.w300, + height: 1.2, + letterSpacing: -1.08, // -0.03em + color: InouTheme.text, + ); + + /// Section title: 1.4rem (22.4px), weight 600 (SemiBold) + /// Use for: Section headings like "What we collect" + static const TextStyle sectionTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 22.4, // 1.4 * 16 + fontWeight: FontWeight.w600, + color: InouTheme.text, + ); + + /// Subsection title: 1.1rem (17.6px), weight 600 (SemiBold) + /// Use for: Subsection headings like "Account information" + static const TextStyle subsectionTitle = TextStyle( + fontFamily: fontFamily, + fontSize: 17.6, // 1.1 * 16 + fontWeight: FontWeight.w600, + color: InouTheme.text, + ); + + /// H3: 1.125rem (18px), weight 500 (Medium) + /// Use for: Tertiary headings + static const TextStyle h3 = TextStyle( + fontFamily: fontFamily, + fontSize: 18.0, // 1.125 * 16 + fontWeight: FontWeight.w500, + color: InouTheme.text, + ); + + /// Intro text: 1.15rem (18.4px), weight 300 (Light) + /// Use for: Introduction paragraphs, larger body text + static const TextStyle intro = TextStyle( + fontFamily: fontFamily, + fontSize: 18.4, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ); + + /// Body light: 1rem (16px), weight 300 (Light) + /// Use for: Long-form content, articles, descriptions + static const TextStyle bodyLight = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w300, + height: 1.5, + color: InouTheme.text, + ); + + /// Body regular: 1rem (16px), weight 400 (Regular) + /// Use for: UI labels, default text, buttons + static const TextStyle body = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + height: 1.5, + color: InouTheme.text, + ); + + /// Body small: 0.85rem (13.6px), weight 400 (Regular) + /// Use for: Secondary text, captions, helper text + static const TextStyle bodySmall = TextStyle( + fontFamily: fontFamily, + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Label: 1rem (16px), weight 500 (Medium) + /// Use for: Form labels, button text + static const TextStyle label = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + color: InouTheme.text, + ); + + /// Label caps: 0.75rem (12px), weight 600, uppercase + /// Use for: Category labels like "TEXT BLOCKS", "TYPOGRAPHY SCALE" + static const TextStyle labelCaps = TextStyle( + fontFamily: fontFamily, + fontSize: 12.0, // 0.75 * 16 + fontWeight: FontWeight.w600, + letterSpacing: 1.2, // 0.1em + color: InouTheme.textSubtle, + ); + + /// Logo: 1.75rem (28px), weight 700 (Bold) + /// Use for: "inou" in header + static const TextStyle logo = TextStyle( + fontFamily: fontFamily, + fontSize: 28.0, // 1.75 * 16 + fontWeight: FontWeight.w700, + letterSpacing: -0.56, // -0.02em + ); + + /// Logo light: 1.75rem (28px), weight 300 (Light) + /// Use for: "health" in header + static const TextStyle logoLight = TextStyle( + fontFamily: fontFamily, + fontSize: 28.0, // 1.75 * 16 + fontWeight: FontWeight.w300, + letterSpacing: -0.56, // -0.02em + ); + + /// Logo tagline: 0.95rem (15.2px), weight 300 (Light) + /// Use for: "ai answers for you" tagline + static const TextStyle logoTagline = TextStyle( + fontFamily: fontFamily, + fontSize: 15.2, // 0.95 * 16 + fontWeight: FontWeight.w300, + letterSpacing: 0.608, // 0.04em + color: InouTheme.textMuted, + ); + + /// Nav item: 1rem (16px), weight 400 (Regular) + /// Use for: Navigation menu items + static const TextStyle nav = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Nav item active: 1rem (16px), weight 600 (SemiBold) + /// Use for: Active navigation menu items + static const TextStyle navActive = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w600, + color: InouTheme.accent, + ); + + /// Mono: SF Mono, 0.85rem (13.6px) + /// Use for: Code, technical data, IDs + static const TextStyle mono = TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: ['Monaco', 'Consolas', 'monospace'], + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Profile name: 1.25rem (20px), weight 600 (SemiBold) + /// Use for: User names in profile cards + static const TextStyle profileName = TextStyle( + fontFamily: fontFamily, + fontSize: 20.0, // 1.25 * 16 + fontWeight: FontWeight.w600, + color: InouTheme.text, + ); + + /// Badge: 1rem (16px), weight 500 (Medium) + /// Use for: Badge/pill text + static const TextStyle badge = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + ); + + /// Button: 1rem (16px), weight 500 (Medium) + /// Use for: Button text + static const TextStyle button = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + ); + + /// Input: 1rem (16px), weight 400 (Regular) + /// Use for: Text input fields + static const TextStyle input = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ); + + /// Input placeholder: 1rem (16px), weight 400 (Regular) + /// Use for: Placeholder text in inputs + static const TextStyle inputPlaceholder = TextStyle( + fontFamily: fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.textSubtle, + ); + + /// Error text: 0.85rem (13.6px), weight 400 (Regular) + /// Use for: Form validation errors + static const TextStyle error = TextStyle( + fontFamily: fontFamily, + fontSize: 13.6, + fontWeight: FontWeight.w400, + color: InouTheme.danger, + ); + + /// Link: inherits size, weight 400, accent color + /// Use for: Inline links + static TextStyle link({double? fontSize}) => TextStyle( + fontFamily: fontFamily, + fontSize: fontSize ?? 16.0, + fontWeight: FontWeight.w400, + color: InouTheme.accent, + decoration: TextDecoration.underline, + decorationColor: InouTheme.accent, + ); + + // =========================================== + // CONVENIENCE WIDGETS + // =========================================== + + /// Page title widget + static Widget pageTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: pageTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Hero title widget + static Widget heroTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: heroTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Section title widget + static Widget sectionTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: sectionTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Subsection title widget + static Widget subsectionTitleText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: subsectionTitle.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Body text widget + static Widget bodyText( + String text, { + Color? color, + TextAlign? textAlign, + int? maxLines, + TextOverflow? overflow, + }) { + return Text( + text, + style: body.copyWith(color: color), + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + ); + } + + /// Body light text widget (for long-form) + static Widget bodyLightText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: bodyLight.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Intro text widget + static Widget introText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: intro.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Label caps widget (auto-uppercases) + static Widget labelCapsText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text.toUpperCase(), + style: labelCaps.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Mono text widget + static Widget monoText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: mono.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Small text widget + static Widget smallText( + String text, { + Color? color, + TextAlign? textAlign, + }) { + return Text( + text, + style: bodySmall.copyWith(color: color), + textAlign: textAlign, + ); + } + + /// Error text widget + static Widget errorText( + String text, { + TextAlign? textAlign, + }) { + return Text( + text, + style: error, + textAlign: textAlign, + ); + } + + // =========================================== + // RICH TEXT HELPERS + // =========================================== + + /// Build rich text with multiple styled spans + static Widget rich( + List children, { + TextAlign? textAlign, + }) { + return Text.rich( + TextSpan(children: children), + textAlign: textAlign, + ); + } +} + +/// Styled text spans for use with InouText.rich() +class InouSpan { + InouSpan._(); + + /// Accent colored text + static TextSpan accent(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(color: InouTheme.accent), + ); + } + + /// Muted colored text + static TextSpan muted(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(color: InouTheme.textMuted), + ); + } + + /// Subtle colored text + static TextSpan subtle(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(color: InouTheme.textSubtle), + ); + } + + /// Bold text + static TextSpan bold(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(fontWeight: FontWeight.w700), + ); + } + + /// SemiBold text + static TextSpan semiBold(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(fontWeight: FontWeight.w600), + ); + } + + /// Light text + static TextSpan light(String text, {TextStyle? baseStyle}) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith(fontWeight: FontWeight.w300), + ); + } + + /// Plain text (default style) + static TextSpan plain(String text, {TextStyle? style}) { + return TextSpan(text: text, style: style); + } + + /// Link text + static TextSpan link( + String text, { + VoidCallback? onTap, + TextStyle? baseStyle, + }) { + return TextSpan( + text: text, + style: (baseStyle ?? InouText.body).copyWith( + color: InouTheme.accent, + decoration: TextDecoration.underline, + decorationColor: InouTheme.accent, + ), + // Note: For tap handling, wrap in GestureDetector or use url_launcher + ); + } +} diff --git a/app/lib/design/inou_theme.dart b/app/lib/design/inou_theme.dart new file mode 100644 index 0000000..04a5d71 --- /dev/null +++ b/app/lib/design/inou_theme.dart @@ -0,0 +1,317 @@ +/// inou Design System +/// Source: https://inou.com/static/style.css +/// Base: 16px, Sora font, line-height 1.5 +/// +/// Using LOCAL Sora font (fonts/Sora-*.ttf), not google_fonts package. + +import 'package:flutter/material.dart'; + +class InouTheme { + InouTheme._(); + + // Font family - local asset + static const String _fontFamily = 'Sora'; + + // =========================================== + // COLORS (from :root CSS variables) + // =========================================== + static const Color bg = Color(0xFFF8F7F6); // --bg + static const Color bgCard = Color(0xFFFFFFFF); // --bg-card + static const Color border = Color(0xFFE5E2DE); // --border + static const Color borderHover = Color(0xFFC4BFB8); // --border-hover + static const Color text = Color(0xFF1C1917); // --text + static const Color textMuted = Color(0xFF78716C); // --text-muted + static const Color textSubtle = Color(0xFFA8A29E); // --text-subtle + static const Color accent = Color(0xFFB45309); // --accent + static const Color accentHover = Color(0xFF92400E); // --accent-hover + static const Color accentLight = Color(0xFFFEF3C7); // --accent-light + static const Color danger = Color(0xFFDC2626); // --danger + static const Color dangerLight = Color(0xFFFEF2F2); // --danger-light + static const Color success = Color(0xFF059669); // --success + static const Color successLight = Color(0xFFECFDF5); // --success-light + + // Indicator colors (data sections) + static const Color indicatorImaging = Color(0xFFB45309); + static const Color indicatorLabs = Color(0xFF059669); + static const Color indicatorUploads = Color(0xFF6366F1); + static const Color indicatorVitals = Color(0xFFEC4899); + static const Color indicatorMedications = Color(0xFF8B5CF6); + static const Color indicatorRecords = Color(0xFF06B6D4); + static const Color indicatorJournal = Color(0xFFF59E0B); + static const Color indicatorPrivacy = Color(0xFF64748B); + static const Color indicatorGenetics = Color(0xFF10B981); + + // Message border colors + static const Color errorBorder = Color(0xFFFECACA); // #FECACA + static const Color infoBorder = Color(0xFFFDE68A); // #FDE68A + static const Color successBorder = Color(0xFFA7F3D0); // #A7F3D0 + + // =========================================== + // SPACING + // =========================================== + static const double spaceXs = 4.0; + static const double spaceSm = 8.0; + static const double spaceMd = 12.0; + static const double spaceLg = 16.0; + static const double spaceXl = 24.0; + static const double spaceXxl = 32.0; + static const double spaceXxxl = 48.0; + + // =========================================== + // BORDER RADIUS + // =========================================== + static const double radiusSm = 4.0; + static const double radiusMd = 6.0; + static const double radiusLg = 8.0; + static const double radiusXl = 12.0; + static BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm); + static BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd); + static BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg); + + // =========================================== + // LAYOUT + // =========================================== + static const double maxWidth = 1200.0; + static const double maxWidthNarrow = 800.0; + static const double maxWidthForm = 360.0; + + // =========================================== + // TYPOGRAPHY + // CSS base: body { font-size: 16px; line-height: 1.5; font-weight: 400; } + // All rem values calculated as: rem * 16 + // =========================================== + + // h1: 2.25rem (36px), weight 300, line-height 1.2, letter-spacing -0.03em + static TextStyle get h1 => const TextStyle( + fontFamily: _fontFamily, + fontSize: 36.0, // 2.25 * 16 + fontWeight: FontWeight.w300, + height: 1.2, + letterSpacing: -0.03 * 36.0, // -0.03em + color: text, + ); + + // Page title (styleguide): 2.5rem (40px), weight 700 + static TextStyle get pageTitle => const TextStyle( + fontFamily: _fontFamily, + fontSize: 40.0, // 2.5 * 16 + fontWeight: FontWeight.w800, // ExtraBold + letterSpacing: -0.5, // Tighter tracking to match CSS + height: 1.2, // Line height to match CSS defaults + color: text, + ); + + // h2: 1.5rem (24px), weight 300, letter-spacing -0.02em + static TextStyle get h2 => const TextStyle( + fontFamily: _fontFamily, + fontSize: 24.0, // 1.5 * 16 + fontWeight: FontWeight.w300, + letterSpacing: -0.02 * 24.0, // -0.02em + color: text, + ); + + // Section title (styleguide): 1.4rem (22.4px), weight 600 + static TextStyle get sectionTitle => const TextStyle( + fontFamily: _fontFamily, + fontSize: 22.4, // 1.4 * 16 + fontWeight: FontWeight.w600, + color: text, + ); + + // h3: 1.125rem (18px), weight 500 + static TextStyle get h3 => const TextStyle( + fontFamily: _fontFamily, + fontSize: 18.0, // 1.125 * 16 + fontWeight: FontWeight.w500, + color: text, + ); + + // Subsection title (styleguide): 1.1rem (17.6px), weight 600 + static TextStyle get subsectionTitle => const TextStyle( + fontFamily: _fontFamily, + fontSize: 17.6, // 1.1 * 16 + fontWeight: FontWeight.w600, + color: text, + ); + + // Intro text: 1.15rem (18.4px), weight 300 + static TextStyle get intro => const TextStyle( + fontFamily: _fontFamily, + fontSize: 18.4, // 1.15 * 16 + fontWeight: FontWeight.w300, + height: 1.8, + color: textMuted, + ); + + // Body light (long-form): 1rem (16px), weight 300 + static TextStyle get bodyLight => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w300, + height: 1.5, + color: text, + ); + + // Body regular (UI labels): 1rem (16px), weight 400 + static TextStyle get body => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w400, + height: 1.5, + color: text, + ); + + // Small text: 0.85rem (13.6px), weight 400 + static TextStyle get bodySmall => const TextStyle( + fontFamily: _fontFamily, + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: text, + ); + + // Label/Category: 0.75rem (12px), weight 600, uppercase, letter-spacing 0.1em + static TextStyle get labelCaps => const TextStyle( + fontFamily: _fontFamily, + fontSize: 12.0, // 0.75 * 16 + fontWeight: FontWeight.w600, + letterSpacing: 0.1 * 12.0, // 0.1em + color: textSubtle, + ); + + // Button/label: 1rem (16px), weight 500 + static TextStyle get label => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + color: text, + ); + static TextStyle get labelLarge => label; // alias + + // Badge text: 1rem (16px), weight 500 + static TextStyle get badge => const TextStyle( + fontFamily: _fontFamily, + fontSize: 16.0, + fontWeight: FontWeight.w500, + ); + static TextStyle get badgeText => badge; // alias + + // Logo: 1.75rem (28px), weight 700, letter-spacing -0.02em + static TextStyle get logo => const TextStyle( + fontFamily: _fontFamily, + fontSize: 28.0, // 1.75 * 16 + fontWeight: FontWeight.w700, + letterSpacing: -0.02 * 28.0, // -0.02em + ); + + // Logo tagline: 0.95rem (15.2px), weight 300, letter-spacing 0.04em + static TextStyle get logoTagline => const TextStyle( + fontFamily: _fontFamily, + fontSize: 15.2, // 0.95 * 16 + fontWeight: FontWeight.w300, + letterSpacing: 0.04 * 15.2, // 0.04em + color: textMuted, + ); + + // Mono: SF Mono, 0.85rem (13.6px) + static TextStyle get mono => const TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: ['Monaco', 'Consolas', 'monospace'], + fontSize: 13.6, // 0.85 * 16 + fontWeight: FontWeight.w400, + color: text, + ); + + // Profile card h3: 1.25rem (20px) + static TextStyle get profileName => const TextStyle( + fontFamily: _fontFamily, + fontSize: 20.0, // 1.25 * 16 + fontWeight: FontWeight.w600, + color: text, + ); + + // =========================================== + // THEME DATA + // =========================================== + static ThemeData get light => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + fontFamily: _fontFamily, + scaffoldBackgroundColor: bg, + colorScheme: ColorScheme.light( + primary: accent, + onPrimary: Colors.white, + secondary: accentLight, + onSecondary: accent, + surface: bgCard, + onSurface: text, + error: danger, + onError: Colors.white, + outline: border, + ), + textTheme: TextTheme( + displayLarge: pageTitle, + displayMedium: h1, + headlineMedium: sectionTitle, + headlineSmall: subsectionTitle, + bodyLarge: body, + bodyMedium: body, + bodySmall: bodySmall, + labelLarge: label, + labelSmall: labelCaps, + ), + appBarTheme: AppBarTheme( + backgroundColor: bg, + foregroundColor: text, + elevation: 0, + centerTitle: false, + ), + cardTheme: CardTheme( + color: bgCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: borderRadiusLg, + side: BorderSide(color: border), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: accent, + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: label, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: text, + side: BorderSide(color: border), + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: label, + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: bgCard, + border: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: accent, width: 1), + ), + contentPadding: EdgeInsets.symmetric(horizontal: spaceMd, vertical: spaceMd), + ), + dividerTheme: DividerThemeData( + color: border, + thickness: 1, + ), + ); +} diff --git a/app/lib/design/screens/styleguide_screen.dart b/app/lib/design/screens/styleguide_screen.dart new file mode 100644 index 0000000..a7fc424 --- /dev/null +++ b/app/lib/design/screens/styleguide_screen.dart @@ -0,0 +1,1189 @@ +// Flutter Styleguide — matches inou.com/styleguide +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +class StyleguideScreen extends StatefulWidget { + const StyleguideScreen({super.key}); + + @override + State createState() => _StyleguideScreenState(); +} + +class _StyleguideScreenState extends State { + String? _selectedOption = 'Option 1'; + String _selectedSex = 'male'; + bool _checkboxValue = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: Column( + children: [ + // Header component + const InouHeader(), + + // Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Page title + Text('Style Guide', style: InouText.pageTitle), + const SizedBox(height: 8), + Text( + 'Design system components for inou', + style: InouText.bodyLight.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 32), + + // Header Component Preview + _buildHeaderSection(), + + // Text Blocks + _buildTextBlocksSection(), + + // Typography + _buildTypographySection(), + + // Colors + _buildColorsSection(), + + // Buttons + _buildButtonsSection(), + + // Badges + _buildBadgesSection(), + + // Messages + _buildMessagesSection(), + + // Form Elements + _buildFormsSection(), + + // Profile Cards + _buildProfileCardsSection(), + + // Data Cards (Imaging, Labs) + _buildDataCardsSection(), + + // Settings + _buildSettingsSection(), + + // Genetics + _buildGeneticsSection(), + + // Notes + _buildNotesSection(), + + // Supplements + _buildSupplementsSection(), + + // Peptides + _buildPeptidesSection(), + + // Upload Area + _buildUploadSection(), + + // Empty State + _buildEmptyStateSection(), + + const SizedBox(height: 48), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + String _selectedLLM = 'claude'; + String _selectedUnits = 'metric'; + + Widget _buildSettingsSection() { + return InouCard( + title: 'Settings', + indicatorColor: InouTheme.indicatorPrivacy, + child: Column( + children: [ + // LLM Selector + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Primary AI Assistant', style: InouText.label), + const SizedBox(height: 2), + Text( + 'Used for "Ask AI" prompts and analysis', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 3, + child: Column( + children: [ + _LLMOption( + icon: '🤖', + name: 'Claude (Anthropic)', + selected: _selectedLLM == 'claude', + onTap: () => setState(() => _selectedLLM = 'claude'), + ), + _LLMOption( + icon: '💬', + name: 'ChatGPT (OpenAI)', + selected: _selectedLLM == 'chatgpt', + onTap: () => setState(() => _selectedLLM = 'chatgpt'), + ), + _LLMOption( + icon: '✖', + name: 'Grok (xAI)', + selected: _selectedLLM == 'grok', + onTap: () => setState(() => _selectedLLM = 'grok'), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1, color: InouTheme.border), + // Units + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Units', style: InouText.label), + const SizedBox(height: 2), + Text( + 'Measurement system for vitals', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 3, + child: InouSelect( + value: _selectedUnits, + options: const [ + InouSelectOption(value: 'metric', label: 'Metric (kg, cm, °C)'), + InouSelectOption(value: 'imperial', label: 'Imperial (lb, in, °F)'), + ], + onChanged: (v) => setState(() => _selectedUnits = v ?? 'metric'), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildGeneticsSection() { + return InouCard( + title: 'Genetics', + subtitle: 'Medication Response · 47 variants', + indicatorColor: InouTheme.indicatorGenetics, + child: Column( + children: [ + InouDataRow( + label: 'Medication Response', + meta: '47 variants', + isExpandable: true, + initiallyExpanded: true, + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('CYP2C19', style: InouText.label), + const SizedBox(width: 8), + Text('rs4244285', style: InouText.mono.copyWith(color: InouTheme.textMuted)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text('G;A', style: InouText.mono.copyWith(fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 8), + Text('intermediate', style: InouText.bodySmall.copyWith(color: InouTheme.accent)), + ], + ), + const SizedBox(height: 8), + Text( + 'Intermediate metabolizer for clopidogrel (Plavix). May need dose adjustment or alternative medication.', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted, height: 1.4), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: InouTheme.accentLight, + border: Border.all(color: InouTheme.accent), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Ask AI', + style: InouText.bodySmall.copyWith(color: InouTheme.accent, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ], + ), + InkWell( + onTap: () {}, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Center( + child: Text( + 'Show all 47 variants in Medication Response →', + style: InouText.bodySmall.copyWith(color: InouTheme.accent), + ), + ), + ), + ), + const InouDataRow(label: 'Metabolism', meta: '23 variants', isExpandable: true), + const InouDataRow(label: 'Cardiovascular', meta: '18 variants', isExpandable: true), + ], + ), + ); + } + + Widget _buildNotesSection() { + return InouCard( + title: 'Notes', + subtitle: 'Health journal entries', + indicatorColor: InouTheme.indicatorJournal, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Knee injury', + meta: '3 photos', + date: 'Dec 20', + leading: const InouNoteIcon(emoji: '📷', color: Color(0xFF6366F1)), + isExpandable: true, + initiallyExpanded: true, + children: [ + Container( + padding: const EdgeInsets.all(16), + color: InouTheme.bg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Photos + Row( + children: [ + _PhotoPlaceholder(label: 'Dec 20'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 22'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 26'), + const SizedBox(width: 12), + _AddPhotoPlaceholder(), + ], + ), + const SizedBox(height: 16), + // Timeline + _NoteTimelineEntry(date: 'Dec 20, 3:45 PM', text: 'Jim fell on his knee at soccer practice. Swelling visible, applied ice.'), + _NoteTimelineEntry(date: 'Dec 22, 10:20 AM', text: 'Swelling reduced. Still some bruising. Can walk without pain.'), + _NoteTimelineEntry(date: 'Dec 26, 9:15 AM', text: 'Almost fully healed. Light bruise remaining.'), + ], + ), + ), + ], + ), + InouDataRow( + label: 'Mild headache after workout', + date: 'Dec 25', + leading: const InouNoteIcon(emoji: '📝', color: InouTheme.accent), + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildSupplementsSection() { + return InouCard( + title: 'Supplements', + subtitle: 'Daily routine', + indicatorColor: InouTheme.indicatorMedications, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: const [ + _SupplementRow(name: 'Vitamin D3', dose: '1 capsule', amount: '5000 IU', timing: 'morning, with food'), + _SupplementRow(name: 'Omega-3 Fish Oil', dose: '2 capsules', amount: '2000 mg EPA/DHA', timing: 'morning, with food'), + _SupplementRow(name: 'Magnesium Glycinate', dose: '2 capsules', amount: '400 mg', timing: 'evening'), + _SupplementRow(name: 'Liquid B12', dose: '5 ml', amount: '1000 mcg', timing: 'morning'), + ], + ), + ); + } + + Widget _buildPeptidesSection() { + return InouCard( + title: 'Peptides', + subtitle: 'Therapeutic protocols', + indicatorColor: InouTheme.indicatorMedications, + child: Column( + children: const [ + _PeptideRow(name: 'BPC-157', dose: '250 mcg subQ · 2x daily', endDate: 'until Jan 23, 2025', status: 'active'), + _PeptideRow(name: 'TB-500', dose: '2.5 mg subQ · 2x weekly', endDate: 'until Feb 5, 2025', status: 'active'), + _PeptideRow(name: 'BPC-157', dose: '250 mcg subQ · 2x daily', endDate: 'Aug 15 – Sep 7, 2025', status: 'completed'), + ], + ), + ); + } + + Widget _buildEmptyStateSection() { + return InouCard( + title: 'Empty State', + indicatorColor: InouTheme.indicatorRecords, + child: Container( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'No lab data', + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ), + ), + ); + } + + Widget _buildHeaderSection() { + return InouCard( + title: 'Header Component', + indicatorColor: InouTheme.accent, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'The InouHeader widget is shown at the top of this page.', + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: ClipRRect( + borderRadius: InouTheme.borderRadiusLg, + child: const InouHeader(), + ), + ), + const SizedBox(height: 16), + Text( + 'Logo: "inou" (accent, 700) + "health" (muted, 300) • Font: Sora 1.75rem', + style: InouText.mono.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + ); + } + + Widget _buildTextBlocksSection() { + return InouCard( + title: 'Text Blocks', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your data. Your rules.', + style: InouText.heroTitle, + ), + const SizedBox(height: 16), + InouText.rich([ + InouSpan.plain('We built ', style: InouText.intro), + InouSpan.accent('inou', baseStyle: InouText.intro.copyWith(fontWeight: FontWeight.w700)), + InouSpan.plain( + ' because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.', + style: InouText.intro, + ), + ]), + const SizedBox(height: 32), + Text( + 'What we collect', + style: InouText.sectionTitle, + ), + const SizedBox(height: 16), + Text( + 'Account information.', + style: InouText.subsectionTitle, + ), + const SizedBox(height: 8), + Text( + 'Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.', + style: InouText.intro, + ), + ], + ), + ), + ); + } + + Widget _buildTypographySection() { + return InouCard( + title: 'Typography Scale', + indicatorColor: InouTheme.indicatorLabs, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TypographyRow('Page Title', InouText.pageTitle, '2.5rem / 800'), + _TypographyRow('Section Title', InouText.sectionTitle, '1.4rem / 600'), + _TypographyRow('Subsection Title', InouText.subsectionTitle, '1.1rem / 600'), + _TypographyRow( + 'LABEL / CATEGORY', + InouText.labelCaps, + '0.75rem / 600 / caps', + ), + _TypographyRow( + 'Intro text — larger, lighter', + InouText.intro, + '1.15rem / 300', + ), + _TypographyRow( + 'Body light — long-form', + InouText.bodyLight, + '1rem / 300', + ), + _TypographyRow( + 'Body regular — UI labels', + InouText.body, + '1rem / 400', + ), + _TypographyRow( + 'Mono: 1,234,567.89', + InouText.mono, + 'SF Mono', + ), + ], + ), + ), + ); + } + + Widget _buildColorsSection() { + return InouCard( + title: 'Colors', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _ColorRow('Accent', InouTheme.accent, '#B45309'), + _ColorRow('Text', InouTheme.text, '#1C1917'), + _ColorRow('Text Muted', InouTheme.textMuted, '#78716C'), + _ColorRow('Background', InouTheme.bg, '#F8F7F6', hasBorder: true), + _ColorRow('Success', InouTheme.success, '#059669'), + _ColorRow('Danger', InouTheme.danger, '#DC2626'), + ], + ), + ), + ); + } + + Widget _buildButtonsSection() { + return InouCard( + title: 'Buttons', + indicatorColor: InouTheme.indicatorVitals, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + InouButton(text: 'Primary', onPressed: () {}), + InouButton( + text: 'Secondary', + variant: ButtonVariant.secondary, + onPressed: () {}, + ), + InouButton( + text: 'Danger', + variant: ButtonVariant.danger, + onPressed: () {}, + ), + InouButton( + text: 'Small', + size: ButtonSize.small, + onPressed: () {}, + ), + ], + ), + ), + ); + } + + Widget _buildBadgesSection() { + return InouCard( + title: 'Badges', + indicatorColor: InouTheme.indicatorMedications, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: const [ + InouBadge(text: 'default'), + InouBadge(text: 'care', variant: BadgeVariant.care), + InouBadge(text: 'Coming Soon', variant: BadgeVariant.comingSoon), + InouBadge(text: 'processing', variant: BadgeVariant.processing), + ], + ), + ), + ); + } + + Widget _buildMessagesSection() { + return InouCard( + title: 'Messages', + indicatorColor: InouTheme.indicatorRecords, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: const [ + InouMessage( + message: 'Error message — something went wrong.', + type: MessageType.error, + ), + SizedBox(height: 12), + InouMessage( + message: 'Info message — useful information.', + type: MessageType.info, + ), + SizedBox(height: 12), + InouMessage( + message: 'Success message — operation completed.', + type: MessageType.success, + ), + ], + ), + ), + ); + } + + Widget _buildFormsSection() { + return InouCard( + title: 'Form Elements', + indicatorColor: InouTheme.indicatorJournal, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const InouTextField( + label: 'Text Input', + placeholder: 'Enter text...', + ), + const SizedBox(height: 16), + InouSelect( + label: 'Select', + value: _selectedOption, + options: const [ + InouSelectOption(value: 'Option 1', label: 'Option 1'), + InouSelectOption(value: 'Option 2', label: 'Option 2'), + InouSelectOption(value: 'Option 3', label: 'Option 3'), + ], + onChanged: (v) => setState(() => _selectedOption = v), + ), + const SizedBox(height: 16), + const InouTextField( + label: 'Code Input', + placeholder: '123456', + isCode: true, + maxLength: 6, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + InouRadioGroup( + value: _selectedSex, + options: const [ + InouRadioOption(value: 'male', label: 'Male'), + InouRadioOption(value: 'female', label: 'Female'), + ], + onChanged: (v) => setState(() => _selectedSex = v ?? 'male'), + ), + const SizedBox(height: 16), + InouCheckbox( + value: _checkboxValue, + label: 'Can add data (supplements, notes, etc.)', + onChanged: (v) => setState(() => _checkboxValue = v ?? false), + ), + ], + ), + ), + ); + } + + Widget _buildProfileCardsSection() { + return InouCard( + title: 'Profile Cards', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(24), + child: LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth > 700 + ? (constraints.maxWidth - 24) / 3 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Johan Jongsma', + role: 'you', + dob: '1985-03-15', + sex: 'Male', + stats: const [ + ProfileStat('📷', '3 studies'), + ProfileStat('🧪', '12 labs'), + ProfileStat('🧬', 'genome'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Sophia', + role: 'Parent', + dob: '2017-01-01', + sex: 'Female', + isCare: true, + stats: const [ + ProfileStat('📷', '16 studies'), + ProfileStat('🧪', '0 labs'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouAddCard( + label: 'Add dossier', + onTap: () {}, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildDataCardsSection() { + return Column( + children: [ + // Imaging + InouCard( + title: 'Imaging', + subtitle: '16 studies · 4113 slices', + indicatorColor: InouTheme.indicatorImaging, + trailing: InouButton( + text: 'Open viewer', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'MRI BRAIN W/WO CONTRAST', + meta: '13 series', + date: '5/5/2022', + isExpandable: true, + children: [ + InouChildRow(label: 'AX T1', meta: '24 slices'), + InouChildRow(label: 'AX T2 FLAIR', meta: '24 slices'), + InouChildRow(label: 'SAG T1', meta: '20 slices'), + ], + ), + const InouDataRow( + label: 'XR CHEST AP ONLY', + date: '5/6/2022', + ), + ], + ), + ), + + // Labs + InouCard( + title: 'Labs', + subtitle: '4 panels · 23 results', + indicatorColor: InouTheme.indicatorLabs, + child: Column( + children: [ + InouDataRow( + label: 'Complete Blood Count (CBC)', + meta: '8 tests', + date: '12/15/2024', + isExpandable: true, + initiallyExpanded: true, + children: const [ + InouChildRow( + label: 'Hemoglobin', + value: '14.2 g/dL', + meta: '12.0–16.0', + ), + InouChildRow( + label: 'White Blood Cells', + value: '7.8 K/µL', + meta: '4.5–11.0', + ), + InouChildRow( + label: 'Platelets', + value: '142 K/µL', + meta: '150–400', + valueColor: InouTheme.danger, + ), + ], + ), + ], + ), + ), + + // Vitals + InouCard( + title: 'Vitals', + subtitle: 'Self-reported measurements', + indicatorColor: InouTheme.indicatorVitals, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Temperature', + value: '37.2 °C', + meta: 'today', + leading: const InouNoteIcon( + emoji: '🌡', + color: InouTheme.danger, + ), + isExpandable: true, + ), + InouDataRow( + label: 'Weight', + value: '72.4 kg', + meta: 'today', + leading: InouNoteIcon( + emoji: '⚖', + color: Colors.blue.shade600, + ), + isExpandable: true, + ), + InouDataRow( + label: 'Blood Pressure', + value: '118/76', + meta: 'yesterday', + leading: InouNoteIcon( + emoji: '❤', + color: Colors.pink.shade600, + ), + isExpandable: true, + ), + ], + ), + ), + ], + ); + } + + Widget _buildUploadSection() { + return InouCard( + title: 'Upload Area', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(24), + child: Container( + padding: const EdgeInsets.all(40), + decoration: BoxDecoration( + border: Border.all( + color: InouTheme.border, + width: 2, + ), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Column( + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 32, + color: InouTheme.accent, + ), + const SizedBox(height: 12), + Text( + 'Click or drag files here', + style: InouText.label, + ), + const SizedBox(height: 4), + Text( + 'DICOM, PDF, CSV, VCF, and more', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ), + ); + } +} + +// Helper widgets +class _TypographyRow extends StatelessWidget { + final String text; + final TextStyle style; + final String spec; + + const _TypographyRow(this.text, this.style, this.spec); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded(child: Text(text, style: style)), + Text( + spec, + style: InouText.mono.copyWith( + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + ), + ); + } +} + +class _ColorRow extends StatelessWidget { + final String name; + final Color color; + final String hex; + final bool hasBorder; + + const _ColorRow(this.name, this.color, this.hex, {this.hasBorder = false}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(6), + border: hasBorder ? Border.all(color: InouTheme.border) : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text(name, style: InouText.label), + ), + Text( + hex, + style: InouText.mono.copyWith( + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + ), + ); + } +} + +class _LLMOption extends StatelessWidget { + final String icon; + final String name; + final bool selected; + final VoidCallback onTap; + + const _LLMOption({ + required this.icon, + required this.name, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: selected ? InouTheme.accentLight : InouTheme.bgCard, + border: Border.all(color: selected ? InouTheme.accent : InouTheme.border), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text(icon, style: const TextStyle(fontSize: 12)), + ), + const SizedBox(width: 8), + Text( + name, + style: InouText.body.copyWith(color: selected ? InouTheme.accent : InouTheme.text), + ), + ], + ), + ), + ); + } +} + +class _PhotoPlaceholder extends StatelessWidget { + final String label; + + const _PhotoPlaceholder({required this.label}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.border, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Text('🦵', style: TextStyle(fontSize: 24)), + ), + const SizedBox(height: 4), + Text(label, style: InouText.bodySmall.copyWith(fontSize: 11)), + ], + ); + } +} + +class _AddPhotoPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: InouTheme.border), + ), + alignment: Alignment.center, + child: Text('+', style: TextStyle(fontSize: 24, color: InouTheme.accent)), + ), + const SizedBox(height: 4), + Text('Add photo', style: InouText.bodySmall.copyWith(fontSize: 11, color: InouTheme.accent)), + ], + ); + } +} + +class _NoteTimelineEntry extends StatelessWidget { + final String date; + final String text; + + const _NoteTimelineEntry({required this.date, required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(date, style: InouText.bodySmall.copyWith(color: InouTheme.textMuted)), + const SizedBox(height: 4), + Text(text, style: InouText.bodySmall), + ], + ), + ); + } +} + +class _SupplementRow extends StatelessWidget { + final String name; + final String dose; + final String amount; + final String timing; + + const _SupplementRow({ + required this.name, + required this.dose, + required this.amount, + required this.timing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: InouText.label), + const SizedBox(height: 2), + Text('$dose · $amount', style: InouText.bodySmall.copyWith(color: InouTheme.textMuted)), + ], + ), + ), + Text(timing, style: InouText.bodySmall.copyWith(color: InouTheme.textSubtle)), + ], + ), + ); + } +} + +class _PeptideRow extends StatelessWidget { + final String name; + final String dose; + final String endDate; + final String status; + + const _PeptideRow({ + required this.name, + required this.dose, + required this.endDate, + required this.status, + }); + + @override + Widget build(BuildContext context) { + final isActive = status == 'active'; + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(name, style: InouText.label), + const SizedBox(width: 8), + Text(dose, style: InouText.bodySmall.copyWith(color: InouTheme.textMuted)), + ], + ), + const SizedBox(height: 2), + Text(endDate, style: InouText.bodySmall.copyWith(color: InouTheme.textSubtle)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isActive ? InouTheme.successLight : InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + status, + style: InouText.bodySmall.copyWith( + color: isActive ? InouTheme.success : InouTheme.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/design/widgets/inou_badge.dart b/app/lib/design/widgets/inou_badge.dart new file mode 100644 index 0000000..9ebf366 --- /dev/null +++ b/app/lib/design/widgets/inou_badge.dart @@ -0,0 +1,71 @@ +// AUTO-GENERATED widget — matches web .badge +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +enum BadgeVariant { normal, care, comingSoon, processing } + +class InouBadge extends StatelessWidget { + final String text; + final BadgeVariant variant; + + const InouBadge({ + super.key, + required this.text, + this.variant = BadgeVariant.normal, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + final isUppercase = variant == BadgeVariant.comingSoon; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), // 2v, 8h per styleguide + decoration: BoxDecoration( + color: style.background, + borderRadius: BorderRadius.circular(InouTheme.radiusSm), + ), + child: Text( + isUppercase ? text.toUpperCase() : text, + style: InouTheme.badgeText.copyWith( + fontSize: variant == BadgeVariant.comingSoon ? 10 : 15, // 15px (1rem) per styleguide + color: style.foreground, + letterSpacing: isUppercase ? 0.5 : 0, + ), + ), + ); + } + + _BadgeStyle _getStyle() { + switch (variant) { + case BadgeVariant.normal: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + case BadgeVariant.care: + return _BadgeStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + ); + case BadgeVariant.comingSoon: + return _BadgeStyle( + background: InouTheme.bg, + foreground: InouTheme.textMuted, + ); + case BadgeVariant.processing: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + } + } +} + +class _BadgeStyle { + final Color background; + final Color foreground; + + _BadgeStyle({required this.background, required this.foreground}); +} diff --git a/app/lib/design/widgets/inou_button.dart b/app/lib/design/widgets/inou_button.dart new file mode 100644 index 0000000..5e1696c --- /dev/null +++ b/app/lib/design/widgets/inou_button.dart @@ -0,0 +1,129 @@ +// AUTO-GENERATED widget — matches web .btn +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +enum ButtonVariant { primary, secondary, danger } +enum ButtonSize { regular, small } + +class InouButton extends StatelessWidget { + final String text; + final ButtonVariant variant; + final ButtonSize size; + final bool fullWidth; + final VoidCallback? onPressed; + final Widget? icon; + + const InouButton({ + super.key, + required this.text, + this.variant = ButtonVariant.primary, + this.size = ButtonSize.regular, + this.fullWidth = false, + this.onPressed, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final isSmall = size == ButtonSize.small; + final padding = isSmall + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 6) + : const EdgeInsets.symmetric(horizontal: 18, vertical: 10); + + final style = _getStyle(); + + Widget button = TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + backgroundColor: style.background, + foregroundColor: style.foreground, + padding: padding, + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + side: style.border, + ), + textStyle: InouText.label.copyWith( + fontSize: 15, // Always 15px (1rem) for both sizes per styleguide + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + icon!, + const SizedBox(width: 6), + ], + Text(text), + ], + ), + ); + + if (fullWidth) { + button = SizedBox(width: double.infinity, child: button); + } + + return button; + } + + _ButtonStyle _getStyle() { + switch (variant) { + case ButtonVariant.primary: + return _ButtonStyle( + background: InouTheme.accent, + foreground: Colors.white, + border: BorderSide.none, + ); + case ButtonVariant.secondary: + return _ButtonStyle( + background: InouTheme.bgCard, + foreground: InouTheme.text, + border: const BorderSide(color: InouTheme.border), + ); + case ButtonVariant.danger: + return _ButtonStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: BorderSide(color: InouTheme.danger.withOpacity(0.3)), + ); + } + } +} + +class _ButtonStyle { + final Color background; + final Color foreground; + final BorderSide border; + + _ButtonStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} + +/// Icon button (matches .btn-icon) +class InouIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + final Color? color; + + const InouIconButton({ + super.key, + required this.icon, + this.onPressed, + this.color, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon), + onPressed: onPressed, + color: color ?? InouTheme.textSubtle, + iconSize: 20, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(), + ); + } +} diff --git a/app/lib/design/widgets/inou_card.dart b/app/lib/design/widgets/inou_card.dart new file mode 100644 index 0000000..a90ba7e --- /dev/null +++ b/app/lib/design/widgets/inou_card.dart @@ -0,0 +1,310 @@ +// AUTO-GENERATED widget — matches web .data-card +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/inou_badge.dart'; +import 'package:inou_app/design/widgets/inou_button.dart'; + +/// Data card with colored indicator bar +class InouCard extends StatelessWidget { + final String? title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + final Widget? child; + final VoidCallback? onTap; + + const InouCard({ + super.key, + this.title, + this.subtitle, + this.indicatorColor = InouTheme.accent, + this.trailing, + this.child, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) + _Header( + title: title!, + subtitle: subtitle, + indicatorColor: indicatorColor, + trailing: trailing, + ), + if (child != null) child!, + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + final String title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + + const _Header({ + required this.title, + this.subtitle, + required this.indicatorColor, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(InouTheme.spaceLg), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Row( + children: [ + Container( + width: 4, + height: 32, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: InouTheme.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: InouText.labelCaps, + ), + if (subtitle != null) + Text( + subtitle!, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +/// Simple card without indicator +class InouSimpleCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final VoidCallback? onTap; + + const InouSimpleCard({ + super.key, + required this.child, + this.padding, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final card = Container( + padding: padding ?? const EdgeInsets.all(InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: child, + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: card, + ); + } + return card; + } +} + +/// Profile card for dashboard +class InouProfileCard extends StatelessWidget { + final String name; + final String? role; + final String? dob; + final String? sex; + final List stats; + final bool isCare; + final VoidCallback? onTap; + final VoidCallback? onEdit; + + const InouProfileCard({ + super.key, + required this.name, + this.role, + this.dob, + this.sex, + this.stats = const [], + this.isCare = false, + this.onTap, + this.onEdit, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(name, style: InouText.h3), + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text('✎', style: TextStyle(color: InouTheme.textMuted)), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + role ?? 'you', + style: InouText.bodySmall.copyWith(color: InouTheme.textSubtle), + ), + if (isCare) ...[ + const SizedBox(width: 8), + const InouBadge(text: 'care', variant: BadgeVariant.care), + ], + ], + ), + if (dob != null) ...[ + const SizedBox(height: 8), + Text( + 'Born: $dob${sex != null ? ' · $sex' : ''}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (stats.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: stats.map((s) => _StatChip(stat: s)).toList(), + ), + ], + const Spacer(), + InouButton( + text: 'View', + size: ButtonSize.small, + onPressed: onTap, + ), + ], + ), + ), + ); + } +} + +class ProfileStat { + final String emoji; + final String label; + + const ProfileStat(this.emoji, this.label); +} + +class _StatChip extends StatelessWidget { + final ProfileStat stat; + + const _StatChip({required this.stat}); + + @override + Widget build(BuildContext context) { + return Text( + '${stat.emoji} ${stat.label}', + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + fontSize: 12, + ), + ); + } +} + +/// Add card (dashed border) +class InouAddCard extends StatelessWidget { + final String label; + final VoidCallback? onTap; + + const InouAddCard({ + super.key, + required this.label, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: InouTheme.borderRadiusLg, + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, // Note: Flutter doesn't support dashed directly + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '+', + style: TextStyle( + fontSize: 28, + color: InouTheme.accent, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 6), + Text( + label, + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/design/widgets/inou_data_row.dart b/app/lib/design/widgets/inou_data_row.dart new file mode 100644 index 0000000..2119246 --- /dev/null +++ b/app/lib/design/widgets/inou_data_row.dart @@ -0,0 +1,233 @@ +// AUTO-GENERATED widget — matches web .data-row +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +/// Expandable data row (for imaging, labs, etc.) +class InouDataRow extends StatefulWidget { + final String label; + final String? meta; + final String? date; + final String? value; + final bool isExpandable; + final List? children; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final bool initiallyExpanded; + + const InouDataRow({ + super.key, + required this.label, + this.meta, + this.date, + this.value, + this.isExpandable = false, + this.children, + this.leading, + this.trailing, + this.onTap, + this.initiallyExpanded = false, + }); + + @override + State createState() => _InouDataRowState(); +} + +class _InouDataRowState extends State { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InkWell( + onTap: widget.isExpandable + ? () => setState(() => _expanded = !_expanded) + : widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + if (widget.isExpandable) + SizedBox( + width: 20, + child: Text( + _expanded ? '−' : '+', + style: TextStyle( + color: InouTheme.textMuted, + fontSize: 14, + fontFamily: 'monospace', + ), + ), + ) + else if (widget.leading == null) + const SizedBox(width: 32), + if (widget.leading != null) ...[ + widget.leading!, + const SizedBox(width: 12), + ], + Expanded( + child: Text( + widget.label, + style: InouText.body.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (widget.value != null) + Text( + widget.value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: InouTheme.text, + ), + ), + if (widget.meta != null) ...[ + const SizedBox(width: 16), + Text( + widget.meta!, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + if (widget.date != null) ...[ + const SizedBox(width: 16), + Text( + widget.date!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + if (widget.trailing != null) ...[ + const SizedBox(width: 8), + widget.trailing!, + ], + ], + ), + ), + ), + if (_expanded && widget.children != null) + Container( + color: InouTheme.bg, + child: Column(children: widget.children!), + ), + ], + ); + } +} + +/// Child row (indented) +class InouChildRow extends StatelessWidget { + final String label; + final String? value; + final String? meta; + final Widget? trailing; + final Color? valueColor; + + const InouChildRow({ + super.key, + required this.label, + this.value, + this.meta, + this.trailing, + this.valueColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: InouTheme.spaceXxxl), // 48px indent per styleguide + Expanded( + child: Text( + label, + style: InouText.body, + ), + ), + if (value != null) + Text( + value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: valueColor ?? InouTheme.text, + ), + ), + if (meta != null) ...[ + const SizedBox(width: 16), + Text( + meta!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + ); + } +} + +/// Icon for notes/vitals +class InouNoteIcon extends StatelessWidget { + final String emoji; + final Color color; + + const InouNoteIcon({ + super.key, + required this.emoji, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 16)), + ); + } +} diff --git a/app/lib/design/widgets/inou_footer.dart b/app/lib/design/widgets/inou_footer.dart new file mode 100644 index 0000000..ffb01d8 --- /dev/null +++ b/app/lib/design/widgets/inou_footer.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +/// Footer link group +class FooterLinkGroup { + final String title; + final List links; + + const FooterLinkGroup({ + required this.title, + required this.links, + }); +} + +/// Individual footer link +class FooterLink { + final String label; + final String route; + final bool isExternal; + + const FooterLink({ + required this.label, + required this.route, + this.isExternal = false, + }); +} + +/// inou Footer - responsive, matches web design +class InouFooter extends StatelessWidget { + final List linkGroups; + final Function(FooterLink)? onLinkTap; + final String? copyrightText; + + const InouFooter({ + super.key, + this.linkGroups = const [], + this.onLinkTap, + this.copyrightText, + }); + + static final defaultLinkGroups = [ + const FooterLinkGroup( + title: 'Product', + links: [ + FooterLink(label: 'Features', route: '/features'), + FooterLink(label: 'Security', route: '/security'), + FooterLink(label: 'FAQ', route: '/faq'), + ], + ), + const FooterLinkGroup( + title: 'Legal', + links: [ + FooterLink(label: 'Privacy', route: '/privacy'), + FooterLink(label: 'Terms', route: '/terms'), + FooterLink(label: 'DPA', route: '/dpa'), + ], + ), + const FooterLinkGroup( + title: 'Connect', + links: [ + FooterLink(label: 'Contact', route: '/contact'), + FooterLink(label: 'Invite a friend', route: '/invite'), + ], + ), + ]; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 768; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border( + top: BorderSide(color: InouTheme.border, width: 1), + ), + ), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: isMobile ? 32 : 48, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + isMobile + ? _buildMobileLinks(context) + : _buildDesktopLinks(context), + const SizedBox(height: 32), + _buildBottomBar(context, isMobile), + ], + ), + ), + ), + ); + } + + Widget _buildDesktopLinks(BuildContext context) { + final groups = linkGroups.isEmpty ? defaultLinkGroups : linkGroups; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Logo and tagline + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'inou', + style: InouText.h3.copyWith( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 12), + Text( + 'Your health, understood.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + ], + ), + ), + + // Link groups + for (final group in groups) ...[ + const SizedBox(width: 48), + Expanded( + child: _buildLinkGroup(context, group), + ), + ], + ], + ); + } + + Widget _buildMobileLinks(BuildContext context) { + final groups = linkGroups.isEmpty ? defaultLinkGroups : linkGroups; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Logo + Text( + 'inou', + style: InouText.h3.copyWith( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 8), + Text( + 'Your health, understood.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 32), + + // Links in 2-column grid + Wrap( + spacing: 32, + runSpacing: 24, + children: [ + for (final group in groups) + SizedBox( + width: 140, + child: _buildLinkGroup(context, group), + ), + ], + ), + ], + ); + } + + Widget _buildLinkGroup(BuildContext context, FooterLinkGroup group) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.title.toUpperCase(), + style: InouText.labelCaps.copyWith( + color: InouTheme.textMuted, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 12), + for (final link in group.links) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () => _handleLinkTap(context, link), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + link.label, + style: InouText.bodySmall.copyWith( + color: InouTheme.text, + ), + ), + if (link.isExternal) ...[ + const SizedBox(width: 4), + Icon( + Icons.open_in_new, + size: 12, + color: InouTheme.textMuted, + ), + ], + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildBottomBar(BuildContext context, bool isMobile) { + final year = DateTime.now().year; + final copyright = copyrightText ?? '© $year inou health. All rights reserved.'; + + return Container( + padding: const EdgeInsets.only(top: 24), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: InouTheme.border, width: 1), + ), + ), + child: isMobile + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + copyright, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 12), + _buildSocialLinks(), + ], + ) + : Row( + children: [ + Text( + copyright, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + const Spacer(), + _buildSocialLinks(), + ], + ), + ); + } + + Widget _buildSocialLinks() { + // Placeholder for social links if needed + return const SizedBox.shrink(); + } + + void _handleLinkTap(BuildContext context, FooterLink link) { + if (onLinkTap != null) { + onLinkTap!(link); + return; + } + + if (link.isExternal) { + // Handle external links (url_launcher) + return; + } + + Navigator.pushNamed(context, link.route); + } +} diff --git a/app/lib/design/widgets/inou_header.dart b/app/lib/design/widgets/inou_header.dart new file mode 100644 index 0000000..fc6effb --- /dev/null +++ b/app/lib/design/widgets/inou_header.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/main.dart'; +import 'package:inou_app/core/locale_provider.dart'; + +/// Navigation item for header +class NavItem { + final String label; + final String route; + final bool isExternal; + + const NavItem({ + required this.label, + required this.route, + this.isExternal = false, + }); +} + +/// inou Header - responsive, matches web design with language switcher +class InouHeader extends StatelessWidget { + final VoidCallback? onLogoTap; + final List navItems; + final String? currentRoute; + final VoidCallback? onLoginTap; + final VoidCallback? onSignupTap; + final bool isLoggedIn; + final String? userName; + final VoidCallback? onProfileTap; + final VoidCallback? onLogoutTap; + + const InouHeader({ + super.key, + this.onLogoTap, + this.navItems = const [], + this.currentRoute, + this.onLoginTap, + this.onSignupTap, + this.isLoggedIn = false, + this.userName, + this.onProfileTap, + this.onLogoutTap, + }); + + static const defaultNavItems = [ + NavItem(label: 'Dossiers', route: '/dossiers'), + NavItem(label: 'Privacy', route: '/privacy'), + NavItem(label: 'Connect', route: '/connect'), + NavItem(label: 'Invite a friend', route: '/invite'), + NavItem(label: 'Demo', route: '/demo'), + ]; + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 768; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border( + bottom: BorderSide(color: InouTheme.border, width: 1), + ), + ), + child: SafeArea( + bottom: false, + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: 12, + ), + child: isMobile ? _buildMobileHeader(context) : _buildDesktopHeader(context), + ), + ), + ), + ); + } + + Widget _buildDesktopHeader(BuildContext context) { + return Row( + children: [ + // Logo + _buildLogo(context), + + const SizedBox(width: 48), + + // Navigation + Expanded( + child: Row( + children: [ + for (final item in navItems.isEmpty ? defaultNavItems : navItems) + _buildNavItem(context, item), + ], + ), + ), + + // Language switcher + _LanguageSwitcher(), + + const SizedBox(width: 16), + + // Auth buttons + _buildAuthSection(context), + ], + ); + } + + Widget _buildMobileHeader(BuildContext context) { + return Row( + children: [ + _buildLogo(context), + const Spacer(), + _LanguageSwitcher(), + const SizedBox(width: 8), + _buildMobileMenuButton(context), + ], + ); + } + + Widget _buildLogo(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return GestureDetector( + onTap: onLogoTap, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + 'inou', + style: InouText.logo.copyWith( + color: InouTheme.accent, + ), + ), + Text( + 'health', + style: InouText.logoLight.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(width: 12), + Text( + l10n?.appTagline ?? 'ai answers for you', + style: InouText.logoTagline, + ), + ], + ), + ); + } + + Widget _buildNavItem(BuildContext context, NavItem item) { + final isActive = currentRoute == item.route; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: InkWell( + onTap: () => _navigateTo(context, item), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text( + item.label, + style: isActive ? InouText.navActive : InouText.nav, + ), + ), + ), + ); + } + + Widget _buildAuthSection(BuildContext context) { + final l10n = AppLocalizations.of(context); + + if (isLoggedIn) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onProfileTap, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: InouTheme.accentLight, + child: Text( + (userName ?? 'U')[0].toUpperCase(), + style: InouText.bodySmall.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + userName ?? 'Account', + style: InouText.body, + ), + ], + ), + ), + ), + ], + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: onLoginTap, + child: Text( + l10n?.signIn ?? 'Log in', + style: InouText.nav, + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: onSignupTap, + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + ), + ), + child: Text(l10n?.getStarted ?? 'Get started', style: InouText.button.copyWith(color: Colors.white)), + ), + ], + ); + } + + Widget _buildMobileMenuButton(BuildContext context) { + return IconButton( + icon: const Icon(Icons.menu, color: InouTheme.text), + onPressed: () => _showMobileMenu(context), + ); + } + + void _showMobileMenu(BuildContext context) { + final l10n = AppLocalizations.of(context); + + showModalBottomSheet( + context: context, + backgroundColor: InouTheme.bgCard, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in navItems.isEmpty ? defaultNavItems : navItems) + ListTile( + title: Text(item.label, style: InouText.body), + trailing: item.isExternal + ? Icon(Icons.open_in_new, size: 18, color: InouTheme.textMuted) + : null, + onTap: () { + Navigator.pop(context); + _navigateTo(context, item); + }, + ), + const Divider(height: 32), + if (!isLoggedIn) ...[ + OutlinedButton( + onPressed: () { + Navigator.pop(context); + onLoginTap?.call(); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Text(l10n?.signIn ?? 'Log in', style: InouText.button), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + onSignupTap?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Text(l10n?.getStarted ?? 'Get started', style: InouText.button.copyWith(color: Colors.white)), + ), + ] else ...[ + ListTile( + leading: CircleAvatar( + backgroundColor: InouTheme.accentLight, + child: Text( + (userName ?? 'U')[0].toUpperCase(), + style: InouText.bodySmall.copyWith(color: InouTheme.accent), + ), + ), + title: Text(userName ?? 'Account', style: InouText.body), + onTap: () { + Navigator.pop(context); + onProfileTap?.call(); + }, + ), + ListTile( + leading: const Icon(Icons.logout), + title: Text('Log out', style: InouText.body), + onTap: () { + Navigator.pop(context); + onLogoutTap?.call(); + }, + ), + ], + ], + ), + ), + ), + ); + } + + void _navigateTo(BuildContext context, NavItem item) { + if (item.isExternal) { + // Handle external links (url_launcher would be needed) + return; + } + Navigator.pushNamed(context, item.route); + } +} + +/// Language switcher dropdown matching Go version .lang-menu +class _LanguageSwitcher extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: localeNotifier, + builder: (context, locale, _) { + final currentCode = LocaleProvider.localeCodes[locale.languageCode] ?? 'EN'; + + return PopupMenuButton( + offset: const Offset(0, 40), + tooltip: 'Change language', + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + ), + color: InouTheme.bgCard, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + currentCode, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down, + size: 16, + color: InouTheme.textMuted, + ), + ], + ), + ), + onSelected: (selectedLocale) { + InouApp.setLocale(context, selectedLocale); + }, + itemBuilder: (context) => [ + for (final supportedLocale in LocaleProvider.supportedLocales) + PopupMenuItem( + value: supportedLocale, + child: Row( + children: [ + Text( + LocaleProvider.localeNames[supportedLocale.languageCode] ?? '', + style: InouText.bodySmall.copyWith( + color: locale.languageCode == supportedLocale.languageCode + ? InouTheme.accent + : InouTheme.text, + fontWeight: locale.languageCode == supportedLocale.languageCode + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + if (locale.languageCode == supportedLocale.languageCode) ...[ + const SizedBox(width: 8), + Icon(Icons.check, size: 16, color: InouTheme.accent), + ], + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/app/lib/design/widgets/inou_input.dart b/app/lib/design/widgets/inou_input.dart new file mode 100644 index 0000000..b01ecae --- /dev/null +++ b/app/lib/design/widgets/inou_input.dart @@ -0,0 +1,292 @@ +// Form input widgets +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +/// Text input field with validation support +class InouTextField extends StatelessWidget { + final String? label; + final String? placeholder; + final TextEditingController? controller; + final bool obscureText; + final TextInputType? keyboardType; + final int? maxLength; + final int? maxLines; + final bool isCode; + final ValueChanged? onChanged; + final FormFieldValidator? validator; + final Widget? suffixIcon; + final Iterable? autofillHints; + final bool enabled; + + const InouTextField({ + super.key, + this.label, + this.placeholder, + this.controller, + this.obscureText = false, + this.keyboardType, + this.maxLength, + this.maxLines = 1, + this.isCode = false, + this.onChanged, + this.validator, + this.suffixIcon, + this.autofillHints, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: InouText.label, + ), + const SizedBox(height: 4), + ], + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + maxLength: maxLength, + maxLines: maxLines, + enabled: enabled, + textAlign: isCode ? TextAlign.center : TextAlign.start, + onChanged: onChanged, + validator: validator, + autofillHints: autofillHints, + style: isCode + ? const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + letterSpacing: 8, + fontFamily: 'SF Mono', + ) + : InouText.body, + decoration: InputDecoration( + hintText: placeholder, + counterText: '', + suffixIcon: suffixIcon, + filled: true, + fillColor: InouTheme.bgCard, + border: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.accent, width: 1), // 1px per styleguide + ), + errorBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.danger), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: InouTheme.borderRadiusMd, + borderSide: BorderSide(color: InouTheme.danger, width: 1), // 1px per styleguide + ), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: maxLines != null && maxLines! > 1 ? 12 : 14, + ), + ), + ), + ], + ); + } +} + +/// Dropdown select +class InouSelect extends StatelessWidget { + final String? label; + final T? value; + final List> options; + final ValueChanged? onChanged; + + const InouSelect({ + super.key, + this.label, + this.value, + required this.options, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text(label!, style: InouText.label), + const SizedBox(height: 4), + ], + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusMd, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + items: options + .map((o) => DropdownMenuItem( + value: o.value, + child: Text(o.label), + )) + .toList(), + onChanged: onChanged, + ), + ), + ), + ], + ); + } +} + +class InouSelectOption { + final T value; + final String label; + + const InouSelectOption({required this.value, required this.label}); +} + +/// Radio group +class InouRadioGroup extends StatelessWidget { + final String? label; + final String? hint; + final T? value; + final List> options; + final ValueChanged? onChanged; + final Axis direction; + + const InouRadioGroup({ + super.key, + this.label, + this.hint, + this.value, + required this.options, + this.onChanged, + this.direction = Axis.horizontal, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text(label!, style: InouText.label), + if (hint != null) ...[ + const SizedBox(height: 2), + Text( + hint!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + const SizedBox(height: 8), + ], + direction == Axis.horizontal + ? Row( + children: _buildOptions(), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildOptions(), + ), + ], + ); + } + + List _buildOptions() { + return options.map((option) { + return Padding( + padding: EdgeInsets.only( + right: direction == Axis.horizontal ? 16 : 0, + bottom: direction == Axis.vertical ? 8 : 0, + ), + child: InkWell( + onTap: () => onChanged?.call(option.value), + borderRadius: BorderRadius.circular(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Radio( + value: option.value, + groupValue: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Text(option.label, style: InouText.body), + ], + ), + ), + ); + }).toList(); + } +} + +class InouRadioOption { + final T value; + final String label; + + const InouRadioOption({required this.value, required this.label}); +} + +/// Checkbox with optional custom child +class InouCheckbox extends StatelessWidget { + final bool value; + final String? label; + final Widget? child; + final ValueChanged? onChanged; + + const InouCheckbox({ + super.key, + required this.value, + this.label, + this.child, + this.onChanged, + }) : assert(label != null || child != null, 'Provide either label or child'); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged?.call(!value), + borderRadius: BorderRadius.circular(4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 8), + Expanded( + child: child ?? + Text( + label!, + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/design/widgets/inou_message.dart b/app/lib/design/widgets/inou_message.dart new file mode 100644 index 0000000..f9b886e --- /dev/null +++ b/app/lib/design/widgets/inou_message.dart @@ -0,0 +1,70 @@ +// AUTO-GENERATED widget — matches web .error/.info/.success +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; + +enum MessageType { error, info, success } + +class InouMessage extends StatelessWidget { + final String message; + final MessageType type; + + const InouMessage({ + super.key, + required this.message, + this.type = MessageType.info, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: style.background, + border: Border.all(color: style.border), + borderRadius: BorderRadius.circular(InouTheme.radiusMd), + ), + child: Text( + message, + style: InouText.body.copyWith(color: style.foreground), + ), + ); + } + + _MessageStyle _getStyle() { + switch (type) { + case MessageType.error: + return _MessageStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: InouTheme.errorBorder, + ); + case MessageType.info: + return _MessageStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + border: InouTheme.infoBorder, + ); + case MessageType.success: + return _MessageStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + border: InouTheme.successBorder, + ); + } + } +} + +class _MessageStyle { + final Color background; + final Color foreground; + final Color border; + + _MessageStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} diff --git a/app/lib/design/widgets/inou_page.dart b/app/lib/design/widgets/inou_page.dart new file mode 100644 index 0000000..6a14ea1 --- /dev/null +++ b/app/lib/design/widgets/inou_page.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/inou_header.dart'; +import 'package:inou_app/design/widgets/inou_footer.dart'; + +/// Page scaffold with header and footer +/// +/// Use [InouPage] for public pages (landing, security, FAQ, etc.) +/// Use [InouAuthPage] for authenticated pages (dashboard, dossier) +class InouPage extends StatelessWidget { + final Widget child; + final String? currentRoute; + final bool showHeader; + final bool showFooter; + final List? navItems; + final bool isLoggedIn; + final String? userName; + final VoidCallback? onLoginTap; + final VoidCallback? onSignupTap; + final VoidCallback? onProfileTap; + final VoidCallback? onLogoutTap; + final EdgeInsets? padding; + final bool centerContent; + final double? maxWidth; + + const InouPage({ + super.key, + required this.child, + this.currentRoute, + this.showHeader = true, + this.showFooter = true, + this.navItems, + this.isLoggedIn = false, + this.userName, + this.onLoginTap, + this.onSignupTap, + this.onProfileTap, + this.onLogoutTap, + this.padding, + this.centerContent = true, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: Column( + children: [ + if (showHeader) + InouHeader( + currentRoute: currentRoute, + navItems: navItems ?? InouHeader.defaultNavItems, + isLoggedIn: isLoggedIn, + userName: userName, + onLogoTap: () => Navigator.pushNamedAndRemoveUntil( + context, + '/', + (route) => false, + ), + onLoginTap: onLoginTap ?? () => Navigator.pushNamed(context, '/login'), + onSignupTap: onSignupTap ?? () => Navigator.pushNamed(context, '/signup'), + onProfileTap: onProfileTap, + onLogoutTap: onLogoutTap, + ), + + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _buildContent(context), + if (showFooter) const InouFooter(), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildContent(BuildContext context) { + final content = centerContent + ? Center( + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth ?? InouTheme.maxWidth, + ), + padding: padding ?? const EdgeInsets.all(24), + child: child, + ), + ) + : Padding( + padding: padding ?? const EdgeInsets.all(24), + child: child, + ); + + return content; + } +} + +/// Authenticated page scaffold with mandatory header +/// +/// For deep pages like dashboard and dossier +class InouAuthPage extends StatelessWidget { + final Widget child; + final String? currentRoute; + final String? title; + final List? actions; + final String userName; + final VoidCallback onProfileTap; + final VoidCallback onLogoutTap; + final bool showBackButton; + final VoidCallback? onBackTap; + final EdgeInsets? padding; + final double? maxWidth; + + const InouAuthPage({ + super.key, + required this.child, + this.currentRoute, + this.title, + this.actions, + required this.userName, + required this.onProfileTap, + required this.onLogoutTap, + this.showBackButton = false, + this.onBackTap, + this.padding, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: SafeArea( + child: Column( + children: [ + _buildAuthHeader(context), + Expanded( + child: SingleChildScrollView( + child: Center( + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth ?? InouTheme.maxWidth, + ), + padding: padding ?? const EdgeInsets.all(24), + child: child, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAuthHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border( + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Row( + children: [ + if (showBackButton) + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBackTap ?? () => Navigator.pop(context), + color: InouTheme.text, + ), + + if (title != null) ...[ + if (showBackButton) const SizedBox(width: 8), + Text(title!, style: InouText.h3), + ] else ...[ + // Logo + GestureDetector( + onTap: () => Navigator.pushNamedAndRemoveUntil( + context, + '/dashboard', + (route) => false, + ), + child: Text( + 'inou', + style: InouText.h3.copyWith( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + ), + ], + + const Spacer(), + + if (actions != null) + Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ), + + const SizedBox(width: 12), + + // User menu + PopupMenuButton( + offset: const Offset(0, 48), + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: InouTheme.accentLight, + child: Text( + userName[0].toUpperCase(), + style: InouText.bodySmall.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Icon(Icons.keyboard_arrow_down, color: InouTheme.textMuted, size: 20), + ], + ), + onSelected: (value) { + switch (value) { + case 'profile': + onProfileTap(); + break; + case 'logout': + onLogoutTap(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'profile', + child: Row( + children: [ + Icon(Icons.person_outline, size: 20, color: InouTheme.text), + const SizedBox(width: 12), + Text('Profile', style: InouText.body), + ], + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout, size: 20, color: InouTheme.danger), + const SizedBox(width: 12), + Text('Log out', style: InouText.body.copyWith(color: InouTheme.danger)), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +/// Minimal page for auth flows (login, signup, forgot password) +class InouAuthFlowPage extends StatelessWidget { + final Widget child; + final bool showLogo; + final double? maxWidth; + + const InouAuthFlowPage({ + super.key, + required this.child, + this.showLogo = true, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth ?? InouTheme.maxWidthForm, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (showLogo) ...[ + Text( + 'inou', + style: InouTheme.pageTitle.copyWith( + color: InouTheme.accent, + letterSpacing: -1, + ), + ), + const SizedBox(height: 8), + Text( + 'Your health, understood.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 48), + ], + child, + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/design/widgets/widgets.dart b/app/lib/design/widgets/widgets.dart new file mode 100644 index 0000000..48e2ef5 --- /dev/null +++ b/app/lib/design/widgets/widgets.dart @@ -0,0 +1,12 @@ +// Barrel file for all inou widgets +export 'inou_card.dart'; +export 'inou_button.dart'; +export 'inou_badge.dart'; +export 'inou_message.dart'; +export 'inou_input.dart'; +export 'inou_data_row.dart'; + +// Layout components +export 'inou_header.dart'; +export 'inou_footer.dart'; +export 'inou_page.dart'; diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart new file mode 100644 index 0000000..ca4d3f4 --- /dev/null +++ b/app/lib/features/auth/auth.dart @@ -0,0 +1,3 @@ +// Barrel file for auth pages +export 'login_page.dart'; +export 'signup_page.dart'; diff --git a/app/lib/features/auth/login_page.dart b/app/lib/features/auth/login_page.dart new file mode 100644 index 0000000..0318dd3 --- /dev/null +++ b/app/lib/features/auth/login_page.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Login page with biometric support +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _obscurePassword = true; + bool _rememberMe = false; + + // Biometric state + bool _biometricAvailable = false; + bool _biometricEnrolled = false; + + @override + void initState() { + super.initState(); + _checkBiometricAvailability(); + } + + Future _checkBiometricAvailability() async { + // TODO: Check actual biometric availability using local_auth + // For now, simulate availability on mobile + setState(() { + _biometricAvailable = true; // Placeholder + _biometricEnrolled = false; // Placeholder - check if user has enrolled + }); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InouAuthFlowPage( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Welcome back', + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Sign in to access your health dossier', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 32), + + // Biometric login button (if available and enrolled) + if (_biometricAvailable && _biometricEnrolled) ...[ + _buildBiometricButton(), + const SizedBox(height: 24), + _buildDivider('or sign in with email'), + const SizedBox(height: 24), + ], + + // Email field + InouTextField( + label: 'Email', + controller: _emailController, + placeholder: 'you@example.com', + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Password field + InouTextField( + label: 'Password', + controller: _passwordController, + placeholder: '••••••••', + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + color: InouTheme.textMuted, + size: 20, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Remember me & forgot password row + Row( + children: [ + InouCheckbox( + value: _rememberMe, + label: 'Remember me', + onChanged: (value) { + setState(() => _rememberMe = value ?? false); + }, + ), + const Spacer(), + TextButton( + onPressed: _handleForgotPassword, + child: Text( + 'Forgot password?', + style: InouText.bodySmall.copyWith( + color: InouTheme.accent, + ), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Login button + InouButton( + text: _isLoading ? 'Signing in...' : 'Sign in', + onPressed: _isLoading ? null : _handleLogin, + ), + + const SizedBox(height: 24), + + // Sign up link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Don\'t have an account? ', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + TextButton( + onPressed: () => Navigator.pushReplacementNamed(context, '/signup'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Sign up', + style: InouText.body.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildBiometricButton() { + return OutlinedButton.icon( + onPressed: _handleBiometricLogin, + icon: const Icon(Icons.fingerprint, size: 24), + label: const Text('Sign in with biometrics'), + style: OutlinedButton.styleFrom( + foregroundColor: InouTheme.text, + side: BorderSide(color: InouTheme.border), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + ), + ), + ); + } + + Widget _buildDivider(String text) { + return Row( + children: [ + Expanded(child: Divider(color: InouTheme.border)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + text, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ), + Expanded(child: Divider(color: InouTheme.border)), + ], + ); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + // TODO: Implement actual login + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + // After successful login, offer biometric enrollment if available + if (_biometricAvailable && !_biometricEnrolled) { + await _offerBiometricEnrollment(); + } + + // Navigate to dashboard + Navigator.pushNamedAndRemoveUntil( + context, + '/dashboard', + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Login failed: ${e.toString()}'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _handleBiometricLogin() async { + // TODO: Implement biometric authentication using local_auth + // On success, retrieve stored credentials and login + } + + Future _handleForgotPassword() async { + // Navigate to forgot password flow + Navigator.pushNamed(context, '/forgot-password'); + } + + Future _offerBiometricEnrollment() async { + final shouldEnroll = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusLg, + ), + title: const Text('Enable biometric login?'), + content: const Text( + 'Sign in faster next time using your fingerprint or face.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text( + 'Not now', + style: TextStyle(color: InouTheme.textMuted), + ), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + ), + child: const Text('Enable'), + ), + ], + ), + ); + + if (shouldEnroll == true) { + // TODO: Enroll biometric credentials + // Store encrypted credentials securely + } + } +} diff --git a/app/lib/features/auth/signup_page.dart b/app/lib/features/auth/signup_page.dart new file mode 100644 index 0000000..d0c7269 --- /dev/null +++ b/app/lib/features/auth/signup_page.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Signup page with step-by-step flow +class SignupPage extends StatefulWidget { + const SignupPage({super.key}); + + @override + State createState() => _SignupPageState(); +} + +class _SignupPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirm = true; + bool _acceptedTerms = false; + + // Additional profile info + DateTime? _dateOfBirth; + String? _sex; + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InouAuthFlowPage( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Create your account', + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Start understanding your health better', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 32), + + // Name field + InouTextField( + label: 'Full name', + controller: _nameController, + placeholder: 'Your name', + autofillHints: const [AutofillHints.name], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your name'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Email field + InouTextField( + label: 'Email', + controller: _emailController, + placeholder: 'you@example.com', + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Date of birth + _buildDateOfBirthField(), + const SizedBox(height: 16), + + // Sex selection + InouRadioGroup( + label: 'Biological sex', + hint: 'Used for accurate medical context', + value: _sex, + options: const [ + InouRadioOption(value: 'male', label: 'Male'), + InouRadioOption(value: 'female', label: 'Female'), + ], + onChanged: (value) { + setState(() => _sex = value); + }, + ), + const SizedBox(height: 16), + + // Password field + InouTextField( + label: 'Password', + controller: _passwordController, + placeholder: 'At least 8 characters', + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.newPassword], + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + color: InouTheme.textMuted, + size: 20, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a password'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Confirm password field + InouTextField( + label: 'Confirm password', + controller: _confirmPasswordController, + placeholder: 'Re-enter your password', + obscureText: _obscureConfirm, + autofillHints: const [AutofillHints.newPassword], + suffixIcon: IconButton( + icon: Icon( + _obscureConfirm ? Icons.visibility_off : Icons.visibility, + color: InouTheme.textMuted, + size: 20, + ), + onPressed: () { + setState(() => _obscureConfirm = !_obscureConfirm); + }, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 20), + + // Terms acceptance + InouCheckbox( + value: _acceptedTerms, + onChanged: (value) { + setState(() => _acceptedTerms = value ?? false); + }, + child: RichText( + text: TextSpan( + style: InouText.bodySmall.copyWith(color: InouTheme.text), + children: [ + const TextSpan(text: 'I agree to the '), + TextSpan( + text: 'Terms of Service', + style: TextStyle(color: InouTheme.accent), + // TODO: Make tappable + ), + const TextSpan(text: ' and '), + TextSpan( + text: 'Privacy Policy', + style: TextStyle(color: InouTheme.accent), + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // Sign up button + InouButton( + text: _isLoading ? 'Creating account...' : 'Create account', + onPressed: (_isLoading || !_acceptedTerms) ? null : _handleSignup, + ), + + const SizedBox(height: 24), + + // Login link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Already have an account? ', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + TextButton( + onPressed: () => Navigator.pushReplacementNamed(context, '/login'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Sign in', + style: InouText.body.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildDateOfBirthField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Date of birth', + style: InouText.label, + ), + const SizedBox(height: 4), + Text( + 'Used for accurate medical context', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(height: 8), + InkWell( + onTap: _selectDateOfBirth, + borderRadius: InouTheme.borderRadiusMd, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusMd, + border: Border.all(color: InouTheme.border), + ), + child: Row( + children: [ + Expanded( + child: Text( + _dateOfBirth != null + ? '${_dateOfBirth!.month}/${_dateOfBirth!.day}/${_dateOfBirth!.year}' + : 'Select date', + style: InouText.body.copyWith( + color: _dateOfBirth != null + ? InouTheme.text + : InouTheme.textMuted, + ), + ), + ), + Icon( + Icons.calendar_today, + size: 20, + color: InouTheme.textMuted, + ), + ], + ), + ), + ), + ], + ); + } + + Future _selectDateOfBirth() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _dateOfBirth ?? DateTime(now.year - 30), + firstDate: DateTime(1900), + lastDate: now, + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: InouTheme.accent, + onPrimary: Colors.white, + surface: InouTheme.bgCard, + onSurface: InouTheme.text, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null) { + setState(() => _dateOfBirth = picked); + } + } + + Future _handleSignup() async { + if (!_formKey.currentState!.validate()) return; + + if (_dateOfBirth == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Please select your date of birth'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + if (_sex == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Please select your biological sex'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + setState(() => _isLoading = true); + + try { + // TODO: Implement actual signup + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + // Navigate to email verification or dashboard + Navigator.pushNamedAndRemoveUntil( + context, + '/dashboard', + (route) => false, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Signup failed: ${e.toString()}'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} diff --git a/app/lib/features/dashboard/dashboard.dart b/app/lib/features/dashboard/dashboard.dart new file mode 100644 index 0000000..60a3e99 --- /dev/null +++ b/app/lib/features/dashboard/dashboard.dart @@ -0,0 +1,4 @@ +export 'models.dart'; +export 'mock_data.dart'; +export 'dashboard_page.dart'; +export 'dossier_page.dart'; diff --git a/app/lib/features/dashboard/dashboard_page.dart b/app/lib/features/dashboard/dashboard_page.dart new file mode 100644 index 0000000..a6dbac6 --- /dev/null +++ b/app/lib/features/dashboard/dashboard_page.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; +import 'models.dart'; +import 'mock_data.dart'; + +class DashboardPage extends StatelessWidget { + const DashboardPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouAuthPage( + userName: 'Johan', // TODO: get from auth state + onProfileTap: () => context.go('/profile'), + onLogoutTap: () => context.go('/login'), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text('Dossiers', style: InouText.pageTitle), + const SizedBox(height: 8), + Text( + 'Manage your health dossiers and those shared with you.', + style: InouText.intro, + ), + const SizedBox(height: 32), + + // Dossier grid + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth > 900 + ? 3 + : constraints.maxWidth > 600 + ? 2 + : 1; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.6, + ), + itemCount: mockDossiers.length + 1, // +1 for add card + itemBuilder: (context, index) { + if (index == mockDossiers.length) { + return _AddDossierCard(); + } + return _DossierCard(dossier: mockDossiers[index]); + }, + ); + }, + ), + + const SizedBox(height: 48), + const InouFooter(), + ], + ), + ), + ), + ), + ); + } +} + +class _DossierCard extends StatelessWidget { + final DossierSummary dossier; + + const _DossierCard({required this.dossier}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => context.go('/dossier/${dossier.id}'), + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row with name and edit + Row( + children: [ + Expanded( + child: Text( + dossier.name, + style: InouText.h3.copyWith(fontSize: 20), + overflow: TextOverflow.ellipsis, + ), + ), + if (dossier.canEdit) + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + color: InouTheme.textMuted, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + // TODO: navigate to edit + }, + ), + ], + ), + + // Relation/role + const SizedBox(height: 4), + Row( + children: [ + Text( + dossier.isSelf + ? 'you' + : 'my role: ${dossier.relation ?? "Unknown"}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + if (dossier.isCareReceiver) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.successLight, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'care', + style: InouText.bodySmall.copyWith( + color: InouTheme.success, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + + // DOB & Sex + const SizedBox(height: 8), + Text( + [ + if (dossier.dateOfBirth != null) dossier.dateOfBirth, + if (dossier.sex != null) dossier.sex, + ].join(' · '), + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + + const Spacer(), + + // Stats row + if (dossier.stats.hasAnyData) ...[ + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + if (dossier.stats.imaging > 0) + _StatChip( + '📷 ${dossier.stats.imaging} ${dossier.stats.imaging == 1 ? 'study' : 'studies'}', + ), + if (dossier.stats.labs > 0) + _StatChip( + '🧪 ${dossier.stats.labs} ${dossier.stats.labs == 1 ? 'lab' : 'labs'}', + ), + if (dossier.stats.genome) const _StatChip('🧬 genome'), + if (dossier.stats.documents > 0) + _StatChip( + '📄 ${dossier.stats.documents} ${dossier.stats.documents == 1 ? 'doc' : 'docs'}', + ), + if (dossier.stats.medications > 0) + _StatChip('💊 ${dossier.stats.medications} meds'), + if (dossier.stats.supplements > 0) + _StatChip('🌿 ${dossier.stats.supplements} supps'), + ], + ), + ] else ...[ + Text( + 'No data yet', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + + const SizedBox(height: 12), + + // View button + InouButton( + text: 'View', + size: ButtonSize.small, + onPressed: () => context.go('/dossier/${dossier.id}'), + ), + ], + ), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + final String text; + + const _StatChip(this.text); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + fontSize: 12, + ), + ); + } +} + +class _AddDossierCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + // TODO: navigate to add dossier + }, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, + ), + borderRadius: InouTheme.borderRadiusLg, + ), + child: CustomPaint( + painter: _DashedBorderPainter(), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '+', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w300, + color: InouTheme.accent, + ), + ), + const SizedBox(height: 4), + Text( + 'Add dossier', + style: InouText.body.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DashedBorderPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + // Empty - we use the container border for now + // Could implement dashed border if needed + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/app/lib/features/dashboard/dossier_page.dart b/app/lib/features/dashboard/dossier_page.dart new file mode 100644 index 0000000..63b7e95 --- /dev/null +++ b/app/lib/features/dashboard/dossier_page.dart @@ -0,0 +1,810 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; +import 'models.dart'; +import 'mock_data.dart'; + +class DossierPage extends StatelessWidget { + final String dossierId; + + const DossierPage({super.key, required this.dossierId}); + + @override + Widget build(BuildContext context) { + final data = getDossierById(dossierId); + + if (data == null) { + return InouAuthPage( + userName: 'Johan', + onProfileTap: () => context.go('/profile'), + onLogoutTap: () => context.go('/login'), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Dossier not found', style: InouText.sectionTitle), + const SizedBox(height: 16), + InouButton( + text: '← Back to dossiers', + variant: ButtonVariant.secondary, + onPressed: () => context.go('/dashboard'), + ), + ], + ), + ), + ); + } + + return InouAuthPage( + userName: 'Johan', + onProfileTap: () => context.go('/profile'), + onLogoutTap: () => context.go('/login'), + child: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: InouTheme.spaceXl, + vertical: InouTheme.spaceXxxl, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidthNarrow), // 800px per styleguide + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + _DossierHeader(data: data), + const SizedBox(height: 32), + + // Data sections + _ImagingSection(studies: data.studies, dossierId: dossierId), + _LabsSection(labs: data.labs), + if (data.documents.isNotEmpty) _DocumentsSection(documents: data.documents), + if (data.procedures.isNotEmpty) _ProceduresSection(procedures: data.procedures), + if (data.assessments.isNotEmpty) _AssessmentsSection(assessments: data.assessments), + if (data.hasGenome) _GeneticsSection(categories: data.geneticCategories), + _UploadsSection(count: data.uploadCount, size: data.uploadSize, canEdit: data.canEdit), + if (data.medications.isNotEmpty) _MedicationsSection(medications: data.medications), + if (data.symptoms.isNotEmpty) _SymptomsSection(symptoms: data.symptoms), + if (data.hospitalizations.isNotEmpty) _HospitalizationsSection(hospitalizations: data.hospitalizations), + if (data.therapies.isNotEmpty) _TherapiesSection(therapies: data.therapies), + _VitalsSection(), // Coming soon + _PrivacySection(accessList: data.accessList, dossierId: dossierId, canManageAccess: data.canManageAccess), + + const SizedBox(height: 48), + const InouFooter(), + ], + ), + ), + ), + ), + ); + } +} + +class _DossierHeader extends StatelessWidget { + final DossierData data; + + const _DossierHeader({required this.data}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.dossier.name, style: InouText.pageTitle), + const SizedBox(height: 8), + Text( + [ + if (data.dossier.dateOfBirth != null) 'Born: ${data.dossier.dateOfBirth}', + if (data.dossier.sex != null) data.dossier.sex, + ].join(' · '), + style: InouText.intro, + ), + ], + ), + ), + InouButton( + text: '← Back to dossiers', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () => context.go('/dashboard'), + ), + ], + ); + } +} + +// ============================================ +// DATA SECTION CARDS +// ============================================ + +class _ImagingSection extends StatelessWidget { + final List studies; + final String dossierId; + + const _ImagingSection({required this.studies, required this.dossierId}); + + @override + Widget build(BuildContext context) { + final totalSlices = studies.fold(0, (sum, s) => sum + s.series.fold(0, (ss, ser) => ss + ser.sliceCount)); + + return _DataCard( + title: 'IMAGING', + indicatorColor: InouTheme.indicatorImaging, + summary: studies.isEmpty + ? 'No imaging data' + : '${studies.length} studies, $totalSlices slices', + trailing: studies.isNotEmpty + ? InouButton( + text: 'Open viewer', + size: ButtonSize.small, + onPressed: () { + // TODO: open viewer + }, + ) + : null, + child: studies.isEmpty + ? null + : Column( + children: [ + for (var i = 0; i < studies.length && i < 5; i++) + _ImagingStudyRow(study: studies[i], dossierId: dossierId), + if (studies.length > 5) + _ShowMoreRow( + text: 'Show all ${studies.length} studies', + onTap: () { + // TODO: expand + }, + ), + ], + ), + ); + } +} + +class _ImagingStudyRow extends StatefulWidget { + final ImagingStudy study; + final String dossierId; + + const _ImagingStudyRow({required this.study, required this.dossierId}); + + @override + State<_ImagingStudyRow> createState() => _ImagingStudyRowState(); +} + +class _ImagingStudyRowState extends State<_ImagingStudyRow> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final hasSeries = widget.study.seriesCount > 1; + + return Column( + children: [ + InkWell( + onTap: hasSeries ? () => setState(() => _expanded = !_expanded) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + if (hasSeries) + SizedBox( + width: 20, + child: Text( + _expanded ? '−' : '+', + style: InouText.mono.copyWith(color: InouTheme.textMuted), + ), + ) + else + const SizedBox(width: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.study.description, + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + ), + if (hasSeries) + Text( + '${widget.study.seriesCount} series', + style: InouText.mono, + ), + const SizedBox(width: 16), + Text( + _formatDate(widget.study.date), + style: InouText.mono.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 16, color: InouTheme.accent), + ], + ), + ), + ), + if (_expanded) + Container( + color: InouTheme.bg, + child: Column( + children: [ + for (final series in widget.study.series) + if (series.sliceCount > 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const SizedBox(width: 32), + Expanded( + child: Text( + series.description ?? series.modality, + style: InouText.bodySmall, + ), + ), + Text( + '${series.sliceCount} ${series.sliceCount == 1 ? 'slice' : 'slices'}', + style: InouText.mono, + ), + const SizedBox(width: 8), + Icon(Icons.arrow_forward, size: 14, color: InouTheme.accent), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + ], + ); + } + + String _formatDate(String yyyymmdd) { + if (yyyymmdd.length != 8) return yyyymmdd; + return '${yyyymmdd.substring(4, 6)}/${yyyymmdd.substring(6, 8)}/${yyyymmdd.substring(0, 4)}'; + } +} + +class _LabsSection extends StatelessWidget { + final List labs; + + const _LabsSection({required this.labs}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'LABS', + indicatorColor: InouTheme.indicatorLabs, + summary: labs.isEmpty ? 'No lab data' : '${labs.length} results', + child: labs.isEmpty + ? null + : Column( + children: [ + for (final lab in labs) _DataRow(item: lab), + ], + ), + ); + } +} + +class _DocumentsSection extends StatelessWidget { + final List documents; + + const _DocumentsSection({required this.documents}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'RECORDS', + indicatorColor: InouTheme.indicatorRecords, + summary: '${documents.length} documents', + child: Column( + children: [ + for (final doc in documents) _DataRow(item: doc, showType: true), + ], + ), + ); + } +} + +class _ProceduresSection extends StatelessWidget { + final List procedures; + + const _ProceduresSection({required this.procedures}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'PROCEDURES & SURGERY', + indicatorColor: InouTheme.danger, + summary: '${procedures.length} procedures', + child: Column( + children: [ + for (final proc in procedures) _DataRow(item: proc), + ], + ), + ); + } +} + +class _AssessmentsSection extends StatelessWidget { + final List assessments; + + const _AssessmentsSection({required this.assessments}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'CLINICAL ASSESSMENTS', + indicatorColor: const Color(0xFF7C3AED), + summary: '${assessments.length} assessments', + child: Column( + children: [ + for (final assessment in assessments) _DataRow(item: assessment), + ], + ), + ); + } +} + +class _GeneticsSection extends StatelessWidget { + final List categories; + + const _GeneticsSection({required this.categories}); + + @override + Widget build(BuildContext context) { + final totalShown = categories.fold(0, (sum, c) => sum + c.shown); + final totalHidden = categories.fold(0, (sum, c) => sum + c.hidden); + + return _DataCard( + title: 'GENETICS', + indicatorColor: InouTheme.indicatorGenetics, + summary: '$totalShown variants${totalHidden > 0 ? ' ($totalHidden hidden)' : ''}', + trailing: totalHidden > 0 + ? InouButton( + text: 'Show all', + size: ButtonSize.small, + onPressed: () { + // TODO: show warning modal + }, + ) + : null, + child: Column( + children: [ + for (final cat in categories.take(5)) + if (cat.shown > 0) + _GeneticCategoryRow(category: cat), + if (categories.length > 5) + _ShowMoreRow( + text: 'Show all ${categories.length} categories', + onTap: () {}, + ), + ], + ), + ); + } +} + +class _GeneticCategoryRow extends StatelessWidget { + final GeneticCategory category; + + const _GeneticCategoryRow({required this.category}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const SizedBox( + width: 20, + child: Text('+', style: TextStyle(color: InouTheme.textMuted)), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + category.displayName, + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + ), + Text( + '${category.shown} variants${category.hidden > 0 ? ' (${category.hidden} hidden)' : ''}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ); + } +} + +class _UploadsSection extends StatelessWidget { + final int count; + final String size; + final bool canEdit; + + const _UploadsSection({required this.count, required this.size, required this.canEdit}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'UPLOADS', + indicatorColor: InouTheme.indicatorUploads, + summary: count == 0 ? 'No files' : '$count files, $size', + trailing: InouButton( + text: 'Manage', + size: ButtonSize.small, + variant: canEdit ? ButtonVariant.secondary : ButtonVariant.secondary, + onPressed: canEdit ? () {} : null, + ), + ); + } +} + +class _MedicationsSection extends StatelessWidget { + final List medications; + + const _MedicationsSection({required this.medications}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'MEDICATIONS', + indicatorColor: InouTheme.indicatorMedications, + summary: '${medications.length} medications', + child: Column( + children: [ + for (final med in medications) _DataRow(item: med), + ], + ), + ); + } +} + +class _SymptomsSection extends StatelessWidget { + final List symptoms; + + const _SymptomsSection({required this.symptoms}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'SYMPTOMS', + indicatorColor: const Color(0xFFF59E0B), + summary: '${symptoms.length} symptoms', + child: Column( + children: [ + for (final symptom in symptoms) _DataRow(item: symptom), + ], + ), + ); + } +} + +class _HospitalizationsSection extends StatelessWidget { + final List hospitalizations; + + const _HospitalizationsSection({required this.hospitalizations}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'HOSPITALIZATIONS', + indicatorColor: const Color(0xFFEF4444), + summary: '${hospitalizations.length} hospitalizations', + child: Column( + children: [ + for (final hosp in hospitalizations) _DataRow(item: hosp), + ], + ), + ); + } +} + +class _TherapiesSection extends StatelessWidget { + final List therapies; + + const _TherapiesSection({required this.therapies}); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'THERAPIES', + indicatorColor: const Color(0xFF10B981), + summary: '${therapies.length} therapies', + child: Column( + children: [ + for (final therapy in therapies) _DataRow(item: therapy), + ], + ), + ); + } +} + +class _VitalsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'VITALS', + indicatorColor: InouTheme.indicatorVitals, + summary: 'Track blood pressure, weight, temperature, and more', + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border.all(color: InouTheme.border), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'COMING SOON', + style: InouText.labelCaps.copyWith(color: InouTheme.textMuted), + ), + ), + comingSoon: true, + ); + } +} + +class _PrivacySection extends StatelessWidget { + final List accessList; + final String dossierId; + final bool canManageAccess; + + const _PrivacySection({ + required this.accessList, + required this.dossierId, + required this.canManageAccess, + }); + + @override + Widget build(BuildContext context) { + return _DataCard( + title: 'PRIVACY', + indicatorColor: InouTheme.indicatorPrivacy, + summary: '${accessList.length} people with access', + child: Column( + children: [ + for (final access in accessList) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${access.name}${access.isSelf ? ' (you)' : ''}${access.isPending ? ' (pending)' : ''}', + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + Text( + '${access.relation}${access.canEdit ? ' · can edit' : ''}', + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + if (canManageAccess && !access.isSelf) ...[ + InouButton( + text: 'Edit', + size: ButtonSize.small, + variant: ButtonVariant.secondary, + onPressed: () {}, + ), + const SizedBox(width: 8), + InouButton( + text: 'Remove', + size: ButtonSize.small, + variant: ButtonVariant.danger, + onPressed: () {}, + ), + ], + ], + ), + ), + // Privacy actions row + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: InouTheme.bg, + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + _PrivacyAction(text: 'Share access', onTap: () {}), + const SizedBox(width: 24), + if (canManageAccess) ...[ + _PrivacyAction(text: 'Manage permissions', onTap: () {}), + const SizedBox(width: 24), + ], + _PrivacyAction(text: 'View audit log', onTap: () {}), + const SizedBox(width: 24), + _PrivacyAction(text: 'Export data', onTap: () {}), + ], + ), + ), + ], + ), + ); + } +} + +class _PrivacyAction extends StatelessWidget { + final String text; + final VoidCallback onTap; + + const _PrivacyAction({required this.text, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Text( + text, + style: InouText.bodySmall.copyWith(color: InouTheme.accent), + ), + ); + } +} + +// ============================================ +// SHARED WIDGETS +// ============================================ + +class _DataCard extends StatelessWidget { + final String title; + final Color indicatorColor; + final String summary; + final Widget? trailing; + final Widget? child; + final bool comingSoon; + + const _DataCard({ + required this.title, + required this.indicatorColor, + required this.summary, + this.trailing, + this.child, + this.comingSoon = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Opacity( + opacity: comingSoon ? 0.6 : 1.0, + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Indicator bar + Container( + width: 4, + height: 32, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + // Title and summary + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.labelCaps.copyWith( + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 2), + Text( + summary, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + // Content + if (child != null) + Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: child, + ), + ], + ), + ), + ); + } +} + +class _DataRow extends StatelessWidget { + final DataItem item; + final bool showType; + + const _DataRow({required this.item, this.showType = false}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border, style: BorderStyle.solid)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.value, + style: InouText.body.copyWith(fontWeight: FontWeight.w500), + ), + if (item.summary != null) + Text( + item.summary!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + if (showType && item.type != null) ...[ + Text( + item.type!, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + const SizedBox(width: 16), + ], + if (item.date != null) + Text( + item.date!, + style: InouText.mono.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ); + } +} + +class _ShowMoreRow extends StatelessWidget { + final String text; + final VoidCallback onTap; + + const _ShowMoreRow({required this.text, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Text( + text, + style: InouText.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ), + ); + } +} diff --git a/app/lib/features/dashboard/mock_data.dart b/app/lib/features/dashboard/mock_data.dart new file mode 100644 index 0000000..d5e453e --- /dev/null +++ b/app/lib/features/dashboard/mock_data.dart @@ -0,0 +1,239 @@ +/// Mock data for testing - based on Anastasia's dossier (most categories) +import 'models.dart'; + +/// Mock dossiers for dashboard +final mockDossiers = [ + const DossierSummary( + id: 'self-001', + name: 'Johan Jongsma', + dateOfBirth: '1985-03-15', + sex: 'Male', + isSelf: true, + canEdit: true, + stats: DossierStats( + imaging: 3, + labs: 12, + genome: true, + documents: 2, + medications: 4, + supplements: 5, + ), + ), + const DossierSummary( + id: 'sophia-001', + name: 'Sophia', + dateOfBirth: '2017-01-01', + sex: 'Female', + relation: 'Parent', + isCareReceiver: true, + canEdit: true, + stats: DossierStats( + imaging: 16, + labs: 0, + ), + ), + const DossierSummary( + id: 'anastasia-001', + name: 'Anastasia', + dateOfBirth: '1990-07-22', + sex: 'Female', + relation: 'Demo', + canEdit: false, + stats: DossierStats( + imaging: 8, + labs: 24, + genome: true, + documents: 5, + vitals: 12, + medications: 7, + supplements: 3, + ), + ), +]; + +/// Full dossier data for Anastasia (richest example) +final mockAnastasiaDossier = DossierData( + dossier: mockDossiers[2], + canEdit: false, + canManageAccess: false, + hasGenome: true, + uploadCount: 12, + uploadSize: '847 MB', + studies: const [ + ImagingStudy( + id: 'study-001', + description: 'MRI BRAIN W/WO CONTRAST', + date: '20240315', + series: [ + ImagingSeries(id: 's1', description: 'AX T1', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 's2', description: 'AX T2 FLAIR', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 's3', description: 'AX DWI', modality: 'MR', sliceCount: 60), + ImagingSeries(id: 's4', description: 'SAG T1 POST', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 's5', description: 'COR T2', modality: 'MR', sliceCount: 120), + ], + ), + ImagingStudy( + id: 'study-002', + description: 'CT CHEST W CONTRAST', + date: '20240210', + series: [ + ImagingSeries(id: 's6', description: 'AX LUNG', modality: 'CT', sliceCount: 250), + ImagingSeries(id: 's7', description: 'COR RECON', modality: 'CT', sliceCount: 120), + ], + ), + ImagingStudy( + id: 'study-003', + description: 'XR CHEST AP ONLY', + date: '20240115', + series: [ + ImagingSeries(id: 's8', modality: 'XR', sliceCount: 1), + ], + ), + ImagingStudy( + id: 'study-004', + description: 'MRI SPINE CERVICAL', + date: '20231201', + series: [ + ImagingSeries(id: 's9', description: 'SAG T1', modality: 'MR', sliceCount: 20), + ImagingSeries(id: 's10', description: 'SAG T2', modality: 'MR', sliceCount: 20), + ImagingSeries(id: 's11', description: 'AX T2', modality: 'MR', sliceCount: 60), + ], + ), + ImagingStudy( + id: 'study-005', + description: 'US THYROID', + date: '20231015', + series: [ + ImagingSeries(id: 's12', modality: 'US', sliceCount: 24), + ], + ), + ], + labs: const [ + DataItem(value: 'Complete Blood Count (CBC)', summary: '8 tests', date: '2024-03-10'), + DataItem(value: 'Comprehensive Metabolic Panel', summary: '14 tests', date: '2024-03-10'), + DataItem(value: 'Lipid Panel', summary: '4 tests', date: '2024-03-10'), + DataItem(value: 'Thyroid Panel', summary: 'TSH, T3, T4', date: '2024-02-15'), + DataItem(value: 'Vitamin D, 25-Hydroxy', summary: '38 ng/mL (normal)', date: '2024-02-15'), + DataItem(value: 'Hemoglobin A1c', summary: '5.2% (normal)', date: '2024-01-20'), + ], + documents: const [ + DataItem(value: 'Discharge Summary', type: 'PDF', date: '2024-03-16'), + DataItem(value: 'Radiology Report - Brain MRI', type: 'PDF', date: '2024-03-15'), + DataItem(value: 'Lab Results Summary', type: 'PDF', date: '2024-03-10'), + DataItem(value: 'Insurance Authorization', type: 'PDF', date: '2024-02-01'), + DataItem(value: 'Referral Letter', type: 'PDF', date: '2024-01-15'), + ], + procedures: const [ + DataItem(value: 'Lumbar Puncture', summary: 'Diagnostic CSF analysis', date: '2024-02-20'), + DataItem(value: 'Thyroid Biopsy', summary: 'Fine needle aspiration', date: '2023-10-18'), + ], + assessments: const [ + DataItem(value: 'Neurological Exam', summary: 'Normal findings', date: '2024-03-15'), + DataItem(value: 'Cognitive Assessment', summary: 'MMSE 29/30', date: '2024-02-25'), + DataItem(value: 'Physical Therapy Eval', summary: 'Mild cervical dysfunction', date: '2024-01-10'), + ], + medications: const [ + DataItem(value: 'Levothyroxine', summary: '50 mcg daily', date: '2024-01-01'), + DataItem(value: 'Vitamin D3', summary: '5000 IU daily', date: '2024-01-01'), + DataItem(value: 'Magnesium Glycinate', summary: '400 mg evening', date: '2024-01-01'), + DataItem(value: 'Omega-3 Fish Oil', summary: '2000 mg daily', date: '2024-01-01'), + DataItem(value: 'Gabapentin', summary: '300 mg PRN (discontinued)', date: '2023-06-01'), + ], + symptoms: const [ + DataItem(value: 'Headache', summary: 'Tension-type, intermittent', date: '2024-03-01'), + DataItem(value: 'Neck stiffness', summary: 'Morning, improves with movement', date: '2024-02-15'), + DataItem(value: 'Fatigue', summary: 'Mild, afternoon slump', date: '2024-01-20'), + ], + hospitalizations: const [ + DataItem(value: 'Johns Hopkins Hospital', summary: 'Observation - headache evaluation', date: '2024-02-20'), + DataItem(value: 'Local ER', summary: 'Chest pain workup - negative', date: '2023-08-15'), + ], + therapies: const [ + DataItem(value: 'Physical Therapy', summary: 'Cervical spine - 8 sessions completed', date: '2024-03-01'), + DataItem(value: 'Massage Therapy', summary: 'Bi-weekly maintenance', date: '2024-01-15'), + DataItem(value: 'Acupuncture', summary: 'Trial - 4 sessions', date: '2023-11-01'), + ], + geneticCategories: const [ + GeneticCategory(name: 'traits', shown: 12, hidden: 45), + GeneticCategory(name: 'metabolism', shown: 8, hidden: 23), + GeneticCategory(name: 'medication', shown: 15, hidden: 32), + GeneticCategory(name: 'cardiovascular', shown: 6, hidden: 18), + GeneticCategory(name: 'neurological', shown: 4, hidden: 12), + GeneticCategory(name: 'longevity', shown: 3, hidden: 8), + GeneticCategory(name: 'autoimmune', shown: 2, hidden: 5), + GeneticCategory(name: 'cancer', shown: 1, hidden: 4), + ], + accessList: const [ + AccessEntry( + dossierID: 'anastasia-001', + name: 'Anastasia', + relation: 'Owner', + canEdit: true, + isSelf: true, + ), + AccessEntry( + dossierID: 'johan-001', + name: 'Johan Jongsma', + relation: 'Demo access', + canEdit: false, + ), + ], +); + +/// Dossier data for Sophia +final mockSophiaDossier = DossierData( + dossier: mockDossiers[1], + canEdit: true, + canManageAccess: true, + hasGenome: false, + uploadCount: 156, + uploadSize: '12.4 GB', + studies: const [ + ImagingStudy( + id: 'sophia-study-001', + description: 'MRI BRAIN W/WO CONTRAST', + date: '20220505', + series: [ + ImagingSeries(id: 'ss1', description: 'AX T1', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 'ss2', description: 'AX T2 FLAIR', modality: 'MR', sliceCount: 180), + ImagingSeries(id: 'ss3', description: 'AX SWI', modality: 'MR', sliceCount: 120), + ImagingSeries(id: 'ss4', description: 'AX DWI', modality: 'MR', sliceCount: 60), + ImagingSeries(id: 'ss5', description: 'SAG T1', modality: 'MR', sliceCount: 180), + ], + ), + ImagingStudy( + id: 'sophia-study-002', + description: 'CT HEAD W/O CONTRAST', + date: '20220502', + series: [ + ImagingSeries(id: 'ss6', description: 'AX', modality: 'CT', sliceCount: 40), + ], + ), + ], + accessList: const [ + AccessEntry( + dossierID: 'sophia-001', + name: 'Sophia', + relation: 'Self', + isSelf: true, + ), + AccessEntry( + dossierID: 'johan-001', + name: 'Johan Jongsma', + relation: 'Parent', + canEdit: true, + ), + ], +); + +/// Get dossier data by ID +DossierData? getDossierById(String id) { + switch (id) { + case 'anastasia-001': + return mockAnastasiaDossier; + case 'sophia-001': + return mockSophiaDossier; + default: + return null; + } +} diff --git a/app/lib/features/dashboard/models.dart b/app/lib/features/dashboard/models.dart new file mode 100644 index 0000000..dd16600 --- /dev/null +++ b/app/lib/features/dashboard/models.dart @@ -0,0 +1,197 @@ +/// Data models for dashboard and dossier pages + +class DossierStats { + final int imaging; + final int labs; + final bool genome; + final int documents; + final int vitals; + final int medications; + final int supplements; + + const DossierStats({ + this.imaging = 0, + this.labs = 0, + this.genome = false, + this.documents = 0, + this.vitals = 0, + this.medications = 0, + this.supplements = 0, + }); + + bool get hasAnyData => + imaging > 0 || + labs > 0 || + genome || + documents > 0 || + vitals > 0 || + medications > 0 || + supplements > 0; +} + +class DossierSummary { + final String id; + final String name; + final String? dateOfBirth; + final String? sex; + final String? relation; + final bool isSelf; + final bool isCareReceiver; + final bool canEdit; + final DossierStats stats; + + const DossierSummary({ + required this.id, + required this.name, + this.dateOfBirth, + this.sex, + this.relation, + this.isSelf = false, + this.isCareReceiver = false, + this.canEdit = false, + this.stats = const DossierStats(), + }); +} + +class DataItem { + final String value; + final String? summary; + final String? date; + final String? type; + + const DataItem({ + required this.value, + this.summary, + this.date, + this.type, + }); +} + +class ImagingSeries { + final String id; + final String? description; + final String modality; + final int sliceCount; + + const ImagingSeries({ + required this.id, + this.description, + required this.modality, + required this.sliceCount, + }); +} + +class ImagingStudy { + final String id; + final String description; + final String date; + final List series; + + const ImagingStudy({ + required this.id, + required this.description, + required this.date, + required this.series, + }); + + int get seriesCount => series.length; +} + +class GeneticVariant { + final String rsid; + final String? gene; + final String genotype; + final double? magnitude; + final String? summary; + final String category; + + const GeneticVariant({ + required this.rsid, + this.gene, + required this.genotype, + this.magnitude, + this.summary, + required this.category, + }); + + String get formattedAllele { + final letters = genotype.replaceAll(';', ''); + return letters.split('').join(';'); + } +} + +class GeneticCategory { + final String name; + final int shown; + final int hidden; + + const GeneticCategory({ + required this.name, + required this.shown, + this.hidden = 0, + }); + + int get total => shown + hidden; + + String get displayName => + name.substring(0, 1).toUpperCase() + + name.substring(1).replaceAll('_', ' '); +} + +class AccessEntry { + final String dossierID; + final String name; + final String relation; + final bool canEdit; + final bool isSelf; + final bool isPending; + + const AccessEntry({ + required this.dossierID, + required this.name, + required this.relation, + this.canEdit = false, + this.isSelf = false, + this.isPending = false, + }); +} + +class DossierData { + final DossierSummary dossier; + final List studies; + final List labs; + final List documents; + final List procedures; + final List assessments; + final List medications; + final List symptoms; + final List hospitalizations; + final List therapies; + final List geneticCategories; + final List accessList; + final int uploadCount; + final String uploadSize; + final bool hasGenome; + final bool canEdit; + final bool canManageAccess; + + const DossierData({ + required this.dossier, + this.studies = const [], + this.labs = const [], + this.documents = const [], + this.procedures = const [], + this.assessments = const [], + this.medications = const [], + this.symptoms = const [], + this.hospitalizations = const [], + this.therapies = const [], + this.geneticCategories = const [], + this.accessList = const [], + this.uploadCount = 0, + this.uploadSize = '0 KB', + this.hasGenome = false, + this.canEdit = false, + this.canManageAccess = false, + }); +} diff --git a/app/lib/features/static/connect_page.dart b/app/lib/features/static/connect_page.dart new file mode 100644 index 0000000..27753ac --- /dev/null +++ b/app/lib/features/static/connect_page.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Connect/Contact page +class ConnectPage extends StatefulWidget { + const ConnectPage({super.key}); + + @override + State createState() => _ConnectPageState(); +} + +class _ConnectPageState extends State { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _messageController = TextEditingController(); + String _selectedTopic = 'General inquiry'; + bool _isSubmitting = false; + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/connect', + maxWidth: 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Get in touch', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Have a question, feedback, or just want to say hello? We\'d love to hear from you.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + + const SizedBox(height: 48), + + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InouTextField( + label: 'Name', + controller: _nameController, + placeholder: 'Your name', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your name'; + } + return null; + }, + ), + const SizedBox(height: 20), + + InouTextField( + label: 'Email', + controller: _emailController, + placeholder: 'you@example.com', + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 20), + + InouSelect( + label: 'Topic', + value: _selectedTopic, + options: const [ + InouSelectOption(value: 'General inquiry', label: 'General inquiry'), + InouSelectOption(value: 'Technical support', label: 'Technical support'), + InouSelectOption(value: 'Privacy question', label: 'Privacy question'), + InouSelectOption(value: 'Partnership', label: 'Partnership'), + InouSelectOption(value: 'Press', label: 'Press'), + InouSelectOption(value: 'Other', label: 'Other'), + ], + onChanged: (value) { + if (value != null) { + setState(() => _selectedTopic = value); + } + }, + ), + const SizedBox(height: 20), + + InouTextField( + label: 'Message', + controller: _messageController, + placeholder: 'How can we help?', + maxLines: 5, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a message'; + } + return null; + }, + ), + const SizedBox(height: 32), + + InouButton( + text: _isSubmitting ? 'Sending...' : 'Send message', + onPressed: _isSubmitting ? null : _handleSubmit, + ), + ], + ), + ), + + const SizedBox(height: 64), + + // Direct contact info + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Prefer email?', + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + 'Reach us directly at:', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 12), + _buildEmailLink('hello@inou.com', 'General inquiries'), + const SizedBox(height: 8), + _buildEmailLink('support@inou.com', 'Technical support'), + const SizedBox(height: 8), + _buildEmailLink('privacy@inou.com', 'Privacy questions'), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildEmailLink(String email, String label) { + return Row( + children: [ + Text( + email, + style: InouText.body.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + Text( + '— $label', + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ); + } + + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSubmitting = true); + + // TODO: Implement actual form submission + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() => _isSubmitting = false); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Message sent! We\'ll get back to you soon.'), + backgroundColor: InouTheme.success, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ); + + _nameController.clear(); + _emailController.clear(); + _messageController.clear(); + } + } +} diff --git a/app/lib/features/static/dpa_page.dart b/app/lib/features/static/dpa_page.dart new file mode 100644 index 0000000..98494ba --- /dev/null +++ b/app/lib/features/static/dpa_page.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Data Processing Agreement page +class DpaPage extends StatelessWidget { + const DpaPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/dpa', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Data Processing Agreement', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Last updated: January 2026', + style: InouText.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + + const SizedBox(height: 48), + + _buildSection( + '1. Introduction', + 'This Data Processing Agreement ("DPA") forms part of the Terms of Service between inou health ("Processor", "we", "us") and the user ("Controller", "you") for the processing of personal data, including health data, as defined under applicable data protection laws.', + ), + + _buildSection( + '2. Definitions', + '''• "Personal Data" means any information relating to an identified or identifiable natural person. +• "Health Data" means personal data related to the physical or mental health of a natural person, including the provision of health care services. +• "Processing" means any operation performed on personal data, including collection, storage, use, and deletion. +• "Sub-processor" means any third party engaged by us to process personal data on your behalf.''', + ), + + _buildSection( + '3. Scope and Purpose', + 'We process your personal data and health data solely for the purpose of providing our services: secure storage, organization, and AI-assisted analysis of your health information. We act as a Processor on your behalf; you remain the Controller of your data.', + ), + + _buildSection( + '4. Data Processing Obligations', + '''We shall: +• Process personal data only on your documented instructions +• Ensure persons authorized to process the data are bound by confidentiality +• Implement appropriate technical and organizational security measures +• Assist you in responding to data subject requests +• Delete or return all personal data upon termination of services +• Make available all information necessary to demonstrate compliance''', + ), + + _buildSection( + '5. Security Measures', + '''We implement the following security measures: +• FIPS 140-3 compliant encryption at rest and in transit +• TLS 1.3 for all data transmission +• Dedicated single-tenant infrastructure +• Role-based access control +• Continuous monitoring and intrusion detection +• Regular security assessments and penetration testing''', + ), + + _buildSection( + '6. Sub-processors', + 'We may engage sub-processors to assist in providing our services. A current list of sub-processors is available upon request. We will notify you of any intended changes to sub-processors, giving you the opportunity to object.', + ), + + _buildSection( + '7. International Transfers', + 'Your data is stored and processed in the United States. For transfers to third countries, we ensure appropriate safeguards are in place, including Standard Contractual Clauses where required.', + ), + + _buildSection( + '8. Data Subject Rights', + 'We will assist you in fulfilling your obligations to respond to data subject requests for access, rectification, erasure, restriction, portability, and objection. Contact privacy@inou.com for assistance.', + ), + + _buildSection( + '9. Data Breach Notification', + 'We will notify you without undue delay (and in any event within 72 hours) upon becoming aware of a personal data breach affecting your data, and will provide all information necessary for you to meet your notification obligations.', + ), + + _buildSection( + '10. Term and Termination', + 'This DPA remains in effect for the duration of our processing of your data. Upon termination, we will delete or return all personal data and certify such deletion, unless legally required to retain certain data.', + ), + + const SizedBox(height: 48), + + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contact', + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + 'For questions about this DPA or to exercise your rights, contact:\nprivacy@inou.com', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + height: 1.6, + ), + ), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildSection(String title, String content) { + return Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.h3, + ), + const SizedBox(height: 12), + Text( + content, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.8, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/static/faq_page.dart b/app/lib/features/static/faq_page.dart new file mode 100644 index 0000000..6096128 --- /dev/null +++ b/app/lib/features/static/faq_page.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// FAQ page +class FaqPage extends StatelessWidget { + const FaqPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/faq', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Frequently Asked Questions', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Everything you need to know about inou.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + + const SizedBox(height: 48), + + ..._buildFaqItems(), + + const SizedBox(height: 48), + + // Contact CTA + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Still have questions?', + style: InouText.h3, + ), + const SizedBox(height: 4), + Text( + 'We\'re here to help.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + InouButton( + text: 'Contact us', + variant: ButtonVariant.secondary, + onPressed: () => Navigator.pushNamed(context, '/connect'), + ), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + List _buildFaqItems() { + final faqs = [ + _FaqItem( + question: 'What is inou?', + answer: 'inou is a secure platform that organizes all your health data — medical images, lab results, genetic information, vitals, and more — in one place. It then connects securely with AI to help you understand your health better than ever before.', + ), + _FaqItem( + question: 'Is my data safe?', + answer: 'Absolutely. Your data is encrypted with FIPS 140-3 compliant encryption at rest and in transit. We run on dedicated hardware (not shared cloud), and we never sell, share, or use your data to train AI models. See our Security page for complete details.', + ), + _FaqItem( + question: 'What file formats do you support?', + answer: 'We support DICOM files (medical imaging), PDF (lab reports, medical records), CSV (lab data, health exports), VCF (genetic data from 23andMe, Ancestry, etc.), and common image formats for documents.', + ), + _FaqItem( + question: 'How does the AI integration work?', + answer: 'You control when and how your data is shared with AI. When you choose to connect, your data is transmitted securely to the AI provider you select. The AI can then analyze your complete health picture and provide insights no single specialist could.', + ), + _FaqItem( + question: 'Can I share my data with my doctor?', + answer: 'Yes. You can generate secure, time-limited sharing links for any part of your health dossier. Your doctor gets read-only access to exactly what you choose to share.', + ), + _FaqItem( + question: 'What does it cost?', + answer: 'Basic storage and organization is free. Premium features like advanced AI analysis, family accounts, and priority support are available with a subscription. See our pricing page for current plans.', + ), + _FaqItem( + question: 'Can I delete my data?', + answer: 'Yes, completely. You can delete individual files or your entire account at any time. When you delete, the data is permanently removed from our systems — no backups retained, no recovery possible.', + ), + _FaqItem( + question: 'Do you support family accounts?', + answer: 'Yes. You can create dossiers for family members you care for — children, elderly parents, anyone who needs an advocate. Each dossier is separate and secure.', + ), + ]; + + return faqs.map((faq) => _FaqExpansionTile(faq: faq)).toList(); + } +} + +class _FaqItem { + final String question; + final String answer; + + const _FaqItem({ + required this.question, + required this.answer, + }); +} + +class _FaqExpansionTile extends StatefulWidget { + final _FaqItem faq; + + const _FaqExpansionTile({required this.faq}); + + @override + State<_FaqExpansionTile> createState() => _FaqExpansionTileState(); +} + +class _FaqExpansionTileState extends State<_FaqExpansionTile> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + title: Text( + widget.faq.question, + style: InouText.body.copyWith( + fontWeight: FontWeight.w500, + ), + ), + trailing: AnimatedRotation( + turns: _isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.keyboard_arrow_down, + color: InouTheme.textMuted, + ), + ), + onExpansionChanged: (expanded) { + setState(() => _isExpanded = expanded); + }, + children: [ + Text( + widget.faq.answer, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + height: 1.7, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/static/invite_page.dart b/app/lib/features/static/invite_page.dart new file mode 100644 index 0000000..11043e1 --- /dev/null +++ b/app/lib/features/static/invite_page.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Invite a friend page +class InvitePage extends StatefulWidget { + const InvitePage({super.key}); + + @override + State createState() => _InvitePageState(); +} + +class _InvitePageState extends State { + final _emailController = TextEditingController(); + final List _invitedEmails = []; + bool _isSending = false; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // TODO: Check if user is logged in, if not show signup prompt + final isLoggedIn = false; // Placeholder + + return InouPage( + currentRoute: '/invite', + maxWidth: 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Invite a friend', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Know someone who could benefit from understanding their health better? Invite them to inou.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + ), + ), + + const SizedBox(height: 48), + + if (!isLoggedIn) ...[ + _buildSignupPrompt(context), + ] else ...[ + _buildInviteForm(), + ], + + const SizedBox(height: 48), + + // Why invite section + _buildWhyInvite(), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildSignupPrompt(BuildContext context) { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + children: [ + Icon( + Icons.person_add_outlined, + size: 48, + color: InouTheme.accent, + ), + const SizedBox(height: 16), + Text( + 'Sign in to invite friends', + style: InouText.h3, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Create an account or sign in to share inou with people you care about.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InouButton( + text: 'Sign up', + onPressed: () => Navigator.pushNamed(context, '/signup'), + ), + const SizedBox(width: 12), + InouButton( + text: 'Log in', + variant: ButtonVariant.secondary, + onPressed: () => Navigator.pushNamed(context, '/login'), + ), + ], + ), + ], + ), + ); + } + + Widget _buildInviteForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Email input + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: InouTextField( + label: 'Friend\'s email', + controller: _emailController, + placeholder: 'friend@example.com', + keyboardType: TextInputType.emailAddress, + ), + ), + const SizedBox(width: 12), + InouButton( + text: _isSending ? 'Sending...' : 'Send invite', + onPressed: _isSending ? null : _handleSendInvite, + ), + ], + ), + + const SizedBox(height: 24), + + // Share link + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: InouTheme.borderRadiusMd, + border: Border.all(color: InouTheme.border), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Or share your invite link', + style: InouText.labelCaps.copyWith( + color: InouTheme.textMuted, + ), + ), + const SizedBox(height: 4), + Text( + 'https://inou.com/join/abc123', + style: InouText.body.copyWith( + fontFamily: 'SF Mono', + fontSize: 13, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 20), + color: InouTheme.accent, + onPressed: () { + Clipboard.setData(const ClipboardData( + text: 'https://inou.com/join/abc123', + )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Link copied!'), + backgroundColor: InouTheme.success, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + ), + ], + ), + ), + + // Sent invites + if (_invitedEmails.isNotEmpty) ...[ + const SizedBox(height: 32), + Text( + 'Invites sent', + style: InouText.h3, + ), + const SizedBox(height: 12), + ...(_invitedEmails.map((email) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( + Icons.check_circle, + size: 16, + color: InouTheme.success, + ), + const SizedBox(width: 8), + Text(email, style: InouText.body), + ], + ), + ))), + ], + ], + ); + } + + Widget _buildWhyInvite() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Why invite?', + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 24), + _buildBenefitItem( + Icons.favorite_outline, + 'Help someone you care about', + 'Whether they\'re managing a chronic condition, caring for a family member, or just want to understand their health better — inou can help.', + ), + const SizedBox(height: 20), + _buildBenefitItem( + Icons.people_outline, + 'Build your health circle', + 'When family members use inou, sharing relevant health information becomes seamless and secure.', + ), + const SizedBox(height: 20), + _buildBenefitItem( + Icons.card_giftcard_outlined, + 'Early access perks', + 'Friends you invite get priority access to new features and may qualify for special pricing.', + ), + ], + ); + } + + Widget _buildBenefitItem(IconData icon, String title, String description) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: InouTheme.accentLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: InouTheme.accent, size: 20), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: InouText.h3.copyWith(fontSize: 16)), + const SizedBox(height: 4), + Text( + description, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.6, + ), + ), + ], + ), + ), + ], + ); + } + + Future _handleSendInvite() async { + final email = _emailController.text.trim(); + if (email.isEmpty || !email.contains('@')) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Please enter a valid email address'), + backgroundColor: InouTheme.danger, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + setState(() => _isSending = true); + + // TODO: Implement actual invite sending + await Future.delayed(const Duration(seconds: 1)); + + if (mounted) { + setState(() { + _isSending = false; + _invitedEmails.add(email); + }); + _emailController.clear(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Invite sent to $email!'), + backgroundColor: InouTheme.success, + behavior: SnackBarBehavior.floating, + ), + ); + } + } +} diff --git a/app/lib/features/static/landing_page.dart b/app/lib/features/static/landing_page.dart new file mode 100644 index 0000000..2dadadb --- /dev/null +++ b/app/lib/features/static/landing_page.dart @@ -0,0 +1,700 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Landing page - main marketing page matching Go version layout +class LandingPage extends StatelessWidget { + const LandingPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + + // Hero card + _HeroCard(), + + // "You need AI for your health" card + _YouNeedAiCard(), + + // "The challenge" card + _ChallengeCard(), + + // "Why we built this" card + _WhyCard(), + + // Trust section + _TrustSection(), + + const SizedBox(height: 24), + ], + ), + ); + } +} + +/// Landing card wrapper - matches Go .landing-card +class _LandingCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + + const _LandingCard({required this.child, this.padding}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 24), + padding: padding ?? const EdgeInsets.all(48), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: child, + ); + } +} + +/// Hero card - centered text with CTA +class _HeroCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + children: [ + // Hero answer text + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ), + children: [ + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + TextSpan( + text: ' ${l10n.heroAnswer.replaceFirst('inou ', '')}', + ), + ], + ), + ), + + const SizedBox(height: 32), + + // Tagline + Text( + l10n.heroTagline, + textAlign: TextAlign.center, + style: InouText.body.copyWith( + fontSize: isMobile ? 20 : 21, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 32), + + // CTA Button + ElevatedButton( + onPressed: () => context.go('/signup'), + style: ElevatedButton.styleFrom( + backgroundColor: InouTheme.accent, + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.symmetric( + horizontal: isMobile ? 40 : 56, + vertical: 18, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + child: Text( + l10n.signIn.toUpperCase(), + style: InouText.button.copyWith( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 1.28, + ), + ), + ), + ], + ), + ); + } +} + +/// "You need AI for your health" card +class _YouNeedAiCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + l10n.youNeedAiTitle, + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 32), + + // Warm prose paragraphs + _WarmProse(text: l10n.youNeedAiPara1), + const SizedBox(height: 20), + _WarmProse(text: l10n.youNeedAiPara2), + const SizedBox(height: 20), + + // Emphasis: "But you..." + RichText( + text: TextSpan( + style: _warmProseStyle(context), + children: [ + const TextSpan(text: 'But '), + TextSpan( + text: 'you', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontStyle: FontStyle.italic, + ), + ), + TextSpan(text: ' ${l10n.youNeedAiPara3.replaceFirst('But you ', '')}'), + ], + ), + ), + + const SizedBox(height: 20), + + // Final emphasis with inou + RichText( + text: TextSpan( + style: _warmProseStyle(context).copyWith( + fontWeight: FontWeight.w600, + fontSize: 18.4, + ), + children: [ + TextSpan(text: l10n.youNeedAiEmphasis.split('inou')[0]), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + TextSpan(text: l10n.youNeedAiEmphasis.split('inou').last), + ], + ), + ), + ], + ), + ); + } +} + +/// "The challenge" card with data/reality pairs +class _ChallengeCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + l10n.challengeTitle, + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 24), + + // Data/Reality pairs + _DataRealityPair(data: l10n.challengeMri, reality: l10n.challengeMriReality), + _DataRealityPair(data: l10n.challengeGenome, reality: l10n.challengeGenomeReality), + _DataRealityPair(data: l10n.challengeBlood, reality: l10n.challengeBloodReality), + _DataRealityPair(data: l10n.challengeWatch, reality: l10n.challengeWatchReality), + _DataRealityPair(data: l10n.challengeSupplements, reality: l10n.challengeSupplementsReality), + + // Transition + Container( + margin: const EdgeInsets.symmetric(vertical: 32), + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: InouTheme.border), + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Text( + l10n.challengeTransition, + style: InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ), + ), + ), + + // Gaps section + _GapsSection(l10n: l10n), + + const SizedBox(height: 32), + + // Connections + _ConnectionsSection(l10n: l10n), + + const SizedBox(height: 32), + + // AI section + _AiSection(l10n: l10n), + + const SizedBox(height: 32), + + // Closing + _ClosingText(text: l10n.challengeClosing), + ], + ), + ); + } +} + +/// "Why we built this" card +class _WhyCard extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isMobile = MediaQuery.of(context).size.width < 600; + + return _LandingCard( + padding: EdgeInsets.all(isMobile ? 32 : 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + l10n.whyTitle, + style: InouText.body.copyWith( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + + const SizedBox(height: 24), + + // Prose paragraphs + _StoryProse(text: l10n.whyPara1), + _StoryProse(text: l10n.whyPara2), + _StoryProse(text: l10n.whyPara3), + _StoryProse(text: l10n.whyPara4), + + // AI finally can - emphasis + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + l10n.whyPara5, + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.8, + color: InouTheme.text, + ), + ), + ), + + _StoryProse(text: l10n.whyPara6), + + // inou paragraph + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: RichText( + text: TextSpan( + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ), + children: [ + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + TextSpan(text: ' ${l10n.whyPara7.replaceFirst('inou ', '')}'), + ], + ), + ), + ), + + // Closing + Container( + padding: const EdgeInsets.only(top: 24), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: InouTheme.border), + ), + ), + child: Text( + l10n.whyClosing, + style: InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ), + ), + ), + ], + ), + ); + } +} + +/// Trust section - 4-column grid +class _TrustSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = screenWidth < 600; + final isTablet = screenWidth < 900 && screenWidth >= 600; + + return Container( + margin: const EdgeInsets.only(bottom: 24), + padding: EdgeInsets.all(isMobile ? 24 : 48), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Label + Text( + l10n.trustLabel.toUpperCase(), + style: InouText.labelCaps.copyWith( + color: InouTheme.textMuted, + letterSpacing: 0.6, + ), + ), + + const SizedBox(height: 24), + + // Trust grid + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = isMobile ? 1 : (isTablet ? 2 : 4); + + return GridView.count( + crossAxisCount: crossAxisCount, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: isMobile ? 20 : 32, + crossAxisSpacing: 32, + childAspectRatio: isMobile ? 4 : (isTablet ? 2.5 : 1.5), + children: [ + _TrustItem( + title: l10n.neverTraining, + description: l10n.neverTrainingDesc, + ), + _TrustItem( + title: l10n.neverShared, + description: l10n.neverSharedDesc, + ), + _TrustItem( + title: l10n.encrypted, + description: l10n.encryptedDesc, + ), + _TrustItem( + title: l10n.deleteAnytime, + description: l10n.deleteAnytimeDesc, + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +// ============================================================================= +// Helper widgets +// ============================================================================= + +TextStyle _warmProseStyle(BuildContext context) { + return InouText.body.copyWith( + fontSize: 17.6, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.text, + ); +} + +class _WarmProse extends StatelessWidget { + final String text; + const _WarmProse({required this.text}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: _warmProseStyle(context), + ); + } +} + +class _StoryProse extends StatelessWidget { + final String text; + const _StoryProse({required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + text, + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ), + ), + ); + } +} + +class _DataRealityPair extends StatelessWidget { + final String data; + final String reality; + + const _DataRealityPair({required this.data, required this.reality}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data, + style: InouText.body.copyWith( + fontSize: 17.6, + fontWeight: FontWeight.w400, + color: InouTheme.text, + ), + ), + const SizedBox(height: 4), + Text( + reality, + style: InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + fontStyle: FontStyle.italic, + color: InouTheme.textMuted, + ), + ), + ], + ), + ); + } +} + +class _GapsSection extends StatelessWidget { + final AppLocalizations l10n; + const _GapsSection({required this.l10n}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.challengeGap1, + style: _gapsStyle(), + ), + Padding( + padding: const EdgeInsets.only(left: 16, top: 4, bottom: 8), + child: Text( + l10n.challengeGap1Indent, + style: _gapsStyle().copyWith(fontStyle: FontStyle.italic), + ), + ), + Text(l10n.challengeGap2, style: _gapsStyle()), + const SizedBox(height: 8), + Text(l10n.challengeGap3, style: _gapsStyle()), + ], + ); + } + + TextStyle _gapsStyle() => InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ); +} + +class _ConnectionsSection extends StatelessWidget { + final AppLocalizations l10n; + const _ConnectionsSection({required this.l10n}); + + @override + Widget build(BuildContext context) { + final style = InouText.body.copyWith( + fontSize: 16, + fontWeight: FontWeight.w300, + height: 1.8, + color: InouTheme.textMuted, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.challengeConnection1, style: style), + Text(l10n.challengeConnection2, style: style), + Text(l10n.challengeConnection3, style: style), + ], + ); + } +} + +class _AiSection extends StatelessWidget { + final AppLocalizations l10n; + const _AiSection({required this.l10n}); + + @override + Widget build(BuildContext context) { + final style = InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.challengeAi1, style: style), + Text(l10n.challengeAi2, style: style), + Text(l10n.challengeAi3, style: style), + Text( + l10n.challengeAi4, + style: style.copyWith(fontStyle: FontStyle.italic), + ), + ], + ); + } +} + +class _ClosingText extends StatelessWidget { + final String text; + const _ClosingText({required this.text}); + + @override + Widget build(BuildContext context) { + // Split at 'inou' to style it + final parts = text.split('inou'); + + return RichText( + text: TextSpan( + style: InouText.body.copyWith( + fontSize: 20, + fontWeight: FontWeight.w400, + height: 1.8, + color: InouTheme.text, + ), + children: [ + if (parts.isNotEmpty) TextSpan(text: parts[0]), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + if (parts.length > 1) TextSpan(text: parts[1]), + ], + ), + ); + } +} + +class _TrustItem extends StatelessWidget { + final String title; + final String description; + + const _TrustItem({required this.title, required this.description}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.body.copyWith( + fontSize: 14.4, + fontWeight: FontWeight.w600, + color: InouTheme.text, + ), + ), + const SizedBox(height: 4), + Expanded( + child: Text( + description, + style: InouText.body.copyWith( + fontSize: 14.4, + fontWeight: FontWeight.w300, + height: 1.6, + color: InouTheme.textMuted, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/static/privacy_page.dart b/app/lib/features/static/privacy_page.dart new file mode 100644 index 0000000..fae432b --- /dev/null +++ b/app/lib/features/static/privacy_page.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Privacy policy page +class PrivacyPage extends StatelessWidget { + const PrivacyPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/privacy', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + Text( + 'Your data. Your rules.', + style: InouText.pageTitle, + ), + const SizedBox(height: 24), + RichText( + text: TextSpan( + style: InouText.body.copyWith( + fontWeight: FontWeight.w300, + color: InouTheme.textMuted, + height: 1.8, + ), + children: [ + const TextSpan(text: 'We built '), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + const TextSpan( + text: ' because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.', + ), + ], + ), + ), + + const SizedBox(height: 64), + + _buildSection( + 'What we collect', + [ + _PolicyItem( + title: 'Account information.', + content: 'Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.', + ), + _PolicyItem( + title: 'Health data you upload.', + content: 'Medical images (DICOM files), lab results (PDF, CSV), genetic data (VCF files from 23andMe, etc.), and any notes or vitals you choose to add. We only have what you give us.', + ), + _PolicyItem( + title: 'Usage information.', + content: 'Basic analytics to understand how the service is used and improve it. We don\'t track you across the web or sell your data to advertisers.', + ), + ], + ), + + const SizedBox(height: 48), + + _buildSection( + 'What we never do', + [ + _PolicyItem( + title: 'Sell your data.', + content: 'Your health information is not a product. We don\'t sell it, rent it, or share it with data brokers. Period.', + ), + _PolicyItem( + title: 'Train AI on your data.', + content: 'Your medical records don\'t train our models or anyone else\'s. Your data is yours alone.', + ), + _PolicyItem( + title: 'Share without consent.', + content: 'We don\'t share your health data with anyone unless you explicitly authorize it — not employers, not insurers, not advertisers.', + ), + ], + ), + + const SizedBox(height: 48), + + _buildSection( + 'Your rights', + [ + _PolicyItem( + title: 'Access your data.', + content: 'You can download everything you\'ve uploaded at any time, in standard formats.', + ), + _PolicyItem( + title: 'Delete your data.', + content: 'Request deletion and we\'ll remove your data from our systems. When you leave, your data leaves with you.', + ), + _PolicyItem( + title: 'Know what we have.', + content: 'You can request a full accounting of all data we store about you.', + ), + ], + ), + + const SizedBox(height: 48), + + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Questions?', + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + 'If you have questions about our privacy practices, contact us at privacy@inou.com', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } + + Widget _buildSection(String title, List<_PolicyItem> items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: InouText.sectionTitle.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 24), + ...items.map((item) => Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: InouText.h3, + ), + const SizedBox(height: 8), + Text( + item.content, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.8, + ), + ), + ], + ), + )).toList(), + ], + ); + } +} + +class _PolicyItem { + final String title; + final String content; + + const _PolicyItem({ + required this.title, + required this.content, + }); +} diff --git a/app/lib/features/static/security_page.dart b/app/lib/features/static/security_page.dart new file mode 100644 index 0000000..0b07d6d --- /dev/null +++ b/app/lib/features/static/security_page.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/design/inou_text.dart'; +import 'package:inou_app/design/widgets/widgets.dart'; + +/// Security page - matches inou.com/security +class SecurityPage extends StatelessWidget { + const SecurityPage({super.key}); + + @override + Widget build(BuildContext context) { + return InouPage( + currentRoute: '/security', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 48), + + // Hero + Text( + 'How we protect your health dossier.', + style: InouText.pageTitle, + ), + const SizedBox(height: 16), + Text( + 'Security isn\'t a feature we added. It\'s how we built inou from day one.', + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + fontSize: 18, + ), + ), + + const SizedBox(height: 64), + + // Security features + ..._buildSecurityFeatures(), + + const SizedBox(height: 48), + ], + ), + ); + } + + List _buildSecurityFeatures() { + final features = [ + _SecurityFeature( + icon: Icons.dns_outlined, + title: 'Your data never shares a server.', + description: 'Most services run on shared cloud infrastructure — your files sitting next to thousands of strangers. Not here. inou runs on dedicated, single-tenant hardware. Your data lives on machines that exist solely for this purpose.', + ), + _SecurityFeature( + icon: Icons.lock_outline, + title: 'Encryption you can trust.', + description: 'FIPS 140-3 is the US government standard for cryptographic security — the same bar the military uses. Your files are encrypted in flight with TLS 1.3, encrypted again at the application layer before they touch the database, and stay encrypted at rest. Three layers deep.', + ), + _SecurityFeature( + icon: Icons.power_outlined, + title: 'Power doesn\'t go out.', + description: 'Servers run on uninterruptible power, backed by a natural gas generator. Not a battery that buys you fifteen minutes — a generator with fuel supply independent of the grid. If the power company fails, we don\'t.', + ), + _SecurityFeature( + icon: Icons.storage_outlined, + title: 'Drives fail. Data doesn\'t.', + description: 'Storage runs on ZFS with RAID-Z2 — enterprise technology that survives the simultaneous failure of any two drives without losing a byte. Backups happen automatically. (Our founder spent two decades building backup systems for a living. We take this seriously.)', + ), + _SecurityFeature( + icon: Icons.satellite_alt_outlined, + title: 'The internet has a backup too.', + description: 'Primary connectivity is dedicated fiber. If that fails, satellite kicks in. Terrestrial and space-based redundancy — because your access matters.', + ), + _SecurityFeature( + icon: Icons.visibility_outlined, + title: 'We watch. We act.', + description: 'Continuous uptime monitoring, automated alerting, 24/7. If something blinks wrong, we know — and our systems respond before you\'d ever notice.', + ), + _SecurityFeature( + icon: Icons.shield_outlined, + title: 'We keep attackers out.', + description: 'Firewall rules block malicious traffic at the edge. Tarpits slow down scanners and bots, wasting their time instead of ours. Role-based access control ensures every request is authenticated and authorized — no exceptions.', + ), + _SecurityFeature( + icon: Icons.code_outlined, + title: 'Built with intention.', + description: 'Most software is assembled from open source libraries — code written by strangers, maintained by volunteers, used by millions. When a vulnerability is discovered, every application using that library is exposed. We write our own code. We control every line. We don\'t inherit other people\'s risks.', + ), + ]; + + return features.map((feature) => Padding( + padding: const EdgeInsets.only(bottom: 48), + child: _buildFeatureCard(feature), + )).toList(); + } + + Widget _buildFeatureCard(_SecurityFeature feature) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: InouTheme.accentLight, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + feature.icon, + color: InouTheme.accent, + size: 24, + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + feature.title, + style: InouText.h3, + ), + const SizedBox(height: 12), + Text( + feature.description, + style: InouText.body.copyWith( + color: InouTheme.textMuted, + fontWeight: FontWeight.w300, + height: 1.8, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _SecurityFeature { + final IconData icon; + final String title; + final String description; + + const _SecurityFeature({ + required this.icon, + required this.title, + required this.description, + }); +} diff --git a/app/lib/features/static/static.dart b/app/lib/features/static/static.dart new file mode 100644 index 0000000..1df9e43 --- /dev/null +++ b/app/lib/features/static/static.dart @@ -0,0 +1,8 @@ +// Barrel file for static pages +export 'landing_page.dart'; +export 'security_page.dart'; +export 'privacy_page.dart'; +export 'faq_page.dart'; +export 'dpa_page.dart'; +export 'connect_page.dart'; +export 'invite_page.dart'; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb new file mode 100644 index 0000000..493d0da --- /dev/null +++ b/app/lib/l10n/app_en.arb @@ -0,0 +1,77 @@ +{ + "@@locale": "en", + + "appTitle": "inou", + "appTagline": "ai answers for you", + + "heroAnswer": "inou organizes and shares your health dossier with your AI — securely and privately.", + "heroTagline": "Your health, understood.", + "signIn": "Sign in", + "inviteAFriend": "Invite a friend", + "getStarted": "Get started", + + "youNeedAiTitle": "You need AI for your health", + "youNeedAiPara1": "Your health data lives in a dozen different places — with your cardiologist, your neurologist, your lab, your watch, your apps, your 23andMe. And only you know the rest: what you eat, what you drink, what supplements you take. Your exercise routine. Your symptoms. Your goals — whether you're trying to get pregnant, training for a marathon, or just trying to feel less exhausted.", + "youNeedAiPara2": "Whether you're healthy and want to stay that way, navigating a difficult diagnosis, or caring for a family member who can't advocate for themselves — no single doctor sees the full picture. No system connects it.", + "youNeedAiPara3": "But you have access to all of it. You just don't have the expertise to make sense of it all.", + "youNeedAiEmphasis": "Your AI does. inou gives it the full picture.", + + "challengeTitle": "The challenge", + "challengeMri": "Your MRI has 4,000 slices.", + "challengeMriReality": "It was read in 10 minutes.", + "challengeGenome": "Your genome has millions of variants.", + "challengeGenomeReality": "All you learned was your eye color and where your ancestors came from.", + "challengeBlood": "Your blood work has dozens of markers.", + "challengeBloodReality": "Your doctor said \"everything looks fine.\"", + "challengeWatch": "Your watch tracked 10,000 hours of sleep.", + "challengeWatchReality": "Your trainer doesn't know it exists.", + "challengeSupplements": "You've tried a hundred different supplements.", + "challengeSupplementsReality": "Nobody asked which ones.", + + "challengeTransition": "The connections are there.\nThey are just too complex for any one person to grasp.", + + "challengeGap1": "Nobody knows how your body processes Warfarin — not even you.", + "challengeGap1Indent": "But the answer might already be hiding in your 23andMe.", + "challengeGap2": "That 'unremarkable' on your MRI — did anyone look closely at all 4,000 slices?", + "challengeGap3": "Your thyroid is 'in range' — but nobody connected it to your fatigue, your weight, always being cold.", + + "challengeConnection1": "Nobody is connecting your afternoon caffeine to your sleep scores.", + "challengeConnection2": "Your iron levels to your workout fatigue.", + "challengeConnection3": "Your genetics to your brain fog.", + + "challengeAi1": "Your AI doesn't forget.", + "challengeAi2": "Doesn't rush.", + "challengeAi3": "Finds what was missed.", + "challengeAi4": "Doesn't specialize — sees the complete you.", + + "challengeClosing": "inou lets your AI take it all into account — every slice, every marker, every variant — connect it all and finally give you answers no one else could.", + + "whyTitle": "Why we built this", + "whyPara1": "You've collected years of health data. Scans from the hospital. Blood work from the lab. Results from your doctor's portal. Data from your watch. Maybe even your DNA.", + "whyPara2": "And then there's everything only you know — your weight, your blood pressure, your training schedule, the supplements you take, the symptoms you've been meaning to mention.", + "whyPara3": "It's all there — but scattered across systems that don't talk to each other, held by specialists who only see their piece, or locked in your own head.", + "whyPara4": "Your cardiologist doesn't know what your neurologist found. Your trainer hasn't seen your blood work. Your doctor has no idea what supplements you are taking. And none of them have time to sit with you and connect the dots.", + "whyPara5": "AI finally can. It can pull together what no single expert sees — and actually explain it to you.", + "whyPara6": "But this data doesn't fit in a chat window. And the last thing you want is your medical history on someone else's servers, training their models.", + "whyPara7": "inou brings it all together — labs, imaging, genetics, vitals, medications, supplements — encrypted, private, and shared with absolutely no one. Your AI connects securely. Your data stays yours.", + "whyClosing": "Your health, understood.", + + "trustLabel": "Your data stays yours", + "neverTraining": "Never used for training", + "neverTrainingDesc": "Your images are never used to train AI models.", + "neverShared": "Never shared", + "neverSharedDesc": "We never share your data with anyone.", + "encrypted": "Military-grade encryption", + "encryptedDesc": "At rest and in transit. Your data never travels unprotected.", + "deleteAnytime": "Delete anytime", + "deleteAnytimeDesc": "Your data, your control.", + + "footerPrivacy": "Privacy", + "footerSecurity": "Security", + "footerFaq": "FAQ", + "footerContact": "Contact", + + "languageEnglish": "English", + "languageDutch": "Nederlands", + "languageRussian": "Русский" +} diff --git a/app/lib/l10n/app_nl.arb b/app/lib/l10n/app_nl.arb new file mode 100644 index 0000000..43ced2d --- /dev/null +++ b/app/lib/l10n/app_nl.arb @@ -0,0 +1,77 @@ +{ + "@@locale": "nl", + + "appTitle": "inou", + "appTagline": "ai antwoorden voor jou", + + "heroAnswer": "inou organiseert en deelt je gezondheidsdossier met je AI — veilig en privé.", + "heroTagline": "Jouw gezondheid, begrepen.", + "signIn": "Inloggen", + "inviteAFriend": "Nodig een vriend uit", + "getStarted": "Aan de slag", + + "youNeedAiTitle": "Je hebt AI nodig voor je gezondheid", + "youNeedAiPara1": "Je gezondheidsgegevens liggen op tientallen plekken verspreid — bij je cardioloog, je neuroloog, je lab, je horloge, je apps, je 23andMe. En alleen jij weet de rest: wat je eet, wat je drinkt, welke supplementen je neemt. Je trainingsschema. Je symptomen. Je doelen — of je nu zwanger probeert te worden, traint voor een marathon, of gewoon minder moe wilt zijn.", + "youNeedAiPara2": "Of je nu gezond bent en dat wilt blijven, een moeilijke diagnose aan het verwerken bent, of zorgt voor een familielid dat niet voor zichzelf kan opkomen — geen enkele arts ziet het volledige plaatje. Geen systeem verbindt alles.", + "youNeedAiPara3": "Maar jij hebt toegang tot alles. Je hebt alleen niet de expertise om het te begrijpen.", + "youNeedAiEmphasis": "Je AI wel. inou geeft het het complete beeld.", + + "challengeTitle": "De uitdaging", + "challengeMri": "Je MRI heeft 4.000 beelden.", + "challengeMriReality": "Het werd in 10 minuten beoordeeld.", + "challengeGenome": "Je genoom heeft miljoenen varianten.", + "challengeGenomeReality": "Het enige wat je leerde was je oogkleur en waar je voorouders vandaan kwamen.", + "challengeBlood": "Je bloedonderzoek heeft tientallen markers.", + "challengeBloodReality": "Je arts zei \"alles ziet er goed uit.\"", + "challengeWatch": "Je horloge registreerde 10.000 uur slaap.", + "challengeWatchReality": "Je trainer weet niet dat het bestaat.", + "challengeSupplements": "Je hebt honderd verschillende supplementen geprobeerd.", + "challengeSupplementsReality": "Niemand vroeg welke.", + + "challengeTransition": "De verbanden zijn er.\nZe zijn alleen te complex voor één persoon om te bevatten.", + + "challengeGap1": "Niemand weet hoe je lichaam Warfarine verwerkt — zelfs jij niet.", + "challengeGap1Indent": "Maar het antwoord ligt misschien al in je 23andMe.", + "challengeGap2": "Die 'niet-afwijkend' op je MRI — heeft iemand goed naar alle 4.000 beelden gekeken?", + "challengeGap3": "Je schildklier is 'binnen de norm' — maar niemand verbond het met je vermoeidheid, je gewicht, altijd koud zijn.", + + "challengeConnection1": "Niemand verbindt je middagcafeïne met je slaapscores.", + "challengeConnection2": "Je ijzergehalte met je trainingsvermoeidheid.", + "challengeConnection3": "Je genetica met je hersenmist.", + + "challengeAi1": "Je AI vergeet niet.", + "challengeAi2": "Haast zich niet.", + "challengeAi3": "Vindt wat gemist werd.", + "challengeAi4": "Specialiseert niet — ziet de complete jij.", + + "challengeClosing": "inou laat je AI alles meenemen — elke opname, elke marker, elke variant — verbindt alles en geeft je eindelijk antwoorden die niemand anders kon geven.", + + "whyTitle": "Waarom we dit bouwden", + "whyPara1": "Je hebt jarenlang gezondheidsgegevens verzameld. Scans van het ziekenhuis. Bloedonderzoek van het lab. Resultaten van je artsenportaal. Data van je horloge. Misschien zelfs je DNA.", + "whyPara2": "En dan is er alles wat alleen jij weet — je gewicht, je bloeddruk, je trainingsschema, de supplementen die je neemt, de symptomen die je steeds vergeet te noemen.", + "whyPara3": "Het is er allemaal — maar verspreid over systemen die niet met elkaar praten, beheerd door specialisten die alleen hun stukje zien, of opgesloten in je eigen hoofd.", + "whyPara4": "Je cardioloog weet niet wat je neuroloog vond. Je trainer heeft je bloedonderzoek niet gezien. Je arts heeft geen idee welke supplementen je neemt. En geen van hen heeft tijd om met je te zitten en de punten te verbinden.", + "whyPara5": "AI kan dat eindelijk. Het kan samenbrengen wat geen enkele expert ziet — en het je daadwerkelijk uitleggen.", + "whyPara6": "Maar deze data past niet in een chatvenster. En het laatste wat je wilt is je medische geschiedenis op andermans servers, hun modellen trainen.", + "whyPara7": "inou brengt alles samen — labs, beeldvorming, genetica, vitale functies, medicatie, supplementen — versleuteld, privé, en met niemand gedeeld. Je AI verbindt veilig. Je data blijft van jou.", + "whyClosing": "Jouw gezondheid, begrepen.", + + "trustLabel": "Jouw data blijft van jou", + "neverTraining": "Nooit gebruikt voor training", + "neverTrainingDesc": "Je beelden worden nooit gebruikt om AI-modellen te trainen.", + "neverShared": "Nooit gedeeld", + "neverSharedDesc": "We delen je data nooit met anderen.", + "encrypted": "Versleutelde opslag", + "encryptedDesc": "In rust en onderweg. Je data reist nooit onbeschermd.", + "deleteAnytime": "Altijd verwijderen", + "deleteAnytimeDesc": "Jouw data, jouw controle.", + + "footerPrivacy": "Privacy", + "footerSecurity": "Beveiliging", + "footerFaq": "FAQ", + "footerContact": "Contact", + + "languageEnglish": "English", + "languageDutch": "Nederlands", + "languageRussian": "Русский" +} diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb new file mode 100644 index 0000000..0094153 --- /dev/null +++ b/app/lib/l10n/app_ru.arb @@ -0,0 +1,77 @@ +{ + "@@locale": "ru", + + "appTitle": "inou", + "appTagline": "ии ответы для вас", + + "heroAnswer": "inou организует и делится вашим медицинским досье с вашим ИИ — безопасно и конфиденциально.", + "heroTagline": "Ваше здоровье, понятно.", + "signIn": "Войти", + "inviteAFriend": "Пригласить друга", + "getStarted": "Начать", + + "youNeedAiTitle": "Вам нужен ИИ для вашего здоровья", + "youNeedAiPara1": "Ваши медицинские данные хранятся в десятках разных мест — у кардиолога, невролога, в лаборатории, в ваших часах, приложениях, 23andMe. И только вы знаете остальное: что вы едите, что пьёте, какие добавки принимаете. Ваш режим тренировок. Ваши симптомы. Ваши цели — беременность, марафон, или просто меньше уставать.", + "youNeedAiPara2": "Здоровы вы или нет, справляетесь со сложным диагнозом или ухаживаете за членом семьи — ни один врач не видит полную картину. Ни одна система не связывает всё воедино.", + "youNeedAiPara3": "Но у вас есть доступ ко всему. Просто нет экспертизы, чтобы во всём разобраться.", + "youNeedAiEmphasis": "У вашего ИИ есть. inou даёт ему полную картину.", + + "challengeTitle": "Проблема", + "challengeMri": "Ваше МРТ содержит 4000 снимков.", + "challengeMriReality": "Их просмотрели за 10 минут.", + "challengeGenome": "Ваш геном содержит миллионы вариантов.", + "challengeGenomeReality": "Всё, что вы узнали — цвет глаз и откуда ваши предки.", + "challengeBlood": "В вашем анализе крови десятки показателей.", + "challengeBloodReality": "Врач сказал «всё в норме».", + "challengeWatch": "Ваши часы записали 10 000 часов сна.", + "challengeWatchReality": "Ваш тренер не знает об этом.", + "challengeSupplements": "Вы перепробовали сотню разных добавок.", + "challengeSupplementsReality": "Никто не спросил каких.", + + "challengeTransition": "Связи существуют.\nОни просто слишком сложны для одного человека.", + + "challengeGap1": "Никто не знает, как ваш организм обрабатывает варфарин — даже вы.", + "challengeGap1Indent": "Но ответ может уже быть в вашем 23andMe.", + "challengeGap2": "Это «без особенностей» на МРТ — кто-нибудь внимательно посмотрел все 4000 снимков?", + "challengeGap3": "Ваша щитовидка «в норме» — но никто не связал это с усталостью, весом, вечным холодом.", + + "challengeConnection1": "Никто не связывает послеобеденный кофе с качеством сна.", + "challengeConnection2": "Уровень железа с усталостью на тренировках.", + "challengeConnection3": "Генетику с туманом в голове.", + + "challengeAi1": "Ваш ИИ не забывает.", + "challengeAi2": "Не торопится.", + "challengeAi3": "Находит упущенное.", + "challengeAi4": "Не специализируется — видит вас полностью.", + + "challengeClosing": "inou позволяет вашему ИИ учесть всё — каждый снимок, каждый показатель, каждый вариант — связать всё вместе и наконец дать ответы, которые никто другой не мог.", + + "whyTitle": "Почему мы это создали", + "whyPara1": "Вы годами собирали данные о здоровье. Снимки из больницы. Анализы из лаборатории. Результаты с портала врача. Данные с часов. Может, даже ДНК.", + "whyPara2": "И есть всё то, что знаете только вы — вес, давление, график тренировок, добавки, симптомы, о которых забываете сказать.", + "whyPara3": "Всё это есть — но разбросано по системам, которые не общаются друг с другом, хранится у специалистов, которые видят только свой кусок, или заперто в вашей голове.", + "whyPara4": "Кардиолог не знает, что нашёл невролог. Тренер не видел анализы крови. Врач понятия не имеет о ваших добавках. И ни у кого нет времени сесть с вами и соединить точки.", + "whyPara5": "ИИ наконец может. Он может собрать то, что не видит ни один эксперт — и объяснить вам.", + "whyPara6": "Но эти данные не влезают в окно чата. И меньше всего вам нужно, чтобы медицинская история хранилась на чужих серверах и обучала их модели.", + "whyPara7": "inou собирает всё вместе — анализы, снимки, генетику, показатели, лекарства, добавки — зашифрованно, приватно, без передачи кому-либо. Ваш ИИ подключается безопасно. Ваши данные остаются вашими.", + "whyClosing": "Ваше здоровье, понятно.", + + "trustLabel": "Ваши данные остаются вашими", + "neverTraining": "Никогда не для обучения", + "neverTrainingDesc": "Ваши снимки никогда не используются для обучения моделей ИИ.", + "neverShared": "Никогда не передаются", + "neverSharedDesc": "Мы никогда не делимся вашими данными.", + "encrypted": "Шифрование военного уровня", + "encryptedDesc": "При хранении и передаче. Ваши данные никогда не путешествуют без защиты.", + "deleteAnytime": "Удалить в любое время", + "deleteAnytimeDesc": "Ваши данные, ваш контроль.", + + "footerPrivacy": "Конфиденциальность", + "footerSecurity": "Безопасность", + "footerFaq": "FAQ", + "footerContact": "Контакты", + + "languageEnglish": "English", + "languageDutch": "Nederlands", + "languageRussian": "Русский" +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 0000000..6b339a2 --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:inou_app/design/inou_theme.dart'; +import 'package:inou_app/core/router.dart'; +import 'package:inou_app/core/locale_provider.dart'; + +void main() { + runApp(const InouApp()); +} + +/// Global locale notifier - accessible throughout the app +final localeNotifier = ValueNotifier(const Locale('en')); + +class InouApp extends StatefulWidget { + const InouApp({super.key}); + + @override + State createState() => _InouAppState(); + + /// Static method to change locale from anywhere + static void setLocale(BuildContext context, Locale locale) { + localeNotifier.value = locale; + // Also persist to SharedPreferences + LocaleProvider().setLocale(locale); + } + + /// Get current locale + static Locale getLocale(BuildContext context) { + return localeNotifier.value; + } +} + +class _InouAppState extends State { + late LocaleProvider _localeProvider; + + @override + void initState() { + super.initState(); + _localeProvider = LocaleProvider(); + _localeProvider.addListener(_onLocaleChanged); + // Initialize with saved locale + _loadSavedLocale(); + } + + Future _loadSavedLocale() async { + // Wait for provider to load saved locale + await Future.delayed(const Duration(milliseconds: 100)); + if (mounted) { + localeNotifier.value = _localeProvider.locale; + } + } + + void _onLocaleChanged() { + if (mounted) { + localeNotifier.value = _localeProvider.locale; + } + } + + @override + void dispose() { + _localeProvider.removeListener(_onLocaleChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: localeNotifier, + builder: (context, locale, _) { + return MaterialApp.router( + title: 'inou', + debugShowCheckedModeBanner: false, + theme: InouTheme.light, + routerConfig: appRouter, + + // Localization setup + locale: locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: LocaleProvider.supportedLocales, + ); + }, + ); + } +} diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 0000000..f59b945 --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "inou_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.inou.inou_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/main.cc b/app/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/app/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/my_application.cc b/app/linux/my_application.cc new file mode 100644 index 0000000..5a1abee --- /dev/null +++ b/app/linux/my_application.cc @@ -0,0 +1,124 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "inou_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "inou_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/app/linux/my_application.h b/app/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/app/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..b8e2b22 --- /dev/null +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import path_provider_foundation +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b4604a5 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* inou_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "inou_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* inou_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* inou_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inou_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inou_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inou_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inou_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/inou_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/inou_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..693f7ab --- /dev/null +++ b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..8e02df2 --- /dev/null +++ b/app/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..a200e25 --- /dev/null +++ b/app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = inou_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.inou.inouApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.inou. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 0000000..edd28cb --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,439 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.5.4 <4.0.0" + flutter: ">=3.24.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 0000000..509872c --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,100 @@ +name: inou_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.5.4 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # Internationalization + intl: ^0.19.0 + + # Navigation + go_router: ^14.6.0 + + # State management + shared_preferences: ^2.3.5 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + google_fonts: ^6.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^4.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + generate: true + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + fonts: + - family: Sora + fonts: + - asset: fonts/Sora-Thin.ttf + weight: 100 + - asset: fonts/Sora-ExtraLight.ttf + weight: 200 + - asset: fonts/Sora-Light.ttf + weight: 300 + - asset: fonts/Sora-Regular.ttf + weight: 400 + - asset: fonts/Sora-SemiBold.ttf + weight: 600 + - asset: fonts/Sora-Bold.ttf + weight: 700 + - asset: fonts/Sora-ExtraBold.ttf + weight: 800 diff --git a/app/web/favicon.png b/app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/app/web/favicon.png differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..4734a8e --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + inou_app + + + + + + diff --git a/app/web/manifest.json b/app/web/manifest.json new file mode 100644 index 0000000..57419a7 --- /dev/null +++ b/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "inou_app", + "short_name": "inou_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/windows/.gitignore b/app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt new file mode 100644 index 0000000..072a55b --- /dev/null +++ b/app/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(inou_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "inou_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/app/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc new file mode 100644 index 0000000..b21b2bb --- /dev/null +++ b/app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.inou" "\0" + VALUE "FileDescription", "inou_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "inou_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.inou. All rights reserved." "\0" + VALUE "OriginalFilename", "inou_app.exe" "\0" + VALUE "ProductName", "inou_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/app/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp new file mode 100644 index 0000000..d238f30 --- /dev/null +++ b/app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"inou_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/app/windows/runner/resources/app_icon.ico differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/app/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/app/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/app/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/app/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/app/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/design/FLUTTER_STYLEGUIDE_GAPS.md b/design/FLUTTER_STYLEGUIDE_GAPS.md new file mode 100644 index 0000000..aaa8019 --- /dev/null +++ b/design/FLUTTER_STYLEGUIDE_GAPS.md @@ -0,0 +1,559 @@ +# inou Flutter Styleguide Gap Analysis + +**Generated:** 2025-01-28 +**Purpose:** Document gaps between the original web styleguide and the current Flutter implementation to achieve closer visual parity. + +--- + +## Executive Summary + +The Flutter app has a good foundation with `InouTheme` properly implementing the design tokens. However, there are several gaps in how those tokens are applied consistently across the app. The main issues are: + +1. **Typography inconsistencies** - font weights and sizes don't always match the web +2. **Missing CSS-specific styling** - dashed borders, hover states, transitions +3. **Component styling gaps** - buttons, inputs, and badges not pixel-perfect +4. **Layout structure** - page layout and spacing inconsistencies + +--- + +## 1. Typography Gaps + +### Current vs Original + +| Style | Web CSS | Flutter Implementation | Gap | +|-------|---------|------------------------|-----| +| Page Title (h1Large) | 2.5rem (37.5px), 700 | 37.5px, w700 | ✅ Matches | +| h1 | 2.25rem (33.75px), 300, -0.03em | 33.75px, w300, -0.03em | ✅ Matches | +| h2 Section Title | 1.4rem (21px), 600 | 21px, w600 | ✅ Matches | +| h3 Subsection | 1.1rem (16.5px), 600 | 16.5px, w600 | ✅ Matches | +| Intro text | 1.15rem (17.25px), 300, line-height 1.8 | 17.25px, w300, height 1.8 | ✅ Matches | +| Body light | 1rem (15px), 300, line-height 1.8 | Missing `bodyLight` in some places | ⚠️ Inconsistent use | +| Body regular | 1rem (15px), 400 | 15px, w400 | ✅ Matches | +| Label/Category | 0.75rem (11.25px), 600, uppercase, 0.1em | 11.25px, w600, 0.1em spacing | ⚠️ `toUpperCase()` not always called | +| Mono | SF Mono, 0.85rem (12.75px) | SF Mono, 12.75px | ⚠️ Fallback fonts missing | + +### Fixes Needed + +```dart +// In InouTheme, add missing style variants: + +static TextStyle get bodyLight => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w300, // Light, for long-form content + color: textMuted, + height: 1.8, +); + +// Mono font needs fallbacks +static TextStyle get mono => TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: const ['Monaco', 'Consolas', 'monospace'], + fontSize: 12.75, + fontWeight: FontWeight.w400, + color: text, +); +``` + +--- + +## 2. Color Gaps + +### Theme Colors ✅ +All colors match the design tokens exactly: +- `bg: #F8F7F6` ✅ +- `bgCard: #FFFFFF` ✅ +- `border: #E5E2DE` ✅ +- `text: #1C1917` ✅ +- `textMuted: #78716C` ✅ +- `textSubtle: #A8A29E` ✅ +- `accent: #B45309` ✅ +- All indicator colors ✅ + +### Missing Usage Patterns + +**Issue:** The web uses `opacity: 0.6` for "coming soon" and disabled states. Flutter uses hardcoded opacity. + +```dart +// Web CSS: +.data-card.coming-soon { opacity: 0.6; } +.btn-disabled { opacity: 0.6; } + +// Flutter should use consistent disabled opacity: +static const double disabledOpacity = 0.6; +``` + +--- + +## 3. Spacing Gaps + +### Spacing Scale ✅ +All spacing tokens match: +- xs: 4px ✅ +- sm: 8px ✅ +- md: 12px ✅ +- lg: 16px ✅ +- xl: 24px ✅ +- xxl: 32px ✅ +- xxxl: 48px ✅ + +### Usage Inconsistencies + +**Issue:** The dossier page uses hardcoded padding values instead of theme constants. + +```dart +// Current (in dossier_page.dart): +padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + +// Should be: +padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceXl, + vertical: InouTheme.spaceXxxl, +), +``` + +**Issue:** Card margins not consistent with web. + +```dart +// Web CSS: +.data-card { margin-bottom: 16px; } + +// Flutter InouCard uses spaceLg (16px) ✅ but some pages override +``` + +--- + +## 4. Border Radius Gaps + +### Radius Values ✅ +All match the design tokens. + +### Missing Application + +**Issue:** Some components don't use the theme radius: + +```dart +// Current (in various places): +borderRadius: BorderRadius.circular(4), + +// Should use: +borderRadius: InouTheme.borderRadiusSm, +``` + +--- + +## 5. Component Gaps + +### 5.1 Buttons + +**Web CSS:** +```css +.btn { + padding: 10px 18px; + font-size: 1rem; + font-weight: 500; + border-radius: 6px; + transition: all 0.15s; +} +.btn-small { + padding: 6px 12px; + font-size: 1rem; /* Same font size as regular */ +} +``` + +**Flutter Current:** +```dart +final padding = isSmall + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 6) + : const EdgeInsets.symmetric(horizontal: 18, vertical: 10); +// Font size differs: small uses 14px, regular uses 15px +``` + +**Fix:** +```dart +// Both sizes should use 15px (1rem) font +textStyle: InouTheme.labelLarge.copyWith( + fontSize: 15, // Always 15px, not 14 for small +), +``` + +### 5.2 Badges + +**Web CSS:** +```css +.badge { + padding: 2px 8px; + font-size: 1rem; /* 15px */ + font-weight: 500; + border-radius: 4px; + background: var(--accent-light); + color: var(--accent); +} +.badge-care { + background: var(--success-light); + color: var(--success); +} +``` + +**Flutter Current:** (inou_badge.dart) +- Padding: 4h, 8v (should be 2v, 8h) ❌ +- Font size: uses bodySmall (12.75px) instead of 15px ❌ + +**Fix:** +```dart +// InouBadge needs updating: +static TextStyle get badgeText => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w500, + color: text, +); + +// Padding should be: +padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), +``` + +### 5.3 Data Cards + +**Web CSS:** +```css +.data-card-header { + display: flex; + align-items: center; + padding: 16px; + gap: 12px; +} +.data-card-indicator { + width: 4px; + height: 32px; + border-radius: 2px; +} +.section-heading { + font-size: 0.75rem; /* 11.25px */ + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} +``` + +**Flutter Current:** +The `_DataCard` in dossier_page.dart mostly matches, but: +- Letter spacing is 0.8 instead of 0.08em (0.9px) ❌ +- Not using `InouTheme.labelSmall` which has correct specs + +**Fix:** +```dart +// In _DataCard header: +Text( + title, // Don't call toUpperCase() here + style: InouTheme.labelSmall, // Already has correct specs + uppercase handling +), +``` + +### 5.4 Data Rows + +**Web CSS:** +```css +.data-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px dashed var(--border); /* DASHED! */ +} +.data-row:last-child { + border-bottom: none; +} +.data-row.child { + padding-left: 48px; +} +``` + +**Flutter Current:** +- Uses solid borders instead of dashed ❌ +- Child row padding is 32px instead of 48px ❌ + +**Fix:** +```dart +// Flutter can't do dashed borders easily, but can simulate with DashedLine widget +// or use a dotted pattern via CustomPaint + +// For child padding: +padding: const EdgeInsets.only(left: 48, right: 16, top: 12, bottom: 12), +``` + +### 5.5 Form Inputs + +**Web CSS:** +```css +.form-group input { + width: 100%; + padding: 10px 12px; + font-size: 1rem; + border: 1px solid var(--border); + border-radius: 6px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.form-group input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} +``` + +**Flutter Current:** +The theme's `InputDecorationTheme` uses 2px border on focus, not 1px + shadow. + +**Fix:** +```dart +// In InputDecorationTheme: +focusedBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: accent, width: 1), // 1px, not 2px +), +// Add focus shadow via Container wrapper or InputDecoration +``` + +### 5.6 Messages (Error/Info/Success) + +**Web CSS:** +```css +.error { + background: var(--danger-light); + border: 1px solid #FECACA; + color: var(--danger); + padding: 10px 14px; + border-radius: 6px; +} +.info { + background: var(--accent-light); + border: 1px solid #FDE68A; + color: var(--accent); +} +.success { + background: var(--success-light); + border: 1px solid #A7F3D0; + color: var(--success); +} +``` + +**Flutter Current:** +`InouMessage` widget exists but check border colors. + +**Add specific border colors:** +```dart +static const Color errorBorder = Color(0xFFFECACA); +static const Color infoBorder = Color(0xFFFDE68A); +static const Color successBorder = Color(0xFFA7F3D0); +``` + +--- + +## 6. Layout Gaps + +### 6.1 Page Container + +**Web CSS:** +```css +.container { + max-width: 800px; /* maxWidthNarrow */ + margin: 0 auto; + padding: 40px 20px; +} +.container-narrow { + max-width: 360px; /* maxWidthForm */ + padding: 60px 20px 40px; +} +``` + +**Flutter:** Uses `InouPage` and `InouAuthFlowPage` which are close but: +- Auth pages should use `maxWidthForm` (360px) ✅ +- Dossier uses `maxWidth` (1200px) but should probably use `maxWidthNarrow` (800px) ❌ + +### 6.2 Nav Bar + +**Web CSS:** +```css +.nav { + padding: 12px 24px; + max-width: 1200px; + border-bottom: 1px solid var(--border); +} +.logo { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.02em; + text-transform: lowercase; +} +``` + +**Flutter Current:** +- Logo font size uses `h3` style (~18px) instead of 1.75rem (26.25px) ❌ +- Missing letter-spacing: -0.02em ❌ + +**Fix:** +```dart +// In InouHeader _buildLogo(): +Text( + 'inou', + style: GoogleFonts.sora( + fontSize: 26.25, // 1.75rem + fontWeight: FontWeight.w700, + color: InouTheme.accent, + letterSpacing: -0.02 * 26.25, // -0.02em + ), +), +``` + +### 6.3 Footer + +**Web CSS:** +```css +.footer { + margin-top: 40px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +``` + +**Flutter:** Check `InouFooter` for consistency. + +--- + +## 7. Missing Features + +### 7.1 Dashed Borders +The web uses `border-bottom: 1px dashed var(--border)` for data rows. Flutter doesn't support dashed borders natively. + +**Solutions:** +1. Use `dotted_border` package +2. Custom `CustomPainter` for dashed lines +3. Accept solid borders (simpler but less accurate) + +### 7.2 Hover States +Web has hover effects everywhere. Flutter web supports hover but mobile doesn't. + +**Solutions:** +1. Use `InkWell` with splash colors +2. On web, implement `MouseRegion` for hover states +3. Accept that mobile won't have hover + +### 7.3 Transitions +Web CSS has `transition: all 0.15s` on interactive elements. + +**Solution:** +Add `AnimatedContainer` or `AnimatedDefaultTextStyle` where appropriate. + +--- + +## 8. Priority Fixes + +### High Priority (Visual Impact) +1. **Badge font size and padding** - very noticeable +2. **Logo size in header** - brand consistency +3. **Button small font size** - should match regular +4. **Data card title letter-spacing** - subtle but important + +### Medium Priority +5. **Child row padding** (32px → 48px) +6. **Dossier page max-width** (1200px → 800px) +7. **Input focus style** (2px → 1px + shadow) +8. **Message border colors** - semantic colors + +### Low Priority (Polish) +9. Dashed borders (requires package or custom paint) +10. Hover states (platform-specific) +11. Transitions (nice to have) + +--- + +## 9. Recommended Actions + +### Immediate (Theme File Updates) + +```dart +// Add to InouTheme: + +// 1. Badge text style +static TextStyle get badgeText => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w500, +); + +// 2. Logo style +static TextStyle get logo => GoogleFonts.sora( + fontSize: 26.25, + fontWeight: FontWeight.w700, + letterSpacing: -0.02 * 26.25, +); + +// 3. Message border colors +static const Color errorBorder = Color(0xFFFECACA); +static const Color infoBorder = Color(0xFFFDE68A); +static const Color successBorder = Color(0xFFA7F3D0); + +// 4. Consistent disabled opacity +static const double disabledOpacity = 0.6; +``` + +### Widget Updates + +1. **InouBadge** - Fix padding (2v, 8h) and font size (15px) +2. **InouButton** - Use 15px for small buttons too +3. **InouHeader** - Use new logo style +4. **InouCard** - Ensure labelSmall is used correctly + +### Page Updates + +1. **DossierPage** - Change max-width to 800px +2. **All pages** - Use InouTheme spacing constants instead of hardcoded values + +--- + +## 10. Testing Checklist + +After applying fixes, verify: + +- [ ] Landing page hero text matches web +- [ ] Login form looks identical to web +- [ ] Dashboard profile cards match +- [ ] Dossier data cards and rows match +- [ ] Badges render at correct size +- [ ] Buttons (both sizes) match +- [ ] Header logo size and spacing correct +- [ ] Footer styling matches +- [ ] Messages (error/info/success) match +- [ ] Form inputs match (esp. focus state) + +--- + +## Appendix: Quick Reference + +### Font Sizes (Base 15px) +| Name | rem | px | +|------|-----|-----| +| h1Large | 2.5 | 37.5 | +| h1 | 2.25 | 33.75 | +| h2 | 1.4 | 21 | +| h3 | 1.1 | 16.5 | +| intro | 1.15 | 17.25 | +| body | 1.0 | 15 | +| small | 0.85 | 12.75 | +| label | 0.75 | 11.25 | +| logo | 1.75 | 26.25 | + +### Key Spacing +| Use Case | Value | +|----------|-------| +| Card margin-bottom | 16px | +| Card padding | 16px | +| Header padding | 12px 24px | +| Container padding | 40px 20px | +| Button padding | 10px 18px | +| Button small padding | 6px 12px | +| Badge padding | 2px 8px | + +### Border Radius +| Use Case | Value | +|----------|-------| +| Buttons | 6px (radiusMd) | +| Cards | 8px (radiusLg) | +| Badges | 4px (radiusSm) | +| Inputs | 6px (radiusMd) | diff --git a/design/flutter/inou_theme.dart b/design/flutter/inou_theme.dart new file mode 100644 index 0000000..b222f52 --- /dev/null +++ b/design/flutter/inou_theme.dart @@ -0,0 +1,270 @@ +// AUTO-GENERATED from tokens.json — do not edit directly +// Run: node design/generate.js + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// inou Design System +/// Single source of truth: design/tokens.json +class InouTheme { + InouTheme._(); + + // ============================================ + // COLORS + // ============================================ + static const Color bg = Color(0xFFF8F7F6); + static const Color bgCard = Color(0xFFFFFFFF); + static const Color border = Color(0xFFE5E2DE); + static const Color borderHover = Color(0xFFC4BFB8); + static const Color text = Color(0xFF1C1917); + static const Color textMuted = Color(0xFF78716C); + static const Color textSubtle = Color(0xFFA8A29E); + static const Color accent = Color(0xFFB45309); + static const Color accentHover = Color(0xFF92400E); + static const Color accentLight = Color(0xFFFEF3C7); + static const Color danger = Color(0xFFDC2626); + static const Color dangerLight = Color(0xFFFEF2F2); + static const Color success = Color(0xFF059669); + static const Color successLight = Color(0xFFECFDF5); + + // Indicator colors (data sections) + static const Color indicatorImaging = Color(0xFFB45309); + static const Color indicatorLabs = Color(0xFF059669); + static const Color indicatorUploads = Color(0xFF6366F1); + static const Color indicatorVitals = Color(0xFFEC4899); + static const Color indicatorMedications = Color(0xFF8B5CF6); + static const Color indicatorRecords = Color(0xFF06B6D4); + static const Color indicatorJournal = Color(0xFFF59E0B); + static const Color indicatorPrivacy = Color(0xFF64748B); + static const Color indicatorGenetics = Color(0xFF10B981); + + // ============================================ + // SPACING + // ============================================ + static const double spaceXs = 4.0; + static const double spaceSm = 8.0; + static const double spaceMd = 12.0; + static const double spaceLg = 16.0; + static const double spaceXl = 24.0; + static const double spaceXxl = 32.0; + static const double spaceXxxl = 48.0; + + // ============================================ + // BORDER RADIUS + // ============================================ + static const double radiusSm = 4.0; + static const double radiusMd = 6.0; + static const double radiusLg = 8.0; + static const double radiusXl = 12.0; + static const double radiusFull = 9999.0; + static BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm); + static BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd); + static BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg); + + // ============================================ + // LAYOUT + // ============================================ + static const double maxWidth = 1200.0; + static const double maxWidthNarrow = 800.0; + static const double maxWidthForm = 360.0; + + // ============================================ + // TYPOGRAPHY — matches inou.com/static/style.css + // Base: 15px (body) + // ============================================ + static String get fontFamily => 'Sora'; + + // Page Title: 2.5rem / 700 = 37.5px + static TextStyle get pageTitle => GoogleFonts.sora( + fontSize: 37.5, + fontWeight: FontWeight.w700, + color: text, + ); + + // h1: 2.25rem / 300 / -0.03em = 33.75px (used in forms, etc.) + static TextStyle get h1 => GoogleFonts.sora( + fontSize: 33.75, + fontWeight: FontWeight.w300, + color: text, + letterSpacing: -0.45, // -0.03em × 15px + ); + + // h1.small: 1.5rem / 300 = 22.5px + static TextStyle get h1Small => GoogleFonts.sora( + fontSize: 22.5, + fontWeight: FontWeight.w300, + color: text, + ); + + // Section Title: 1.4rem / 600 = 21px (.privacy-container h2) + static TextStyle get sectionTitle => GoogleFonts.sora( + fontSize: 21.0, + fontWeight: FontWeight.w600, + color: text, + ); + + // h2 (regular): 1.5rem / 300 / -0.02em = 22.5px + static TextStyle get h2 => GoogleFonts.sora( + fontSize: 22.5, + fontWeight: FontWeight.w300, + color: text, + letterSpacing: -0.3, // -0.02em × 15px + ); + + // Subsection Title: 1.1rem / 600 = 16.5px (.privacy-container h3) + static TextStyle get subsectionTitle => GoogleFonts.sora( + fontSize: 16.5, + fontWeight: FontWeight.w600, + color: text, + ); + + // h3 (regular): 1.125rem / 500 = 16.875px + static TextStyle get h3 => GoogleFonts.sora( + fontSize: 16.875, + fontWeight: FontWeight.w500, + color: text, + ); + + // Intro text: 1.15rem / 300 = 17.25px + static TextStyle get intro => GoogleFonts.sora( + fontSize: 17.25, + fontWeight: FontWeight.w300, + color: textMuted, + height: 1.8, + ); + + // Body light (long-form): 1rem / 300 = 15px + static TextStyle get bodyLight => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w300, + color: textMuted, + height: 1.8, + ); + + // Body regular (UI labels): 1rem / 400 = 15px + static TextStyle get body => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w400, + color: text, + ); + + // Label / Category: 0.75rem / 600 / caps / 0.1em = 11.25px + static TextStyle get label => GoogleFonts.sora( + fontSize: 11.25, + fontWeight: FontWeight.w600, + color: textSubtle, + letterSpacing: 1.125, // 0.1em × 11.25px + ); + + // Small text: 0.85rem / 400 = 12.75px + static TextStyle get small => GoogleFonts.sora( + fontSize: 12.75, + fontWeight: FontWeight.w400, + color: textMuted, + ); + + // Mono (SF Mono fallback) + static TextStyle get mono => const TextStyle( + fontFamily: 'SF Mono', + fontFamilyFallback: ['Monaco', 'Consolas', 'monospace'], + fontSize: 12.75, + color: text, + ); + + // Legacy aliases for compatibility + static TextStyle get h1Large => pageTitle; + static TextStyle get bodyLarge => intro; + static TextStyle get bodyMedium => body; + static TextStyle get bodySmall => small; + static TextStyle get labelLarge => GoogleFonts.sora( + fontSize: 15.0, + fontWeight: FontWeight.w500, + color: text, + ); + static TextStyle get labelSmall => label; + + // ============================================ + // THEME DATA + // ============================================ + static ThemeData get light => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: bg, + colorScheme: ColorScheme.light( + primary: accent, + onPrimary: Colors.white, + secondary: accentLight, + onSecondary: accent, + surface: bgCard, + onSurface: text, + error: danger, + onError: Colors.white, + outline: border, + ), + textTheme: TextTheme( + displayLarge: pageTitle, + displayMedium: h1, + headlineMedium: sectionTitle, + headlineSmall: subsectionTitle, + bodyLarge: intro, + bodyMedium: body, + bodySmall: small, + labelLarge: labelLarge, + labelSmall: label, + ), + appBarTheme: AppBarTheme( + backgroundColor: bg, + foregroundColor: text, + elevation: 0, + centerTitle: false, + ), + cardTheme: CardTheme( + color: bgCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: borderRadiusLg, + side: BorderSide(color: border), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: accent, + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: labelLarge, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: text, + side: BorderSide(color: border), + padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd), + shape: RoundedRectangleBorder(borderRadius: borderRadiusMd), + textStyle: labelLarge, + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: bgCard, + border: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: borderRadiusMd, + borderSide: BorderSide(color: accent, width: 2), + ), + contentPadding: EdgeInsets.symmetric(horizontal: spaceMd, vertical: spaceMd), + ), + dividerTheme: DividerThemeData( + color: border, + thickness: 1, + ), + ); +} \ No newline at end of file diff --git a/design/flutter/main_example.dart b/design/flutter/main_example.dart new file mode 100644 index 0000000..68aa257 --- /dev/null +++ b/design/flutter/main_example.dart @@ -0,0 +1,24 @@ +// Example main.dart showing how to use the inou design system +// Copy this to your Flutter project's lib/main.dart + +import 'package:flutter/material.dart'; +import 'inou_theme.dart'; +import 'screens/styleguide_screen.dart'; + +void main() { + runApp(const InouApp()); +} + +class InouApp extends StatelessWidget { + const InouApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'inou', + debugShowCheckedModeBanner: false, + theme: InouTheme.light, // ← One line to apply the entire design system + home: const StyleguideScreen(), + ); + } +} diff --git a/design/flutter/screens/styleguide_screen.dart b/design/flutter/screens/styleguide_screen.dart new file mode 100644 index 0000000..083a057 --- /dev/null +++ b/design/flutter/screens/styleguide_screen.dart @@ -0,0 +1,1333 @@ +// Flutter Styleguide — matches inou.com/styleguide exactly +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; +import '../widgets/widgets.dart'; + +class StyleguideScreen extends StatefulWidget { + const StyleguideScreen({super.key}); + + @override + State createState() => _StyleguideScreenState(); +} + +class _StyleguideScreenState extends State { + String? _selectedOption = 'Option 1'; + String _selectedSex = 'male'; + bool _checkboxValue = true; + String _selectedLLM = 'claude'; + String _selectedUnits = 'metric'; + bool _showGeneDetails = true; + bool _showVitalHistory = false; + bool _showNoteDetails = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: InouTheme.bg, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: InouTheme.maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Text('Style Guide', style: InouTheme.pageTitle), + const SizedBox(height: 8), + Text( + 'Design system components for inou', + style: InouTheme.intro, + ), + const SizedBox(height: 32), + + // Text Blocks + _buildTextBlocksSection(), + + // Typography + _buildTypographySection(), + + // Colors + _buildColorsSection(), + + // Buttons + _buildButtonsSection(), + + // Badges + _buildBadgesSection(), + + // Messages + _buildMessagesSection(), + + // Form Elements + _buildFormsSection(), + + // Settings + _buildSettingsSection(), + + // Profile Cards + _buildProfileCardsSection(), + + // Data Cards (Imaging, Labs) + _buildDataCardsSection(), + + // Genetics + _buildGeneticsSection(), + + // Vitals + _buildVitalsSection(), + + // Notes + _buildNotesSection(), + + // Supplements + _buildSupplementsSection(), + + // Peptides + _buildPeptidesSection(), + + // Upload Area + _buildUploadSection(), + + // Empty State + _buildEmptyStateSection(), + + const SizedBox(height: 48), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTextBlocksSection() { + return InouCard( + title: 'Text Blocks', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your data. Your rules.', + style: InouTheme.pageTitle, + ), + const SizedBox(height: 16), + RichText( + text: TextSpan( + style: InouTheme.bodyLight, + children: [ + const TextSpan(text: 'We built '), + TextSpan( + text: 'inou', + style: TextStyle( + fontWeight: FontWeight.w700, + color: InouTheme.accent, + ), + ), + const TextSpan( + text: ' because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.', + ), + ], + ), + ), + const SizedBox(height: 32), + Text( + 'What we collect', + style: InouTheme.sectionTitle, + ), + const SizedBox(height: 16), + Text( + 'Account information.', + style: InouTheme.subsectionTitle, + ), + const SizedBox(height: 8), + Text( + 'Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.', + style: InouTheme.bodyLight, + ), + ], + ), + ), + ); + } + + Widget _buildTypographySection() { + return InouCard( + title: 'Typography Scale', + indicatorColor: InouTheme.indicatorLabs, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TypographyRow('Page Title', InouTheme.pageTitle, '2.5rem / 700'), + _TypographyRow('Section Title', InouTheme.sectionTitle, '1.4rem / 600'), + _TypographyRow('Subsection Title', InouTheme.subsectionTitle, '1.1rem / 600'), + _TypographyRow( + 'LABEL / CATEGORY', + InouTheme.label.copyWith(color: InouTheme.textSubtle), + '0.75rem / 600 / caps', + isUppercase: true, + ), + _TypographyRow( + 'Intro text — larger, lighter', + InouTheme.intro, + '1.15rem / 300', + ), + _TypographyRow( + 'Body light — long-form', + InouTheme.bodyLight, + '1rem / 300', + ), + _TypographyRow( + 'Body regular — UI labels', + InouTheme.body, + '1rem / 400', + ), + _TypographyRow( + 'Mono: 1,234,567.89', + InouTheme.mono, + 'SF Mono', + ), + ], + ), + ), + ); + } + + Widget _buildColorsSection() { + return InouCard( + title: 'Colors', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _ColorRow('Accent', InouTheme.accent, '#B45309'), + _ColorRow('Text', InouTheme.text, '#1C1917'), + _ColorRow('Text Muted', InouTheme.textMuted, '#78716C'), + _ColorRow('Background', InouTheme.bg, '#F8F7F6', hasBorder: true), + _ColorRow('Success', InouTheme.success, '#059669'), + _ColorRow('Danger', InouTheme.danger, '#DC2626'), + ], + ), + ), + ); + } + + Widget _buildButtonsSection() { + return InouCard( + title: 'Buttons', + indicatorColor: InouTheme.indicatorVitals, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + InouButton(text: 'Primary', onPressed: () {}), + InouButton( + text: 'Secondary', + variant: ButtonVariant.secondary, + onPressed: () {}, + ), + InouButton( + text: 'Danger', + variant: ButtonVariant.danger, + onPressed: () {}, + ), + InouButton( + text: 'Small', + size: ButtonSize.small, + onPressed: () {}, + ), + ], + ), + ), + ); + } + + Widget _buildBadgesSection() { + return InouCard( + title: 'Badges', + indicatorColor: InouTheme.indicatorMedications, + child: Padding( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: const [ + InouBadge(text: 'default'), + InouBadge(text: 'care', variant: BadgeVariant.care), + InouBadge(text: 'COMING SOON', variant: BadgeVariant.comingSoon), + InouBadge(text: 'processing', variant: BadgeVariant.processing), + ], + ), + ), + ); + } + + Widget _buildMessagesSection() { + return InouCard( + title: 'Messages', + indicatorColor: InouTheme.indicatorRecords, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: const [ + InouMessage( + message: 'Error message — something went wrong.', + type: MessageType.error, + ), + SizedBox(height: 12), + InouMessage( + message: "Info message — here's some useful information.", + type: MessageType.info, + ), + SizedBox(height: 12), + InouMessage( + message: 'Success message — operation completed.', + type: MessageType.success, + ), + ], + ), + ), + ); + } + + Widget _buildFormsSection() { + return InouCard( + title: 'Form Elements', + indicatorColor: InouTheme.indicatorJournal, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const InouTextField( + label: 'Text Input', + placeholder: 'Enter text...', + ), + const SizedBox(height: 16), + InouSelect( + label: 'Select', + value: _selectedOption, + options: const [ + InouSelectOption(value: 'Option 1', label: 'Option 1'), + InouSelectOption(value: 'Option 2', label: 'Option 2'), + InouSelectOption(value: 'Option 3', label: 'Option 3'), + ], + onChanged: (v) => setState(() => _selectedOption = v), + ), + const SizedBox(height: 16), + const InouTextField( + label: 'Code Input', + placeholder: '123456', + isCode: true, + maxLength: 6, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + InouRadioGroup( + value: _selectedSex, + options: const [ + InouRadioOption(value: 'male', label: 'Male'), + InouRadioOption(value: 'female', label: 'Female'), + ], + onChanged: (v) => setState(() => _selectedSex = v ?? 'male'), + ), + const SizedBox(height: 16), + InouCheckbox( + value: _checkboxValue, + label: 'Can add data (supplements, notes, etc.)', + onChanged: (v) => setState(() => _checkboxValue = v ?? false), + ), + ], + ), + ), + ); + } + + Widget _buildSettingsSection() { + return InouCard( + title: 'Settings', + indicatorColor: InouTheme.indicatorPrivacy, + child: Column( + children: [ + // LLM Selector + _SettingsRow( + label: 'Primary AI Assistant', + description: 'Used for "Ask AI" prompts and analysis', + child: Column( + children: [ + _LLMOption( + icon: '🤖', + name: 'Claude (Anthropic)', + value: 'claude', + selected: _selectedLLM == 'claude', + onTap: () => setState(() => _selectedLLM = 'claude'), + ), + _LLMOption( + icon: '💬', + name: 'ChatGPT (OpenAI)', + value: 'chatgpt', + selected: _selectedLLM == 'chatgpt', + onTap: () => setState(() => _selectedLLM = 'chatgpt'), + ), + _LLMOption( + icon: '✖', + name: 'Grok (xAI)', + value: 'grok', + selected: _selectedLLM == 'grok', + onTap: () => setState(() => _selectedLLM = 'grok'), + ), + ], + ), + ), + const Divider(height: 1, color: InouTheme.border), + // Units Selector + _SettingsRow( + label: 'Units', + description: 'Measurement system for vitals', + child: InouSelect( + value: _selectedUnits, + options: const [ + InouSelectOption(value: 'metric', label: 'Metric (kg, cm, °C)'), + InouSelectOption(value: 'imperial', label: 'Imperial (lb, in, °F)'), + ], + onChanged: (v) => setState(() => _selectedUnits = v ?? 'metric'), + ), + ), + ], + ), + ); + } + + Widget _buildProfileCardsSection() { + return InouCard( + title: 'Profile Cards', + indicatorColor: InouTheme.indicatorImaging, + child: Padding( + padding: const EdgeInsets.all(24), + child: LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth > 700 + ? (constraints.maxWidth - 24) / 3 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Johan Jongsma', + role: 'you', + dob: '1985-03-15', + sex: 'Male', + stats: const [ + ProfileStat('📷', '3 studies'), + ProfileStat('🧪', '12 labs'), + ProfileStat('🧬', 'genome'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouProfileCard( + name: 'Sophia', + role: 'my role: Parent', + dob: '2017-01-01', + sex: 'Female', + isCare: true, + stats: const [ + ProfileStat('📷', '16 studies'), + ProfileStat('🧪', '0 labs'), + ], + onTap: () {}, + ), + ), + SizedBox( + width: cardWidth, + height: 180, + child: InouAddCard( + label: 'Add dossier', + onTap: () {}, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildDataCardsSection() { + return Column( + children: [ + // Imaging + InouCard( + title: 'Imaging', + subtitle: '16 studies · 4113 slices', + indicatorColor: InouTheme.indicatorImaging, + trailing: InouButton( + text: 'Open viewer', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'MRI BRAIN W/WO CONTRAST', + meta: '13 series', + date: '5/5/2022', + isExpandable: true, + children: [ + InouChildRow(label: 'AX T1', meta: '24 slices'), + InouChildRow(label: 'AX T2 FLAIR', meta: '24 slices'), + InouChildRow(label: 'SAG T1', meta: '20 slices'), + ], + ), + InouDataRow( + label: 'XR CHEST AP ONLY', + date: '5/6/2022', + trailing: GestureDetector( + onTap: () {}, + child: Text('→', style: TextStyle( + color: InouTheme.accent, + fontSize: 18, + fontWeight: FontWeight.w500, + )), + ), + ), + ], + ), + ), + + // Labs + InouCard( + title: 'Labs', + subtitle: '4 panels · 23 results', + indicatorColor: InouTheme.indicatorLabs, + child: Column( + children: [ + InouDataRow( + label: 'Complete Blood Count (CBC)', + meta: '8 tests', + date: '12/15/2024', + isExpandable: true, + initiallyExpanded: true, + children: const [ + InouChildRow( + label: 'Hemoglobin', + value: '14.2 g/dL', + meta: '12.0–16.0', + ), + InouChildRow( + label: 'White Blood Cells', + value: '7.8 K/µL', + meta: '4.5–11.0', + ), + InouChildRow( + label: 'Platelets', + value: '142 K/µL', + meta: '150–400', + valueColor: InouTheme.danger, + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _buildGeneticsSection() { + return InouCard( + title: 'Genetics', + subtitle: 'Medication Response · 47 variants', + indicatorColor: InouTheme.indicatorGenetics, + child: Column( + children: [ + InouDataRow( + label: 'Medication Response', + meta: '47 variants', + isExpandable: true, + initiallyExpanded: _showGeneDetails, + onExpandChanged: (v) => setState(() => _showGeneDetails = v), + children: [ + _GeneVariantRow( + gene: 'CYP2C19', + rsid: 'rs4244285', + allele: 'G;A', + status: 'intermediate', + summary: 'Intermediate metabolizer for clopidogrel (Plavix). May need dose adjustment or alternative medication.', + onAskAI: () {}, + ), + ], + ), + // Show more link + InkWell( + onTap: () {}, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Center( + child: Text( + 'Show all 47 variants in Medication Response →', + style: InouTheme.small.copyWith(color: InouTheme.accent), + ), + ), + ), + ), + InouDataRow( + label: 'Metabolism', + meta: '23 variants', + isExpandable: true, + ), + InouDataRow( + label: 'Cardiovascular', + meta: '18 variants', + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildVitalsSection() { + return InouCard( + title: 'Vitals', + subtitle: 'Self-reported measurements', + indicatorColor: InouTheme.indicatorVitals, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Temperature', + value: '37.2 °C', + meta: 'today', + leading: const InouNoteIcon(emoji: '🌡', color: InouTheme.danger), + isExpandable: true, + initiallyExpanded: _showVitalHistory, + onExpandChanged: (v) => setState(() => _showVitalHistory = v), + children: [ + _VitalHistoryRow(date: 'Today, 8:30 AM', value: '37.2 °C'), + _VitalHistoryRow(date: 'Yesterday, 8:15 AM', value: '36.8 °C'), + _VitalHistoryRow(date: 'Dec 24, 7:45 AM', value: '37.0 °C'), + ], + ), + InouDataRow( + label: 'Weight', + value: '72.4 kg', + meta: 'today', + leading: InouNoteIcon(emoji: '⚖', color: Colors.blue.shade600), + isExpandable: true, + ), + InouDataRow( + label: 'Blood Pressure', + value: '118/76', + meta: 'yesterday', + leading: InouNoteIcon(emoji: '❤', color: Colors.pink.shade600), + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildNotesSection() { + return InouCard( + title: 'Notes', + subtitle: 'Health journal entries', + indicatorColor: InouTheme.indicatorJournal, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: [ + InouDataRow( + label: 'Knee injury', + meta: '3 photos', + date: 'Dec 20', + leading: const InouNoteIcon(emoji: '📷', color: Color(0xFF6366F1)), + trailing: _NoteCategoryBadge(text: 'injury'), + isExpandable: true, + initiallyExpanded: _showNoteDetails, + onExpandChanged: (v) => setState(() => _showNoteDetails = v), + children: [ + _NotePhotosRow(), + _NoteTimelineRow( + date: 'Dec 20, 3:45 PM', + text: 'Jim fell on his knee at soccer practice. Swelling visible, applied ice.', + ), + _NoteTimelineRow( + date: 'Dec 22, 10:20 AM', + text: 'Swelling reduced. Still some bruising. Can walk without pain.', + ), + _NoteTimelineRow( + date: 'Dec 26, 9:15 AM', + text: 'Almost fully healed. Light bruise remaining.', + ), + ], + ), + InouDataRow( + label: 'Mild headache after workout', + date: 'Dec 25', + leading: const InouNoteIcon(emoji: '📝', color: InouTheme.accent), + isExpandable: true, + ), + ], + ), + ); + } + + Widget _buildSupplementsSection() { + return InouCard( + title: 'Supplements', + subtitle: 'Daily routine', + indicatorColor: InouTheme.indicatorMedications, + trailing: InouButton( + text: '+ Add', + variant: ButtonVariant.secondary, + size: ButtonSize.small, + onPressed: () {}, + ), + child: Column( + children: const [ + _SupplementRow( + name: 'Vitamin D3', + dose: '1 capsule', + amount: '5000 IU', + timing: 'morning, with food', + ), + _SupplementRow( + name: 'Omega-3 Fish Oil', + dose: '2 capsules', + amount: '2000 mg EPA/DHA', + timing: 'morning, with food', + ), + _SupplementRow( + name: 'Magnesium Glycinate', + dose: '2 capsules', + amount: '400 mg', + timing: 'evening', + ), + _SupplementRow( + name: 'Liquid B12', + dose: '5 ml', + amount: '1000 mcg', + timing: 'morning', + ), + ], + ), + ); + } + + Widget _buildPeptidesSection() { + return InouCard( + title: 'Peptides', + subtitle: 'Therapeutic protocols', + indicatorColor: InouTheme.indicatorMedications, + child: Column( + children: const [ + _PeptideRow( + name: 'BPC-157', + dose: '250 mcg subQ · 2x daily', + endDate: 'until Jan 23, 2025', + status: 'active', + ), + _PeptideRow( + name: 'TB-500', + dose: '2.5 mg subQ · 2x weekly', + endDate: 'until Feb 5, 2025', + status: 'active', + ), + _PeptideRow( + name: 'BPC-157', + dose: '250 mcg subQ · 2x daily', + endDate: 'Aug 15 – Sep 7, 2025', + status: 'completed', + ), + ], + ), + ); + } + + Widget _buildUploadSection() { + return InouCard( + title: 'Upload Area', + indicatorColor: InouTheme.indicatorUploads, + child: Padding( + padding: const EdgeInsets.all(24), + child: Container( + padding: const EdgeInsets.all(40), + decoration: BoxDecoration( + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, + ), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Column( + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 32, + color: InouTheme.accent, + ), + const SizedBox(height: 12), + Text( + 'Click or drag files here', + style: InouTheme.body.copyWith(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + 'DICOM, PDF, CSV, VCF, and more', + style: InouTheme.small, + ), + ], + ), + ), + ), + ); + } + + Widget _buildEmptyStateSection() { + return InouCard( + title: 'Empty State', + indicatorColor: InouTheme.indicatorRecords, + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusLg, + ), + child: Center( + child: Text( + 'No lab data', + style: InouTheme.body.copyWith(color: InouTheme.textMuted), + ), + ), + ), + ); + } +} + +// ============================================ +// Helper widgets +// ============================================ + +class _TypographyRow extends StatelessWidget { + final String text; + final TextStyle style; + final String spec; + final bool isUppercase; + + const _TypographyRow(this.text, this.style, this.spec, {this.isUppercase = false}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + isUppercase ? text.toUpperCase() : text, + style: style, + ), + ), + Text( + spec, + style: InouTheme.mono.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ); + } +} + +class _ColorRow extends StatelessWidget { + final String name; + final Color color; + final String hex; + final bool hasBorder; + + const _ColorRow(this.name, this.color, this.hex, {this.hasBorder = false}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(6), + border: hasBorder ? Border.all(color: InouTheme.border) : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text(name, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + ), + Text(hex, style: InouTheme.mono.copyWith(color: InouTheme.textMuted)), + ], + ), + ); + } +} + +class _SettingsRow extends StatelessWidget { + final String label; + final String description; + final Widget child; + + const _SettingsRow({ + required this.label, + required this.description, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text(description, style: InouTheme.small), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + flex: 3, + child: child, + ), + ], + ), + ); + } +} + +class _LLMOption extends StatelessWidget { + final String icon; + final String name; + final String value; + final bool selected; + final VoidCallback onTap; + + const _LLMOption({ + required this.icon, + required this.name, + required this.value, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: selected ? InouTheme.accentLight : InouTheme.bgCard, + border: Border.all( + color: selected ? InouTheme.accent : InouTheme.border, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text(icon, style: const TextStyle(fontSize: 12)), + ), + const SizedBox(width: 8), + Text( + name, + style: InouTheme.body.copyWith( + color: selected ? InouTheme.accent : InouTheme.text, + ), + ), + ], + ), + ), + ); + } +} + +class _GeneVariantRow extends StatelessWidget { + final String gene; + final String rsid; + final String allele; + final String status; + final String summary; + final VoidCallback onAskAI; + + const _GeneVariantRow({ + required this.gene, + required this.rsid, + required this.allele, + required this.status, + required this.summary, + required this.onAskAI, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(gene, style: InouTheme.body.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Text(rsid, style: InouTheme.mono.copyWith(fontSize: 12, color: InouTheme.textMuted)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text(allele, style: InouTheme.mono.copyWith(fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 8), + Text(status, style: InouTheme.small.copyWith(color: InouTheme.accent)), + ], + ), + const SizedBox(height: 8), + Text(summary, style: InouTheme.small.copyWith(height: 1.4)), + const SizedBox(height: 12), + GestureDetector( + onTap: onAskAI, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: InouTheme.accentLight, + border: Border.all(color: InouTheme.accent), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Ask AI', + style: InouTheme.small.copyWith( + color: InouTheme.accent, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _VitalHistoryRow extends StatelessWidget { + final String date; + final String value; + + const _VitalHistoryRow({required this.date, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(date, style: InouTheme.small), + Text(value, style: InouTheme.mono), + ], + ), + ); + } +} + +class _NoteCategoryBadge extends StatelessWidget { + final String text; + + const _NoteCategoryBadge({required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: InouTheme.bg, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + text, + style: InouTheme.small.copyWith( + fontSize: 11.25, + color: InouTheme.textSubtle, + ), + ), + ); + } +} + +class _NotePhotosRow extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: InouTheme.bg, + border: Border(top: BorderSide(color: InouTheme.border)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _PhotoPlaceholder(label: 'Dec 20, 3:45 PM'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 22, 10:20 AM'), + const SizedBox(width: 12), + _PhotoPlaceholder(label: 'Dec 26, 9:15 AM'), + const SizedBox(width: 12), + _AddPhotoPlaceholder(), + ], + ), + ], + ), + ); + } +} + +class _PhotoPlaceholder extends StatelessWidget { + final String label; + + const _PhotoPlaceholder({required this.label}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.border, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: InouTheme.border), + ), + alignment: Alignment.center, + child: const Text('🦵', style: TextStyle(fontSize: 24)), + ), + const SizedBox(height: 4), + SizedBox( + width: 64, + child: Text( + label.split(', ').first, + style: InouTheme.small.copyWith(fontSize: 10.5), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class _AddPhotoPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: InouTheme.border, style: BorderStyle.solid), + ), + alignment: Alignment.center, + child: Text('+', style: TextStyle(fontSize: 24, color: InouTheme.accent)), + ), + const SizedBox(height: 4), + Text( + 'Add photo', + style: InouTheme.small.copyWith(fontSize: 10.5, color: InouTheme.accent), + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _NoteTimelineRow extends StatelessWidget { + final String date; + final String text; + + const _NoteTimelineRow({required this.date, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border, style: BorderStyle.solid)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(date, style: InouTheme.small), + const SizedBox(height: 4), + Text(text, style: InouTheme.body.copyWith(fontSize: 13.5)), + ], + ), + ); + } +} + +class _SupplementRow extends StatelessWidget { + final String name; + final String dose; + final String amount; + final String timing; + + const _SupplementRow({ + required this.name, + required this.dose, + required this.amount, + required this.timing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + const SizedBox(height: 2), + Text( + '$dose · $amount', + style: InouTheme.small, + ), + ], + ), + ), + Text(timing, style: InouTheme.small.copyWith(color: InouTheme.textSubtle)), + ], + ), + ); + } +} + +class _PeptideRow extends StatelessWidget { + final String name; + final String dose; + final String endDate; + final String status; + + const _PeptideRow({ + required this.name, + required this.dose, + required this.endDate, + required this.status, + }); + + @override + Widget build(BuildContext context) { + final isActive = status == 'active'; + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: InouTheme.border)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(name, style: InouTheme.body.copyWith(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Text(dose, style: InouTheme.small), + ], + ), + const SizedBox(height: 2), + Text(endDate, style: InouTheme.small.copyWith(color: InouTheme.textSubtle)), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isActive ? InouTheme.successLight : InouTheme.bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + status, + style: InouTheme.small.copyWith( + color: isActive ? InouTheme.success : InouTheme.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/design/flutter/widgets/inou_badge.dart b/design/flutter/widgets/inou_badge.dart new file mode 100644 index 0000000..4c360c7 --- /dev/null +++ b/design/flutter/widgets/inou_badge.dart @@ -0,0 +1,71 @@ +// AUTO-GENERATED widget — matches web .badge +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +enum BadgeVariant { normal, care, comingSoon, processing } + +class InouBadge extends StatelessWidget { + final String text; + final BadgeVariant variant; + + const InouBadge({ + super.key, + required this.text, + this.variant = BadgeVariant.normal, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + final isUppercase = variant == BadgeVariant.comingSoon; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: style.background, + borderRadius: BorderRadius.circular(InouTheme.radiusSm), + ), + child: Text( + isUppercase ? text.toUpperCase() : text, + style: TextStyle( + fontSize: variant == BadgeVariant.comingSoon ? 10 : 13, + fontWeight: FontWeight.w500, + color: style.foreground, + letterSpacing: isUppercase ? 0.5 : 0, + ), + ), + ); + } + + _BadgeStyle _getStyle() { + switch (variant) { + case BadgeVariant.normal: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + case BadgeVariant.care: + return _BadgeStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + ); + case BadgeVariant.comingSoon: + return _BadgeStyle( + background: InouTheme.bg, + foreground: InouTheme.textMuted, + ); + case BadgeVariant.processing: + return _BadgeStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + ); + } + } +} + +class _BadgeStyle { + final Color background; + final Color foreground; + + _BadgeStyle({required this.background, required this.foreground}); +} diff --git a/design/flutter/widgets/inou_button.dart b/design/flutter/widgets/inou_button.dart new file mode 100644 index 0000000..653e622 --- /dev/null +++ b/design/flutter/widgets/inou_button.dart @@ -0,0 +1,128 @@ +// AUTO-GENERATED widget — matches web .btn +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +enum ButtonVariant { primary, secondary, danger } +enum ButtonSize { regular, small } + +class InouButton extends StatelessWidget { + final String text; + final ButtonVariant variant; + final ButtonSize size; + final bool fullWidth; + final VoidCallback? onPressed; + final Widget? icon; + + const InouButton({ + super.key, + required this.text, + this.variant = ButtonVariant.primary, + this.size = ButtonSize.regular, + this.fullWidth = false, + this.onPressed, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final isSmall = size == ButtonSize.small; + final padding = isSmall + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 6) + : const EdgeInsets.symmetric(horizontal: 18, vertical: 10); + + final style = _getStyle(); + + Widget button = TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + backgroundColor: style.background, + foregroundColor: style.foreground, + padding: padding, + shape: RoundedRectangleBorder( + borderRadius: InouTheme.borderRadiusMd, + side: style.border, + ), + textStyle: InouTheme.labelLarge.copyWith( + fontSize: isSmall ? 14 : 15, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + icon!, + const SizedBox(width: 6), + ], + Text(text), + ], + ), + ); + + if (fullWidth) { + button = SizedBox(width: double.infinity, child: button); + } + + return button; + } + + _ButtonStyle _getStyle() { + switch (variant) { + case ButtonVariant.primary: + return _ButtonStyle( + background: InouTheme.accent, + foreground: Colors.white, + border: BorderSide.none, + ); + case ButtonVariant.secondary: + return _ButtonStyle( + background: InouTheme.bgCard, + foreground: InouTheme.text, + border: const BorderSide(color: InouTheme.border), + ); + case ButtonVariant.danger: + return _ButtonStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: BorderSide(color: InouTheme.danger.withOpacity(0.3)), + ); + } + } +} + +class _ButtonStyle { + final Color background; + final Color foreground; + final BorderSide border; + + _ButtonStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} + +/// Icon button (matches .btn-icon) +class InouIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + final Color? color; + + const InouIconButton({ + super.key, + required this.icon, + this.onPressed, + this.color, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon), + onPressed: onPressed, + color: color ?? InouTheme.textSubtle, + iconSize: 20, + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(), + ); + } +} diff --git a/design/flutter/widgets/inou_card.dart b/design/flutter/widgets/inou_card.dart new file mode 100644 index 0000000..69188c3 --- /dev/null +++ b/design/flutter/widgets/inou_card.dart @@ -0,0 +1,312 @@ +// AUTO-GENERATED widget — matches web .data-card +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; +import 'inou_badge.dart'; +import 'inou_button.dart'; + +/// Data card with colored indicator bar +class InouCard extends StatelessWidget { + final String? title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + final Widget? child; + final VoidCallback? onTap; + + const InouCard({ + super.key, + this.title, + this.subtitle, + this.indicatorColor = InouTheme.accent, + this.trailing, + this.child, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) + _Header( + title: title!, + subtitle: subtitle, + indicatorColor: indicatorColor, + trailing: trailing, + ), + if (child != null) child!, + ], + ), + ); + } +} + +class _Header extends StatelessWidget { + final String title; + final String? subtitle; + final Color indicatorColor; + final Widget? trailing; + + const _Header({ + required this.title, + this.subtitle, + required this.indicatorColor, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(InouTheme.spaceLg), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: InouTheme.border), + ), + ), + child: Row( + children: [ + Container( + width: 4, + height: 32, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: InouTheme.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: InouTheme.labelSmall.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: InouTheme.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +/// Simple card without indicator +class InouSimpleCard extends StatelessWidget { + final Widget child; + final EdgeInsets? padding; + final VoidCallback? onTap; + + const InouSimpleCard({ + super.key, + required this.child, + this.padding, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final card = Container( + padding: padding ?? const EdgeInsets.all(InouTheme.spaceLg), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: child, + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: card, + ); + } + return card; + } +} + +/// Profile card for dashboard +class InouProfileCard extends StatelessWidget { + final String name; + final String? role; + final String? dob; + final String? sex; + final List stats; + final bool isCare; + final VoidCallback? onTap; + final VoidCallback? onEdit; + + const InouProfileCard({ + super.key, + required this.name, + this.role, + this.dob, + this.sex, + this.stats = const [], + this.isCare = false, + this.onTap, + this.onEdit, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: InouTheme.bgCard, + borderRadius: InouTheme.borderRadiusLg, + border: Border.all(color: InouTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(name, style: InouTheme.h3), + ), + if (onEdit != null) + GestureDetector( + onTap: onEdit, + child: Text('✎', style: TextStyle(color: InouTheme.textMuted)), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + role ?? 'you', + style: InouTheme.bodySmall.copyWith(color: InouTheme.textSubtle), + ), + if (isCare) ...[ + const SizedBox(width: 8), + const InouBadge(text: 'care', variant: BadgeVariant.care), + ], + ], + ), + if (dob != null) ...[ + const SizedBox(height: 8), + Text( + 'Born: $dob${sex != null ? ' · $sex' : ''}', + style: InouTheme.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (stats.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: stats.map((s) => _StatChip(stat: s)).toList(), + ), + ], + const Spacer(), + InouButton( + text: 'View', + size: ButtonSize.small, + onPressed: onTap, + ), + ], + ), + ), + ); + } +} + +class ProfileStat { + final String emoji; + final String label; + + const ProfileStat(this.emoji, this.label); +} + +class _StatChip extends StatelessWidget { + final ProfileStat stat; + + const _StatChip({required this.stat}); + + @override + Widget build(BuildContext context) { + return Text( + '${stat.emoji} ${stat.label}', + style: InouTheme.bodySmall.copyWith( + color: InouTheme.textMuted, + fontSize: 12, + ), + ); + } +} + +/// Add card (dashed border) +class InouAddCard extends StatelessWidget { + final String label; + final VoidCallback? onTap; + + const InouAddCard({ + super.key, + required this.label, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: InouTheme.borderRadiusLg, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: InouTheme.borderRadiusLg, + border: Border.all( + color: InouTheme.border, + width: 2, + style: BorderStyle.solid, // Note: Flutter doesn't support dashed directly + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '+', + style: TextStyle( + fontSize: 28, + color: InouTheme.accent, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 6), + Text( + label, + style: InouTheme.bodyMedium.copyWith(color: InouTheme.textMuted), + ), + ], + ), + ), + ); + } +} diff --git a/design/flutter/widgets/inou_data_row.dart b/design/flutter/widgets/inou_data_row.dart new file mode 100644 index 0000000..2941553 --- /dev/null +++ b/design/flutter/widgets/inou_data_row.dart @@ -0,0 +1,237 @@ +// AUTO-GENERATED widget — matches web .data-row +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +/// Expandable data row (for imaging, labs, etc.) +class InouDataRow extends StatefulWidget { + final String label; + final String? meta; + final String? date; + final String? value; + final bool isExpandable; + final List? children; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + final bool initiallyExpanded; + final ValueChanged? onExpandChanged; + + const InouDataRow({ + super.key, + required this.label, + this.meta, + this.date, + this.value, + this.isExpandable = false, + this.children, + this.leading, + this.trailing, + this.onTap, + this.initiallyExpanded = false, + this.onExpandChanged, + }); + + @override + State createState() => _InouDataRowState(); +} + +class _InouDataRowState extends State { + late bool _expanded; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InkWell( + onTap: widget.isExpandable + ? () { + setState(() => _expanded = !_expanded); + widget.onExpandChanged?.call(_expanded); + } + : widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + if (widget.isExpandable) + SizedBox( + width: 20, + child: Text( + _expanded ? '−' : '+', + style: TextStyle( + color: InouTheme.textMuted, + fontSize: 14, + fontFamily: 'monospace', + ), + ), + ) + else if (widget.leading == null) + const SizedBox(width: 32), + if (widget.leading != null) ...[ + widget.leading!, + const SizedBox(width: 12), + ], + Expanded( + child: Text( + widget.label, + style: InouTheme.bodyMedium.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (widget.value != null) + Text( + widget.value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: InouTheme.text, + ), + ), + if (widget.meta != null) ...[ + const SizedBox(width: 16), + Text( + widget.meta!, + style: InouTheme.bodySmall.copyWith( + color: InouTheme.textMuted, + ), + ), + ], + if (widget.date != null) ...[ + const SizedBox(width: 16), + Text( + widget.date!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 12, + color: InouTheme.textMuted, + ), + ), + ], + if (widget.trailing != null) ...[ + const SizedBox(width: 8), + widget.trailing!, + ], + ], + ), + ), + ), + if (_expanded && widget.children != null) + Container( + color: InouTheme.bg, + child: Column(children: widget.children!), + ), + ], + ); + } +} + +/// Child row (indented) +class InouChildRow extends StatelessWidget { + final String label; + final String? value; + final String? meta; + final Widget? trailing; + final Color? valueColor; + + const InouChildRow({ + super.key, + required this.label, + this.value, + this.meta, + this.trailing, + this.valueColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: InouTheme.spaceLg, + vertical: InouTheme.spaceMd, + ), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: InouTheme.border, + style: BorderStyle.solid, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 32), // indent + Expanded( + child: Text( + label, + style: InouTheme.bodyMedium, + ), + ), + if (value != null) + Text( + value!, + style: TextStyle( + fontFamily: 'SF Mono', + fontSize: 13, + color: valueColor ?? InouTheme.text, + ), + ), + if (meta != null) ...[ + const SizedBox(width: 16), + Text( + meta!, + style: InouTheme.bodySmall.copyWith(color: InouTheme.textMuted), + ), + ], + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + ); + } +} + +/// Icon for notes/vitals +class InouNoteIcon extends StatelessWidget { + final String emoji; + final Color color; + + const InouNoteIcon({ + super.key, + required this.emoji, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 16)), + ); + } +} diff --git a/design/flutter/widgets/inou_input.dart b/design/flutter/widgets/inou_input.dart new file mode 100644 index 0000000..4a3b313 --- /dev/null +++ b/design/flutter/widgets/inou_input.dart @@ -0,0 +1,205 @@ +// AUTO-GENERATED widget — matches web form elements +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +/// Text input field +class InouTextField extends StatelessWidget { + final String? label; + final String? placeholder; + final TextEditingController? controller; + final bool obscureText; + final TextInputType? keyboardType; + final int? maxLength; + final bool isCode; + final ValueChanged? onChanged; + + const InouTextField({ + super.key, + this.label, + this.placeholder, + this.controller, + this.obscureText = false, + this.keyboardType, + this.maxLength, + this.isCode = false, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: InouTheme.labelLarge, + ), + const SizedBox(height: 4), + ], + TextField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + maxLength: maxLength, + textAlign: isCode ? TextAlign.center : TextAlign.start, + onChanged: onChanged, + style: isCode + ? TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + letterSpacing: 8, + fontFamily: 'SF Mono', + ) + : InouTheme.bodyMedium, + decoration: InputDecoration( + hintText: placeholder, + counterText: '', + ), + ), + ], + ); + } +} + +/// Dropdown select +class InouSelect extends StatelessWidget { + final String? label; + final T? value; + final List> options; + final ValueChanged? onChanged; + + const InouSelect({ + super.key, + this.label, + this.value, + required this.options, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text(label!, style: InouTheme.labelLarge), + const SizedBox(height: 4), + ], + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: InouTheme.bgCard, + border: Border.all(color: InouTheme.border), + borderRadius: InouTheme.borderRadiusMd, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + items: options + .map((o) => DropdownMenuItem( + value: o.value, + child: Text(o.label), + )) + .toList(), + onChanged: onChanged, + ), + ), + ), + ], + ); + } +} + +class InouSelectOption { + final T value; + final String label; + + const InouSelectOption({required this.value, required this.label}); +} + +/// Radio group +class InouRadioGroup extends StatelessWidget { + final T? value; + final List> options; + final ValueChanged? onChanged; + + const InouRadioGroup({ + super.key, + this.value, + required this.options, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: options.map((option) { + return Padding( + padding: const EdgeInsets.only(right: 16), + child: InkWell( + onTap: () => onChanged?.call(option.value), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Radio( + value: option.value, + groupValue: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Text(option.label, style: InouTheme.bodyMedium), + ], + ), + ), + ); + }).toList(), + ); + } +} + +class InouRadioOption { + final T value; + final String label; + + const InouRadioOption({required this.value, required this.label}); +} + +/// Checkbox +class InouCheckbox extends StatelessWidget { + final bool value; + final String label; + final ValueChanged? onChanged; + + const InouCheckbox({ + super.key, + required this.value, + required this.label, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => onChanged?.call(!value), + child: Row( + children: [ + Checkbox( + value: value, + onChanged: onChanged, + activeColor: InouTheme.accent, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Expanded( + child: Text( + label, + style: InouTheme.bodyMedium.copyWith(color: InouTheme.textMuted), + ), + ), + ], + ), + ); + } +} diff --git a/design/flutter/widgets/inou_message.dart b/design/flutter/widgets/inou_message.dart new file mode 100644 index 0000000..f455b5f --- /dev/null +++ b/design/flutter/widgets/inou_message.dart @@ -0,0 +1,69 @@ +// AUTO-GENERATED widget — matches web .error/.info/.success +import 'package:flutter/material.dart'; +import '../inou_theme.dart'; + +enum MessageType { error, info, success } + +class InouMessage extends StatelessWidget { + final String message; + final MessageType type; + + const InouMessage({ + super.key, + required this.message, + this.type = MessageType.info, + }); + + @override + Widget build(BuildContext context) { + final style = _getStyle(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: style.background, + border: Border.all(color: style.border), + borderRadius: BorderRadius.circular(InouTheme.radiusMd), + ), + child: Text( + message, + style: InouTheme.bodyMedium.copyWith(color: style.foreground), + ), + ); + } + + _MessageStyle _getStyle() { + switch (type) { + case MessageType.error: + return _MessageStyle( + background: InouTheme.dangerLight, + foreground: InouTheme.danger, + border: const Color(0xFFFECACA), + ); + case MessageType.info: + return _MessageStyle( + background: InouTheme.accentLight, + foreground: InouTheme.accent, + border: const Color(0xFFFDE68A), + ); + case MessageType.success: + return _MessageStyle( + background: InouTheme.successLight, + foreground: InouTheme.success, + border: const Color(0xFFA7F3D0), + ); + } + } +} + +class _MessageStyle { + final Color background; + final Color foreground; + final Color border; + + _MessageStyle({ + required this.background, + required this.foreground, + required this.border, + }); +} diff --git a/design/flutter/widgets/widgets.dart b/design/flutter/widgets/widgets.dart new file mode 100644 index 0000000..d8729e2 --- /dev/null +++ b/design/flutter/widgets/widgets.dart @@ -0,0 +1,7 @@ +// Barrel file for all inou widgets +export 'inou_card.dart'; +export 'inou_button.dart'; +export 'inou_badge.dart'; +export 'inou_message.dart'; +export 'inou_input.dart'; +export 'inou_data_row.dart'; diff --git a/design/generate.js b/design/generate.js new file mode 100644 index 0000000..02b1ef5 --- /dev/null +++ b/design/generate.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/** + * Design Token Generator + * Single source of truth → CSS variables + Flutter theme + * + * Usage: node generate.js + * Outputs: + * - tokens.css (CSS custom properties) + * - flutter/inou_theme.dart (Flutter ThemeData) + */ + +const fs = require('fs'); +const path = require('path'); + +const tokens = JSON.parse(fs.readFileSync(path.join(__dirname, 'tokens.json'), 'utf8')); + +// ============================================ +// CSS GENERATOR +// ============================================ +function generateCSS(tokens) { + const lines = [ + '/* AUTO-GENERATED from tokens.json — do not edit directly */', + '/* Run: node design/generate.js */', + '', + ':root {' + ]; + + // Colors + for (const [key, value] of Object.entries(tokens.colors)) { + const cssVar = `--${camelToKebab(key)}`; + lines.push(` ${cssVar}: ${value};`); + } + + lines.push(''); + + // Spacing + for (const [key, value] of Object.entries(tokens.spacing.scale)) { + lines.push(` --space-${key}: ${value}px;`); + } + + lines.push(''); + + // Radii + for (const [key, value] of Object.entries(tokens.radii)) { + if (key !== 'full') { + lines.push(` --radius-${key}: ${value}px;`); + } else { + lines.push(` --radius-${key}: ${value}px;`); + } + } + + lines.push(''); + + // Layout + lines.push(` --max-width: ${tokens.layout.maxWidth}px;`); + lines.push(` --max-width-narrow: ${tokens.layout.maxWidthNarrow}px;`); + lines.push(` --max-width-form: ${tokens.layout.maxWidthForm}px;`); + + lines.push('}'); + + return lines.join('\n'); +} + +// ============================================ +// FLUTTER GENERATOR +// ============================================ +function generateFlutter(tokens) { + const lines = [ + '// AUTO-GENERATED from tokens.json — do not edit directly', + '// Run: node design/generate.js', + '', + "import 'package:flutter/material.dart';", + "import 'package:google_fonts/google_fonts.dart';", + '', + '/// inou Design System', + '/// Single source of truth: design/tokens.json', + 'class InouTheme {', + ' InouTheme._();', + '', + ' // ============================================', + ' // COLORS', + ' // ============================================', + ]; + + // Colors as static constants + for (const [key, value] of Object.entries(tokens.colors)) { + const hex = value.replace('#', ''); + const alpha = hex.length === 6 ? 'FF' : ''; + lines.push(` static const Color ${key} = Color(0x${alpha}${hex.toUpperCase()});`); + } + + lines.push(''); + lines.push(' // Indicator colors (data sections)'); + for (const [key, value] of Object.entries(tokens.indicators)) { + const hex = value.replace('#', ''); + lines.push(` static const Color indicator${capitalize(key)} = Color(0xFF${hex.toUpperCase()});`); + } + + // Spacing + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // SPACING'); + lines.push(' // ============================================'); + for (const [key, value] of Object.entries(tokens.spacing.scale)) { + lines.push(` static const double space${capitalize(key)} = ${value}.0;`); + } + + // Radii + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // BORDER RADIUS'); + lines.push(' // ============================================'); + for (const [key, value] of Object.entries(tokens.radii)) { + lines.push(` static const double radius${capitalize(key)} = ${value}.0;`); + } + lines.push(` static BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm);`); + lines.push(` static BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd);`); + lines.push(` static BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg);`); + + // Layout + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // LAYOUT'); + lines.push(' // ============================================'); + lines.push(` static const double maxWidth = ${tokens.layout.maxWidth}.0;`); + lines.push(` static const double maxWidthNarrow = ${tokens.layout.maxWidthNarrow}.0;`); + lines.push(` static const double maxWidthForm = ${tokens.layout.maxWidthForm}.0;`); + + // Typography helpers + lines.push(''); + lines.push(' // ============================================'); + lines.push(' // TYPOGRAPHY'); + lines.push(' // ============================================'); + lines.push(` static String get fontFamily => '${tokens.typography.fontFamily}';`); + lines.push(''); + + // Text styles + const textStyles = { + h1: { size: 36, weight: 300, spacing: -0.5 }, + h1Large: { size: 40, weight: 700 }, + h2: { size: 24, weight: 300, spacing: -0.3 }, + h3: { size: 18, weight: 500 }, + bodyLarge: { size: 16, weight: 400 }, + bodyMedium: { size: 15, weight: 400 }, + bodySmall: { size: 13, weight: 400 }, + labelLarge: { size: 15, weight: 500 }, + labelSmall: { size: 12, weight: 500, spacing: 1.5 }, + }; + + for (const [name, style] of Object.entries(textStyles)) { + const weight = `FontWeight.w${style.weight}`; + const spacing = style.spacing ? `, letterSpacing: ${style.spacing}` : ''; + lines.push(` static TextStyle get ${name} => GoogleFonts.sora(`); + lines.push(` fontSize: ${style.size}.0,`); + lines.push(` fontWeight: ${weight},`); + lines.push(` color: text${spacing},`); + lines.push(` );`); + lines.push(''); + } + + // Color scheme + lines.push(' // ============================================'); + lines.push(' // THEME DATA'); + lines.push(' // ============================================'); + lines.push(' static ThemeData get light => ThemeData('); + lines.push(' useMaterial3: true,'); + lines.push(' brightness: Brightness.light,'); + lines.push(' scaffoldBackgroundColor: bg,'); + lines.push(' colorScheme: ColorScheme.light('); + lines.push(' primary: accent,'); + lines.push(' onPrimary: Colors.white,'); + lines.push(' secondary: accentLight,'); + lines.push(' onSecondary: accent,'); + lines.push(' surface: bgCard,'); + lines.push(' onSurface: text,'); + lines.push(' error: danger,'); + lines.push(' onError: Colors.white,'); + lines.push(' outline: border,'); + lines.push(' ),'); + lines.push(' textTheme: TextTheme('); + lines.push(' displayLarge: h1Large,'); + lines.push(' displayMedium: h1,'); + lines.push(' headlineMedium: h2,'); + lines.push(' headlineSmall: h3,'); + lines.push(' bodyLarge: bodyLarge,'); + lines.push(' bodyMedium: bodyMedium,'); + lines.push(' bodySmall: bodySmall,'); + lines.push(' labelLarge: labelLarge,'); + lines.push(' labelSmall: labelSmall,'); + lines.push(' ),'); + lines.push(' appBarTheme: AppBarTheme('); + lines.push(' backgroundColor: bg,'); + lines.push(' foregroundColor: text,'); + lines.push(' elevation: 0,'); + lines.push(' centerTitle: false,'); + lines.push(' ),'); + lines.push(' cardTheme: CardTheme('); + lines.push(' color: bgCard,'); + lines.push(' elevation: 0,'); + lines.push(' shape: RoundedRectangleBorder('); + lines.push(' borderRadius: borderRadiusLg,'); + lines.push(' side: BorderSide(color: border),'); + lines.push(' ),'); + lines.push(' ),'); + lines.push(' elevatedButtonTheme: ElevatedButtonThemeData('); + lines.push(' style: ElevatedButton.styleFrom('); + lines.push(' backgroundColor: accent,'); + lines.push(' foregroundColor: Colors.white,'); + lines.push(' elevation: 0,'); + lines.push(' padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd),'); + lines.push(' shape: RoundedRectangleBorder(borderRadius: borderRadiusMd),'); + lines.push(' textStyle: labelLarge,'); + lines.push(' ),'); + lines.push(' ),'); + lines.push(' outlinedButtonTheme: OutlinedButtonThemeData('); + lines.push(' style: OutlinedButton.styleFrom('); + lines.push(' foregroundColor: text,'); + lines.push(' side: BorderSide(color: border),'); + lines.push(' padding: EdgeInsets.symmetric(horizontal: spaceLg, vertical: spaceMd),'); + lines.push(' shape: RoundedRectangleBorder(borderRadius: borderRadiusMd),'); + lines.push(' textStyle: labelLarge,'); + lines.push(' ),'); + lines.push(' ),'); + lines.push(' inputDecorationTheme: InputDecorationTheme('); + lines.push(' filled: true,'); + lines.push(' fillColor: bgCard,'); + lines.push(' border: OutlineInputBorder('); + lines.push(' borderRadius: borderRadiusMd,'); + lines.push(' borderSide: BorderSide(color: border),'); + lines.push(' ),'); + lines.push(' enabledBorder: OutlineInputBorder('); + lines.push(' borderRadius: borderRadiusMd,'); + lines.push(' borderSide: BorderSide(color: border),'); + lines.push(' ),'); + lines.push(' focusedBorder: OutlineInputBorder('); + lines.push(' borderRadius: borderRadiusMd,'); + lines.push(' borderSide: BorderSide(color: accent, width: 2),'); + lines.push(' ),'); + lines.push(' contentPadding: EdgeInsets.symmetric(horizontal: spaceMd, vertical: spaceMd),'); + lines.push(' ),'); + lines.push(' dividerTheme: DividerThemeData('); + lines.push(' color: border,'); + lines.push(' thickness: 1,'); + lines.push(' ),'); + lines.push(' );'); + + lines.push('}'); + + return lines.join('\n'); +} + +// ============================================ +// UTILITIES +// ============================================ +function camelToKebab(str) { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +} + +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +// ============================================ +// MAIN +// ============================================ +function main() { + // Generate CSS + const css = generateCSS(tokens); + const cssPath = path.join(__dirname, 'tokens.css'); + fs.writeFileSync(cssPath, css); + console.log(`✓ Generated ${cssPath}`); + + // Generate Flutter + const flutter = generateFlutter(tokens); + const flutterDir = path.join(__dirname, 'flutter'); + if (!fs.existsSync(flutterDir)) { + fs.mkdirSync(flutterDir, { recursive: true }); + } + const flutterPath = path.join(flutterDir, 'inou_theme.dart'); + fs.writeFileSync(flutterPath, flutter); + console.log(`✓ Generated ${flutterPath}`); + + console.log('\nDesign tokens synced. Both CSS and Flutter use the same values.'); +} + +main(); diff --git a/design/tokens.css b/design/tokens.css new file mode 100644 index 0000000..3e7955d --- /dev/null +++ b/design/tokens.css @@ -0,0 +1,37 @@ +/* AUTO-GENERATED from tokens.json — do not edit directly */ +/* Run: node design/generate.js */ + +:root { + --bg: #F8F7F6; + --bg-card: #FFFFFF; + --border: #E5E2DE; + --border-hover: #C4BFB8; + --text: #1C1917; + --text-muted: #78716C; + --text-subtle: #A8A29E; + --accent: #B45309; + --accent-hover: #92400E; + --accent-light: #FEF3C7; + --danger: #DC2626; + --danger-light: #FEF2F2; + --success: #059669; + --success-light: #ECFDF5; + + --space-xs: 4px; + --space-sm: 8px; + --space-md: 12px; + --space-lg: 16px; + --space-xl: 24px; + --space-xxl: 32px; + --space-xxxl: 48px; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-full: 9999px; + + --max-width: 1200px; + --max-width-narrow: 800px; + --max-width-form: 360px; +} \ No newline at end of file diff --git a/design/tokens.json b/design/tokens.json new file mode 100644 index 0000000..c3e2c96 --- /dev/null +++ b/design/tokens.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://inou.com/design-tokens.schema.json", + "name": "inou", + "version": "1.0.0", + + "colors": { + "bg": "#F8F7F6", + "bgCard": "#FFFFFF", + "border": "#E5E2DE", + "borderHover": "#C4BFB8", + "text": "#1C1917", + "textMuted": "#78716C", + "textSubtle": "#A8A29E", + "accent": "#B45309", + "accentHover": "#92400E", + "accentLight": "#FEF3C7", + "danger": "#DC2626", + "dangerLight": "#FEF2F2", + "success": "#059669", + "successLight": "#ECFDF5" + }, + + "typography": { + "fontFamily": "Sora", + "fontFamilyMono": "SF Mono, Monaco, Consolas, monospace", + "fontFamilyFallback": "-apple-system, BlinkMacSystemFont, sans-serif", + "baseFontSize": 15, + "lineHeight": 1.5, + "weights": { + "light": 300, + "regular": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + }, + "scale": { + "h1": { "size": "2.25rem", "weight": 300, "letterSpacing": "-0.03em" }, + "h1Large": { "size": "2.5rem", "weight": 700 }, + "h2": { "size": "1.5rem", "weight": 300, "letterSpacing": "-0.02em" }, + "h3": { "size": "1.125rem", "weight": 500 }, + "body": { "size": "1rem", "weight": 400 }, + "small": { "size": "0.85rem", "weight": 400 }, + "tiny": { "size": "0.75rem", "weight": 500, "letterSpacing": "0.1em", "transform": "uppercase" }, + "code": { "size": "0.85rem", "family": "mono" } + } + }, + + "spacing": { + "unit": 4, + "scale": { + "xs": 4, + "sm": 8, + "md": 12, + "lg": 16, + "xl": 24, + "xxl": 32, + "xxxl": 48 + } + }, + + "radii": { + "sm": 4, + "md": 6, + "lg": 8, + "xl": 12, + "full": 9999 + }, + + "shadows": { + "dropdown": "0 4px 12px rgba(0,0,0,0.1)", + "modal": "0 20px 25px -5px rgba(0,0,0,0.15)", + "card": "0 0 0 1px var(--accent)" + }, + + "layout": { + "maxWidth": 1200, + "maxWidthNarrow": 800, + "maxWidthForm": 360, + "navPadding": { "y": 12, "x": 24 }, + "containerPadding": { "y": 48, "x": 24 } + }, + + "components": { + "button": { + "padding": { "y": 10, "x": 18 }, + "paddingSmall": { "y": 6, "x": 12 }, + "fontSize": "1rem", + "fontWeight": 500, + "borderRadius": "md" + }, + "card": { + "padding": 16, + "borderRadius": "lg", + "borderWidth": 1 + }, + "input": { + "padding": { "y": 10, "x": 12 }, + "fontSize": "1rem", + "borderRadius": "md" + }, + "badge": { + "padding": { "y": 2, "x": 8 }, + "fontSize": "1rem", + "fontWeight": 500, + "borderRadius": "sm" + } + }, + + "indicators": { + "imaging": "#B45309", + "labs": "#059669", + "uploads": "#6366f1", + "vitals": "#ec4899", + "medications": "#8b5cf6", + "records": "#06b6d4", + "journal": "#f59e0b", + "privacy": "#64748b", + "genetics": "#10b981" + } +} diff --git a/docs/._.DS_Store b/docs/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/docs/._.DS_Store differ diff --git a/docs/INTAKE_SYSTEM_SPEC.md b/docs/INTAKE_SYSTEM_SPEC.md new file mode 100644 index 0000000..2682f8a --- /dev/null +++ b/docs/INTAKE_SYSTEM_SPEC.md @@ -0,0 +1,1261 @@ +# Inou Intake System — Design Specification + +## Vision + +Transform how non-technical users capture health data. No forms. No configuration. Just conversation that crystallizes into structure. + +The user talks, the system learns. Yesterday's input becomes today's smart prompt with previous values pre-filled. + +--- + +## Core Principles + +| Principle | Meaning | +|-----------|---------| +| **Intake AI, not advice AI** | We capture, structure, and ask smart follow-ups. We never diagnose or recommend treatment. | +| **Complexity on server, simplicity on client** | All intelligence lives server-side. Apps are dumb renderers. | +| **Templates emerge, not configured** | User describes leg trainer once → system learns the shape → next time shows fields with previous values. | +| **Conversation, not forms** | Onboarding isn't a moment, it's a relationship. Learn one thing at a time, naturally. | +| **Show before save** | Always confirm what was understood. Build trust. | + +--- + +## Phase 1: Server + Ugly UI + +### Goal +Fully functional intake system with web-based interface. Prove the model works before investing in pretty UI or mobile. + +### Deliverables + +#### 1. Data Model Extensions + +**Prompt table:** +```sql +prompts ( + prompt_id INTEGER PRIMARY KEY, + dossier_id INTEGER NOT NULL, + category TEXT, -- "medication", "vital", "exercise", "symptom" + type TEXT, -- "Lisinopril", "BP", "leg_trainer" + question TEXT, -- "Did you take your morning meds?" + + -- Scheduling + frequency TEXT, -- "daily", "weekly:mon,wed,fri", "once", "until_resolved" + time_of_day TEXT, -- "08:00", "12:00", "21:00", NULL for anytime + next_ask INTEGER, -- epoch: next time to show + expires_at INTEGER, -- epoch: stop asking after this (0 = never) + + -- Input definition + input_type TEXT, -- "checkbox", "checkbox_group", "number", "text", "scale", "custom" + input_config TEXT, -- JSON: field definitions, scale labels + + -- Grouping + group_name TEXT, -- "morning_meds", "gym_session" — for collapsing + + -- Source/linking + trigger_entry INTEGER, -- entry that spawned this (for follow-ups) + created_by INTEGER, -- dossier_id of who created (self, caregiver) + + -- Last response (for pre-filling) + last_response TEXT, -- JSON: parsed/structured answer + last_response_raw TEXT, -- what they actually said/typed + last_response_at INTEGER, -- epoch + + -- State + dismissed INTEGER DEFAULT 0, -- 1 = "don't ask again" + active INTEGER DEFAULT 1, -- 1 = active, 0 = paused + + created_at INTEGER, + updated_at INTEGER +) + +CREATE INDEX idx_prompts_dossier ON prompts(dossier_id); +CREATE INDEX idx_prompts_dossier_active ON prompts(dossier_id, active, next_ask); +``` + +### input_config Field Schema + +The `input_config` column stores a JSON object defining form fields. The frontend renders these directly — no AI interpretation needed. + +**Structure:** +```json +{ + "fields": [ + { + "key": "duration_min", + "label": "Duration", + "type": "number", + "datatype": "int", + "min": 1, + "max": 120, + "unit": "min", + "required": true + }, + { + "key": "intensity", + "label": "How hard was it?", + "type": "select", + "datatype": "string", + "options": ["easy", "moderate", "hard"], + "required": true + }, + { + "key": "exercises", + "label": "Exercises", + "type": "multiselect", + "datatype": "string[]", + "options": ["bench", "squat", "deadlift", "treadmill"], + "required": false + }, + { + "key": "notes", + "label": "Notes", + "type": "text", + "datatype": "string", + "maxlength": 500, + "required": false + } + ] +} +``` + +**Field types (what to render):** + +| type | datatype | renders as | +|------|----------|------------| +| `text` | string | text input | +| `textarea` | string | multiline | +| `number` | int / float | number input with +/- | +| `checkbox` | bool | single checkbox | +| `select` | string | dropdown | +| `multiselect` | string[] | checkboxes or multi-picker | +| `scale` | int | 1-5 or 1-10 buttons | +| `time` | string | time picker (HH:MM) | +| `date` | string | date picker | + +**Field properties:** + +| property | applies to | meaning | +|----------|-----------|--------| +| `key` | all | JSON key in response | +| `label` | all | display label | +| `type` | all | widget type | +| `datatype` | all | validation type | +| `required` | all | must fill | +| `default` | all | pre-fill value (overridden by last_response) | +| `min` | number | minimum value | +| `max` | number | maximum value | +| `unit` | number | suffix to display ("°", "min", "lbs") | +| `step` | number | increment (0.1 for float) | +| `maxlength` | text | character limit | +| `options` | select, multiselect | choices | +| `scale_labels` | scale | e.g. ["terrible", "poor", "ok", "good", "great"] | + +**Response handling:** + +When rendering a prompt, the frontend: +1. Reads `input_config.fields` +2. For each field, checks `last_response[key]` +3. Pre-fills if exists +4. User submits → stored as new `last_response` + +**Entry table additions:** +```sql +-- Existing entry table, ensure these fields: +entries ( + ... + parent_id INTEGER, -- for nested entries (gym session → exercises) + data TEXT, -- JSON blob for structured details + raw input + ... +) +``` + +**Dossier table additions (now live):** +```sql +-- Added to dossiers table: + weight_unit TEXT, -- "kg" or "lbs" + height_unit TEXT, -- "cm" or "ft" + last_pull_at INTEGER, -- epoch: last time app fetched prompts + is_provider INTEGER DEFAULT 0, -- 1 if healthcare provider + provider_name TEXT, -- practice/clinic name + away_message TEXT, -- auto-responder text + away_enabled INTEGER DEFAULT 0 -- 1 if auto-responder active +-- (language already exists on dossier) +``` + +#### 2. API Endpoints + +**GET /api/prompts?dossier=X** + +Returns prompts due now for this dossier. + +Response: +```json +{ + "prompts": [ + { + "id": "abc123", + "category": "medication", + "type": "morning_meds", + "question": "Did you take your morning medications?", + "input_type": "checkbox_group", + "input_config": { + "items": [ + { "name": "Lisinopril", "amount": "10mg" }, + { "name": "Metoprolol", "amount": "25mg" } + ] + }, + "group_name": "morning_meds", + "group_count": 2, + "actions": ["took_all", "missed_some", "skip"] + }, + { + "id": "def456", + "category": "exercise", + "type": "leg_trainer", + "question": "Leg trainer (morning)", + "input_type": "custom", + "input_config": { + "fields": [ + { "name": "left_min", "label": "Left min", "type": "number", "previous": 20 }, + { "name": "left_angle", "label": "Left °", "type": "number", "previous": 30 }, + { "name": "left_speed", "label": "Left speed", "type": "number", "previous": 4 }, + { "name": "right_min", "label": "Right min", "type": "number", "previous": 20 }, + { "name": "right_angle", "label": "Right °", "type": "number", "previous": 42 }, + { "name": "right_speed", "label": "Right speed", "type": "number", "previous": 5 } + ], + "notes_field": true + }, + "actions": ["save", "skip"] + }, + { + "id": "ghi789", + "category": "followup", + "type": "symptom_check", + "question": "Yesterday you had a headache. How are you today?", + "input_type": "scale", + "input_config": { + "options": ["Much better", "Same", "Worse"] + }, + "actions": ["select", "skip"] + }, + { + "id": "open", + "category": "general", + "type": "anything_else", + "question": "Anything else you'd like to mention?", + "input_type": "freeform", + "input_config": { + "placeholder": "Voice, text, or photo...", + "allow_photo": true, + "allow_voice": true + }, + "actions": ["save", "nothing"] + } + ], + "for_dossiers": [ + { "id": "xxx", "name": "Johan", "relation": "self" }, + { "id": "yyy", "name": "Sophia", "relation": "daughter" }, + { "id": "zzz", "name": "Mama", "relation": "mother-in-law" } + ] +} +``` + +**POST /api/prompts/respond** + +Submit response to a prompt. + +Request: +```json +{ + "prompt_id": "abc123", + "dossier_id": "xxx", + "action": "took_all", + "response": null +} +``` + +Or for custom input: +```json +{ + "prompt_id": "def456", + "dossier_id": "yyy", + "action": "save", + "response": { + "left_min": 15, + "left_angle": 25, + "left_speed": 3, + "right_min": 15, + "right_angle": 35, + "right_speed": 4, + "notes": "She was tired today" + } +} +``` + +Returns: +```json +{ + "success": true, + "entries_created": ["entry_id_1", "entry_id_2"], + "prompt_updated": true +} +``` + +**POST /api/intake/parse** + +Parse freeform input (text, voice transcript, photo). + +Request: +```json +{ + "dossier_id": "xxx", + "input_type": "text", + "content": "Sophia did leg trainer 20 min, left 30 degrees speed 4, right 40 degrees speed 5", + "attachments": [] +} +``` + +Or with photo: +```json +{ + "dossier_id": "xxx", + "input_type": "photo", + "content": null, + "attachments": [ + { "type": "image", "data": "base64..." } + ], + "context": "supplement_bottle" +} +``` + +Returns parsed interpretation for confirmation: +```json +{ + "interpretation": { + "target_dossier": { "id": "yyy", "name": "Sophia", "confidence": 0.95 }, + "entries": [ + { + "category": "exercise", + "type": "leg_trainer", + "display": "Leg trainer — 20 min", + "data": { + "duration_min": 20, + "left": { "angle": 30, "speed": 4 }, + "right": { "angle": 40, "speed": 5 } + } + } + ], + "follow_up_question": null, + "suggested_prompt": { + "question": "You log leg trainer regularly. Want me to remind you?", + "options": ["Yes, daily", "Yes, twice daily", "No thanks"] + } + }, + "raw": "Sophia did leg trainer 20 min, left 30 degrees speed 4, right 40 degrees speed 5" +} +``` + +**POST /api/intake/confirm** + +Confirm parsed interpretation, create entries. + +Request: +```json +{ + "interpretation_id": "temp_xxx", + "confirmed_dossier": "yyy", + "entries": [...], + "create_prompt": { + "answer": "Yes, twice daily" + } +} +``` + +**POST /api/intake/ask** + +Handle intake AI asking follow-up questions. + +Request: +```json +{ + "dossier_id": "xxx", + "context": "unit_clarification", + "question_type": "weight_unit", + "user_input": "80" +} +``` + +Response: +```json +{ + "question": "Is that kg or lbs?", + "options": ["kg", "lbs"], + "store_preference": true +} +``` + +**GET /api/summary?dossier=X&period=30d** + +Generate summary for doctor visit. + +Response: +```json +{ + "dossier": { "name": "Sophia", "dob": "...", "sex": "female" }, + "period": "last 30 days", + "sections": { + "medications": [], + "therapy": [ + { + "name": "Leg trainer", + "frequency": "2x daily", + "compliance": "12/14 sessions", + "typical": "L 30° speed 4, R 40° speed 5", + "trend": "Right side improving (was 35° in December)" + } + ], + "symptoms": [ + { "date": "Dec 28", "type": "headache", "resolution": "resolved after 2 days" } + ], + "vitals": { + "weight": { "start": "32kg", "end": "32.5kg" } + }, + "imaging": [...], + "labs": [...], + "notes": [ + "Tired after school most days", + "Slept poorly Tuesday, better Wednesday" + ] + }, + "markdown": "...", + "shareable_link": "https://inou.com/s/xxxxx" +} +``` + +#### 3. Intake AI Logic (Server-side) + +**Parsing engine:** +- Receives text/voice transcript/photo +- Identifies target dossier from context ("Sophia", "my son", "I") +- Extracts structured data +- Detects patterns that could become prompts +- Generates follow-up questions when needed + +**Prompt generation:** +- From explicit statements: "I take vitamin D every morning" → daily prompt +- From repeated behavior: 5 similar entries → suggest prompt +- From concerning entries: "running stool" → follow-up tomorrow + +**Template learning:** +- First entry with structure → store shape in prompt's input_config +- Next time → pre-fill with previous values +- Evolves as user input evolves + +**Multi-dossier routing:** +- Parse input for person references +- Match against accessible dossiers +- Require confirmation if ambiguous +- Default to last-used dossier if truly unclear + +#### 4. Onboarding Flows + +Not a wizard. A series of prompts that appear naturally. + +**Initial (account creation):** +- Name, DOB, sex — required +- That's it. Done. + +**Day 1-7 (prompted when they return):** +- "What brings you to inou?" — capture chief concern +- "Do you take any daily medications?" — yes → capture list +- "Any supplements?" — yes → capture list +- "Anything you're tracking? Weight, blood pressure, exercise?" + +**Week 2+ (contextual):** +- After first lab upload: "Any conditions we should know about?" +- After logging same thing 5x: "Want me to remind you?" +- After 2 weeks: "It helps to know family health history. Mind sharing?" + +**Family history flow:** +- "Is your mother still living?" → [Yes] [No] +- If no: "How old? What happened?" → captures cause +- If yes: "Any health conditions?" → open field +- Same for father, siblings +- "Anything that runs in the family?" → open field + +#### 5. Ugly Web UI + +Minimal but functional. Prove the model. + +**Dashboard:** +- List of prompts due today +- Grouped where applicable ("Morning meds (8)") +- Each renders based on input_type +- "Anything else" always at bottom + +**Prompt rendering:** +- checkbox_group: checkboxes + "Took all" button +- number: input field with unit +- scale: buttons with labels +- custom: dynamic fields from input_config +- freeform: text area + photo upload + mic button (browser speech-to-text) + +**Confirmation modal:** +- Shows parsed interpretation +- Allows dossier change if wrong +- Edit button for corrections +- Save / Cancel + +**History view:** +- Today's entries +- Past 7 days +- Searchable +- Editable (tap to modify) + +**Summary view:** +- Generate summary for selected period +- Copy as text +- Print +- Share link + +--- + +## Phase 2: Pretty UI + Mobile + +### Goal +Native mobile experience with Flutter. Beautiful UI. Local capabilities (speech-to-text, barcode, camera). + +### Deliverables + +#### 1. Flutter App + +**Architecture:** +- Calls same API endpoints +- Local speech-to-text (send transcript to server) +- Local barcode scanning (send code to server for lookup) +- Local OCR first pass (send text + image if low confidence) +- Local notifications scheduled from prompt data + +**Pull model:** +- On app open: GET /api/prompts +- Background refresh: once daily +- Store prompts locally for offline +- Queue responses if offline, sync when connected + +**Push fallback:** +- Server tracks last_pull_at per dossier +- If no pull in 3+ days AND prompts due → push notification +- Push just says "Don't forget to log" — opens app → pull → render + +**Screens:** + +*Home (Prompts):* +- Cards for each due prompt +- Swipe to skip +- Tap to expand/respond +- Grouped cards for multi-item (morning meds) +- Always: "Anything else?" at bottom + +*Input:* +- Big mic button for voice +- Camera for photos (bottle labels, wounds, readings) +- Text field +- Confirmation before save + +*Log (History):* +- Timeline view +- Filter by category, dossier +- Tap to edit + +*Summary:* +- Pick dossier, pick period +- Generate and display +- Share / Export + +*Dossiers:* +- Switch between accessible dossiers +- Default for logging (remembers last used) + +#### 2. Notification System + +**Local notifications:** +- App schedules based on prompt times +- "Morning meds" at 8:00 +- "Leg trainer (evening)" at 18:00 +- Tapping opens app directly to that prompt + +**Server push:** +- Firebase Cloud Messaging +- Only when app hasn't pulled in days +- Generic "Check in with inou" + +#### 3. Offline Support + +**Cached data:** +- Prompts (with input_config, previous values) +- Recent entries +- Dossier list + +**Queued responses:** +- If offline, store locally +- Sync on reconnect +- Show "pending" indicator + +#### 4. Voice Flow + +**User taps mic:** +1. Device speech-to-text (on-device) +2. Transcript sent to server: POST /api/intake/parse +3. Server parses, returns interpretation +4. App shows confirmation +5. User confirms → entry created + +**Natural language examples:** +- "Sophia did leg trainer, 20 minutes, left 30 degrees speed 4, right 40 degrees speed 5" +- "I took 2 Tylenol for a headache" +- "Mom took her pills" +- "Blood pressure 120 over 80" +- "Walked for an hour this morning" + +#### 5. Photo Flow + +**Supplement bottle:** +1. User taps camera on "Any supplements?" prompt +2. Takes photo of bottle +3. Device OCR extracts text (or barcode scan) +4. Server: looks up product, extracts dose +5. Returns: "Vitamin D3 5000 IU — when do you take this?" +6. User selects [Morning] [Evening] etc. +7. Prompt created + +**Vitals (BP monitor, scale):** +1. Photo of display +2. OCR extracts numbers +3. Confirm values +4. Entry created + +**Injury/symptom:** +1. Photo of knee scrape +2. User adds context: "My son fell" +3. Entry created with photo attached + +#### 6. Pretty UI Design + +Follow existing inou aesthetic: +- Warm, not clinical +- IBM Plex Sans +- Desert palette: warm beige (#F5EDE4), dark brown (#4A3728), terracotta (#C4704B) + +Cards with generous padding. Large tap targets. Clear typography. + +Accessible: works for someone with shaky hands or poor vision. + +--- + +## Appendix A: Example Prompt Types + +| Type | Input | Previous values? | Follow-up? | +|------|-------|------------------|------------| +| Medication (single) | checkbox | N/A | If missed 3+ days | +| Medications (group) | checkbox_group + "took all" | N/A | If missed | +| Vital (number) | number field + unit | Optional | If concerning | +| Supplement | checkbox | N/A | No | +| Exercise (simple) | number (duration) | Yes | If goal missed | +| Exercise (complex) | custom fields | Yes, all fields | No | +| Symptom follow-up | scale | N/A | If "worse" | +| Freeform | text + voice + photo | N/A | Based on content | + +## Appendix B: Entry Structure Examples + +**Simple medication:** +```json +{ + "category": "medication", + "type": "Lisinopril", + "value": "taken", + "data": { "dose": "10mg" }, + "timestamp": 1704369600 +} +``` + +**Leg trainer session:** +```json +{ + "category": "exercise", + "type": "leg_trainer", + "value": "20 min", + "data": { + "duration_min": 20, + "left": { "angle": 30, "speed": 4 }, + "right": { "angle": 40, "speed": 5 }, + "notes": "She was tired today", + "raw": "Sophia did leg trainer, 20 min..." + }, + "timestamp": 1704369600 +} +``` + +**Gym session with nested entries:** +```json +// Parent +{ + "id": "gym_001", + "category": "exercise", + "type": "gym_session", + "value": null, + "timestamp": 1704369600 +} + +// Children +{ + "parent_id": "gym_001", + "category": "exercise", + "type": "bench_press", + "value": "200", + "data": { "unit": "lbs" } +} +{ + "parent_id": "gym_001", + "category": "exercise", + "type": "squats", + "value": "150", + "data": { "unit": "lbs" } +} +``` + +**Supplement from photo:** +```json +{ + "category": "supplement", + "type": "Vitamin D3", + "value": "1", + "data": { + "dose": "5000 IU", + "brand": "Nature's Way", + "source": "photo_ocr", + "photo_id": "attach_xxx" + }, + "timestamp": 1704369600 +} +``` + +**Family history:** +```json +{ + "category": "family_history", + "type": "mother", + "value": "deceased", + "data": { + "age_at_death": 78, + "cause": "heart attack", + "conditions": ["diabetes", "hypertension"], + "raw": "She had diabetes and high blood pressure, died of a heart attack at 78" + } +} +``` + +## Appendix C: Intake AI Prompt Guidelines + +**Role:** +You are an intake assistant for a health tracking system. Your job is to: +1. Parse natural language into structured health data +2. Ask clarifying questions when needed +3. Identify which person (dossier) an entry is about +4. Never give medical advice or interpret results + +**Parsing rules:** +- Extract: category, type, value, relevant details +- Preserve raw input in data.raw +- Resolve aliases (D3 → Vitamin D3, BP → blood pressure) +- Detect time references ("yesterday", "this morning") +- Detect person references ("my son", "Sophia", "mom", "I") + +**Multi-dossier:** +- If person mentioned matches an accessible dossier → route there +- If ambiguous ("blood pressure 120/80") → ask who +- If clearly about self ("I walked") → current user's dossier + +**Follow-up questions:** +- Unknown unit: "Is that kg or lbs?" +- New supplement: "When do you take this?" +- Symptom logged: create follow-up prompt for tomorrow +- Repeated activity: "Want me to remind you about this?" + +**What NOT to do:** +- Don't interpret if values are good/bad +- Don't recommend treatments +- Don't diagnose +- Don't say "you should see a doctor" (unless asked about resources) + +--- + +## Appendix D: Implementation Order + +### Phase 1 — Server + Ugly UI + +1. **Database:** Add prompts table, extend entries +2. **API:** /api/prompts, /api/prompts/respond +3. **Web UI:** Render prompts, handle responses +4. **Intake parsing:** POST /api/intake/parse (start simple, text only) +5. **Confirmation flow:** Show interpretation, confirm, save +6. **Prompt generation:** From explicit statements ("I take X daily") +7. **Follow-up logic:** Symptoms trigger next-day prompts +8. **Template learning:** Remember structure from first entry +9. **Previous values:** Pre-fill from last entry of same type +10. **Grouping:** Collapse multiple meds into "took all" +11. **Multi-dossier:** Parse who, confirm, route +12. **Onboarding prompts:** Medications, supplements, goals +13. **Summary generation:** /api/summary +14. **Family history flow:** Guided questions + +### Phase 2 — Pretty UI + Mobile + +1. **Flutter scaffold:** Navigation, screens +2. **API integration:** Same endpoints +3. **Prompt rendering:** All input types +4. **Voice input:** On-device STT → server parse +5. **Photo input:** Camera, OCR, send to server +6. **Barcode:** On-device scan → server lookup +7. **Local notifications:** Schedule from prompt times +8. **Offline support:** Cache prompts, queue responses +9. **Push fallback:** Firebase integration +10. **Polish:** Design system, animations, accessibility + +--- + +*Document version: 1.0* +*Last updated: January 5, 2025* + + +--- + +## Phase 1b: Provider Chat + +### Goal +Secure messaging between patients/caregivers and healthcare providers. Replace Spruce for basic communication. FIPS 140-3 encrypted. + +### Why Providers Switch + +| Spruce | Inou | +|--------|------| +| $24-49/user/month | Free | +| Just messages | Messages + patient's complete dossier | +| HIPAA | HIPAA + FIPS 140-3 | + +### Data Model (now live) + +**Messages table:** +```sql +messages ( + message_id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL, + dossier_id INTEGER NOT NULL, -- which patient dossier this relates to + sender_id INTEGER NOT NULL, + recipient_id INTEGER NOT NULL, + + body TEXT, -- encrypted + attachments TEXT, -- encrypted JSON + + sent_at INTEGER NOT NULL, + read_at INTEGER, + + auto_reply INTEGER DEFAULT 0 +) + +CREATE INDEX idx_messages_conversation ON messages(conversation_id, sent_at); +CREATE INDEX idx_messages_recipient_unread ON messages(recipient_id, read_at); +``` + +**Conversations table:** +```sql +conversations ( + conversation_id INTEGER PRIMARY KEY, + dossier_id INTEGER NOT NULL, -- the patient dossier + provider_id INTEGER NOT NULL, -- the provider's dossier + + last_message_at INTEGER, + unread_count INTEGER DEFAULT 0, + + created_at INTEGER +) + +CREATE INDEX idx_conversations_dossier ON conversations(dossier_id); +CREATE INDEX idx_conversations_provider ON conversations(provider_id); +``` + +**Provider fields (added to dossiers table):** +```sql +-- See Phase 1 dossier additions for: +-- is_provider, provider_name, away_message, away_enabled +``` + +### API Endpoints + +**GET /api/conversations?dossier=X** + +List all conversations for a dossier (as patient or provider). + +```json +{ + "conversations": [ + { + "id": "conv_123", + "patient": { "id": "xxx", "name": "Sophia" }, + "provider": { "id": "yyy", "name": "Dr. Patel", "practice": "Kids First Pediatrics" }, + "last_message": { + "body": "See you at the appointment tomorrow", + "sent_at": 1704369600, + "sender": "provider" + }, + "unread_count": 0 + } + ] +} +``` + +**GET /api/messages?conversation=X&limit=50&before=timestamp** + +Get messages in a conversation (paginated). + +```json +{ + "messages": [ + { + "id": "msg_456", + "sender": { "id": "xxx", "name": "Johan", "role": "caregiver" }, + "body": "Sophia's leg trainer went well today. See attached notes.", + "attachments": [ + { "type": "entry", "id": "entry_789", "name": "Leg trainer session" } + ], + "sent_at": 1704369600, + "read_at": 1704369700 + } + ] +} +``` + +**POST /api/messages** + +Send a message. + +```json +{ + "conversation_id": "conv_123", + "body": "Quick question about her medication dosage", + "attachments": [] +} +``` + +Response: +```json +{ + "id": "msg_789", + "sent_at": 1704369600, + "auto_reply": { + "id": "msg_790", + "body": "Thank you for your message. I'm currently unavailable but will respond within 24 hours. For urgent matters, please call 555-1234.", + "sent_at": 1704369601 + } +} +``` + +**POST /api/messages/:id/read** + +Mark message as read. + +**PUT /api/provider/settings** + +Update provider settings (away message, etc). + +```json +{ + "away_enabled": true, + "away_message": "Thank you for your message. I typically respond within 24 hours. For urgent matters, call 555-1234." +} +``` + +### Features + +**Core:** +- Text messages (encrypted at rest, FIPS) +- Attach entries from dossier (labs, imaging, notes) +- Attach photos +- Read receipts +- Auto-responder when provider unavailable + +**Notifications:** +- Push notification on new message +- Email notification if app not opened in 24h + +**Provider View:** +- List of patient conversations +- Unread count badge +- Click patient → see conversation + dossier summary +- Quick access to recent entries, labs, imaging + +### Provider Onboarding + +**Invited by patient:** +1. Patient sends invite to provider email +2. Provider receives: "Johan invited you to view Sophia's health dossier" +3. Provider signs up (free): name, email, practice name +4. Provider linked to patient's dossier with role = medical +5. Provider can message, view dossier + +**Provider invites patients:** +1. Provider creates account (free) +2. Provider uploads patient list (email CSV) or invites one by one +3. Patient receives: "Dr. Patel invited you to connect on inou" +4. Patient signs up → linked to provider +5. Patient can message, optionally upgrade to full tracking + +### Patient Tiers + +| Tier | Cost | Features | +|------|------|----------| +| Passive | Free | Receive messages, basic profile, view shared docs | +| Active | Paid | Full dossier: intake AI, tracking, prompts, summaries, imaging, labs, genome | + +Provider brings patients in free. Some upgrade. That's the revenue. + +### Auto-Responder Logic + +```go +func sendMessage(msg Message) { + save(msg) + notify(recipient) + + if recipient.is_provider && recipient.away_enabled { + autoReply := Message{ + sender: recipient, + recipient: msg.sender, + body: recipient.away_message, + auto_reply: true, + } + save(autoReply) + notify(msg.sender) + } +} +``` + +### Security + +- All messages encrypted with FIPS 140-3 (AES-256-GCM) +- TLS 1.3 in transit +- Messages only visible to conversation participants +- Providers can only see dossiers they're invited to +- Audit log for all access + +### Implementation Order + +1. **Database:** messages, conversations tables; provider fields on dossier +2. **API:** conversations, messages, send, read +3. **Web UI:** conversation list, message thread, compose +4. **Auto-responder:** provider settings, auto-reply logic +5. **Notifications:** push (Firebase), email fallback +6. **Provider invite flow:** patient invites provider +7. **Bulk invite:** provider imports patient list +8. **Provider dashboard:** patient list, unread counts, dossier access + +--- + +*Document version: 1.1* +*Last updated: January 5, 2025* + + +--- + +## Appendix E: Entry Categories + +Complete list of entry categories supported by the system. + +### Core (Daily Logging) + +| Category | Description | Example Types | +|----------|-------------|---------------| +| `medication` | Medications taken or prescribed | Lisinopril, Metoprolol, anti-seizure | +| `vital` | Vital signs and measurements | BP, weight, temperature, SpO2 | +| `exercise` | Physical activity and therapy sessions | leg_trainer, walking, gym_session | +| `symptom` | Symptoms and complaints | headache, seizure, fatigue | +| `supplement` | Supplements and vitamins | Vitamin D3, magnesium, iron | +| `note` | Free-form notes | daily observations | + +### Events + +| Category | Description | Example Types | Nesting | +|----------|-------------|---------------|--------| +| `surgery` | Surgical procedures | shunt_placement, shunt_revision, ETV | Parent with child `procedure` entries | +| `hospitalization` | Hospital stays | NICU, inpatient, ER | Parent with child entries for daily events | +| `consultation` | Doctor visits and consults | video_call, office_visit, second_opinion | Parent with child `finding` or `recommendation` entries | + +### Conditions + +| Category | Description | Example Types | Notes | +|----------|-------------|---------------|-------| +| `diagnosis` | Medical diagnoses | IVH, hydrocephalus, epilepsy, ROP | Can have status: active, resolved, suspected | +| `device` | Implanted or external devices | VP_shunt, Ommaya_reservoir, cochlear_implant | Track settings changes over time as new entries | + +### Development + +| Category | Description | Example Types | Notes | +|----------|-------------|---------------|-------| +| `therapy` | Therapeutic interventions | Vojta, Bobath, Feldenkrais, ABM, speech_therapy | Include outcome: effective, ineffective, ongoing | +| `assessment` | Developmental evaluations | motor, speech, cognitive, vision | Qualitative findings, milestones | + +### Foundational + +| Category | Description | Example Types | Notes | +|----------|-------------|---------------|-------| +| `birth` | Birth and prenatal history | delivery, Apgar, gestational_age, complications | One-time entries, foundational context | +| `history` | Past medical events without full documentation | surgery, illness, injury, allergy | Brief recollections: "appendectomy at age 12" | +| `family_history` | Family medical history | mother, father, sibling, maternal_grandmother | Include condition, age at diagnosis, outcome | + +### Findings (Interpretations) + +| Category | Description | Example Types | Notes | +|----------|-------------|---------------|-------| +| `imaging_finding` | Radiologist or AI interpretation | MRI_finding, CT_finding, XR_finding | Links to DICOM study via data.study_guid | +| `eeg_finding` | EEG report findings | epileptiform_activity, sleep_pattern | From EEG monitoring reports | + +### Meta + +| Category | Description | Example Types | Notes | +|----------|-------------|---------------|-------| +| `provider` | Healthcare providers | neurosurgeon, neurologist, therapist | Institution, contact, relationship period | +| `question` | Open medical questions | surgical, diagnostic, treatment | Answer via child entry when resolved | +| `upload` | Uploaded files | imaging, genetics, document, lab_report | Existing category | + +### Nesting Examples + +**Complex surgery with sub-procedures:** +``` +Parent: + category: surgery + type: shunt_revision + value: "Complex Shunt Revision" + timestamp: 2021-12-10 + data: { + location: "University Clinic Ulm", + surgeon: "Dr. Aurelia Peraud", + indication: "Isolated 4th ventricle with brainstem compression" + } + +Children (parent_id → parent): + category: procedure, type: shunt_replacement, data: {old: "Codman", new: "Miethke Blu"} + category: procedure, type: catheter_placement, data: {target: "4th ventricle"} + category: procedure, type: ETV, data: {location: "3rd ventricle floor"} + category: procedure, type: catheter_placement, data: {from: "3rd ventricle", to: "anterior brain stem"} +``` + +**Device with settings history:** +``` +-- Initial installation -- +category: device +type: miethke_blu_valve +value: "installed" +timestamp: 2021-12-10 +data: {setting: "20 cmH2O", component: "M. blue plus", surgeon: "Dr. Peraud"} + +-- Setting adjustment -- +category: device +type: miethke_blu_valve +value: "adjusted" +timestamp: 2022-08-15 +data: {setting: "30 cmH2O", adjusted_by: "Ulm team"} + +-- Suspected malfunction -- +category: device +type: miethke_blu_valve +value: "malfunction" +timestamp: 2024-10-21 +data: {expected_setting: "30 cmH2O", actual_setting: "24 cmH2O", status: "suspected dysfunction"} +``` + +**Open question with answer:** +``` +Parent: + category: question + type: surgical + value: "Is there opportunity for endoscopy to rearrange natural CSF flow?" + timestamp: 2025-01-01 + data: {status: "unanswered", raised_by: "parent"} + +Child (when answered): + parent_id: [question entry] + category: answer + type: specialist_opinion + value: "Dr. Peraud recommends shunt revision over endoscopy due to..." + timestamp: 2025-02-15 + data: {source: "video consultation", provider: "Dr. Peraud"} +``` + +**Consultation with findings:** +``` +Parent: + category: consultation + type: video_call + value: "Ulm Neurosurgery Follow-up" + timestamp: 2024-10-21 + data: { + institution: "University Clinic Ulm", + provider: "Prof. Dr. Aurelia Peraud", + interpreter: "Frau Natalya Kazmina" + } + +Children: + category: finding, type: imaging, value: "Ventricular enlargement progression" + category: finding, type: device, value: "Suspected valve dysfunction" + category: recommendation, type: surgical, value: "Shunt and valve revision recommended" + category: recommendation, type: screening, value: "MRSA/MRGN testing required pre-op" +``` + +**Personal medical history (undocumented past events):** +``` +category: history +type: surgery +value: "Appendectomy" +data: {age: 12, approximate_year: 2005, notes: "no complications"} + +category: history +type: illness +value: "Chickenpox" +data: {age: 6, severity: "normal"} + +category: history +type: injury +value: "Broke left arm" +data: {year: 2015, treatment: "cast 6 weeks"} + +category: history +type: allergy +value: "Penicillin" +data: {reaction: "rash", severity: "moderate", discovered: "childhood"} +``` + +**Family history:** +``` +category: family_history +type: mother +value: "Breast cancer" +data: {age_at_diagnosis: 58, outcome: "survived", treatment: "mastectomy + chemo"} + +category: family_history +type: father +value: "Type 2 diabetes" +data: {age_at_diagnosis: 52, status: "ongoing", management: "metformin"} + +category: family_history +type: maternal_grandmother +value: "Alzheimer's disease" +data: {age_at_onset: 72, age_at_death: 81} + +category: family_history +type: brother +value: "Healthy" +data: {age: 35, notes: "no known conditions"} + +category: family_history +type: family_pattern +value: "Heart disease" +data: {notes: "Multiple paternal relatives with early heart attacks"} +``` diff --git a/docs/SCHEMA_OLD.sql b/docs/SCHEMA_OLD.sql new file mode 100644 index 0000000..6c2d72f --- /dev/null +++ b/docs/SCHEMA_OLD.sql @@ -0,0 +1,125 @@ +-- Inou Old Database Schema (DICOM tables) +-- Deprecated: 2025-12-29 +-- Migrated to entries table + +-------------------------------------------------------------------------------- +-- PROFILES (now: dossiers) +-------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS profiles ( + guid TEXT PRIMARY KEY, + name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-------------------------------------------------------------------------------- +-- STUDIES (now: entries with category='imaging', type='study') +-------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS studies ( + guid TEXT PRIMARY KEY, + profile_guid TEXT REFERENCES profiles(guid), + study_instance_uid TEXT UNIQUE, + patient_name TEXT, + patient_dob TEXT, + patient_sex TEXT, + patient_age TEXT, + study_date TEXT, + study_time TEXT, + study_desc TEXT, + institution TEXT, + accession_number TEXT, + referring_physician TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_studies_profile ON studies(profile_guid); + +-------------------------------------------------------------------------------- +-- SERIES (now: entries with category='series', parent_id=study) +-------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS series ( + guid TEXT PRIMARY KEY, + study_guid TEXT REFERENCES studies(guid), + series_instance_uid TEXT, + series_number INTEGER, + series_desc TEXT, + modality TEXT, + body_part TEXT, + protocol_name TEXT, + manufacturer TEXT, + model TEXT, + magnetic_field_strength TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(series_instance_uid, series_desc) +); + +CREATE INDEX IF NOT EXISTS idx_series_study ON series(study_guid); + +-------------------------------------------------------------------------------- +-- SLICES (now: entries with category='slice', parent_id=series) +-------------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS slices ( + guid TEXT PRIMARY KEY, + series_guid TEXT REFERENCES series(guid), + sop_instance_uid TEXT, + instance_number INTEGER, -- maps to: ordinal + slice_location REAL, + slice_thickness REAL, + rows INTEGER, + cols INTEGER, + pixel_spacing_row REAL, + pixel_spacing_col REAL, + bits_allocated INTEGER, + bits_stored INTEGER, + high_bit INTEGER, + pixel_representation INTEGER, + window_center REAL, + window_width REAL, + window_center_2 REAL, + window_width_2 REAL, + rescale_intercept REAL, + rescale_slope REAL, + image_position_x REAL, + image_position_y REAL, + image_position_z REAL, + image_orientation TEXT, + orientation_type TEXT, -- maps to: type (axial/sagittal/coronal) + tr REAL, + te REAL, + ti REAL, + flip_angle REAL, + echo_number INTEGER, + spacing_between_slices REAL, + acquisition_time TEXT, + content_time TEXT, + photometric_interpretation TEXT, + samples_per_pixel INTEGER, + pixel_min INTEGER, + pixel_max INTEGER, + raw_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_slices_series ON slices(series_guid); + +-------------------------------------------------------------------------------- +-- MIGRATION MAPPING +-------------------------------------------------------------------------------- +-- studies.guid -> entries.entry_id (hash of guid) +-- studies.profile_guid -> entries.dossier_id +-- studies.study_instance_uid-> entries.value (for dedup) +-- studies.patient_name -> entries.tags +-- studies.* -> entries.data (JSON) +-- +-- series.guid -> entries.entry_id +-- series.study_guid -> entries.parent_id +-- series.series_number -> entries.ordinal +-- series.modality -> entries.type +-- series.series_desc -> entries.tags +-- series.* -> entries.data (JSON) +-- +-- slices.guid -> entries.entry_id +-- slices.series_guid -> entries.parent_id +-- slices.instance_number -> entries.ordinal (CRITICAL for ordering) +-- slices.orientation_type -> entries.type +-- slices.sop_instance_uid -> entries.value +-- slices.* -> entries.data (JSON) diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..e7fd66f --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,7 @@ +# Inou TODO + +## API + +- [ ] Review /api/labs/tests and /api/labs/results endpoints + - Verify entries table structure for labs data + - May need to adjust query logic when lab data is imported diff --git a/docs/anthropic-submission.md b/docs/anthropic-submission.md new file mode 100644 index 0000000..0cf3063 --- /dev/null +++ b/docs/anthropic-submission.md @@ -0,0 +1,104 @@ +# Anthropic MCP Connector Directory Submission + +**Target Date:** January 26, 2026 + +## Submission Checklist + +| Requirement | Status | Notes | +|-------------|--------|-------| +| OAuth 2.0 Authentication | Done | Authorization Code + PKCE | +| Tool Safety Annotations | Done | All 11 tools marked readOnlyHint | +| Privacy Policy | Done | https://inou.com/privacy-policy | +| DPA | Done | https://inou.com/legal/dpa | +| Security Page | Done | https://inou.com/security | +| Support Channel | Done | support@inou.com | +| Usage Examples | Done | docs/mcp-usage-examples.md (5 examples) | +| Test Account | Manual | Set email on Sophia dossier | +| Production Status | Done | No beta labels | + +## OAuth Credentials + +``` +Client ID: 116516c4f757a300e422796bf00f7204 +Client Secret: f5d2fe4f40258131cd6ab4c65a90afcde3a9ca4cb3f76d6979180bb001030a0b +``` + +## OAuth Endpoints + +``` +Authorization URL: https://inou.com/oauth/authorize +Token URL: https://inou.com/oauth/token +UserInfo URL: https://inou.com/oauth/userinfo +Revoke URL: https://inou.com/oauth/revoke +``` + +## MCP Server Details + +- **Name:** inou +- **Version:** 1.6.1 (Bridge) +- **Transport:** stdio +- **Package:** `@anthropic/mcp-bridge-node` (or direct install) + +## Available Tools (11 total, all read-only) + +| Tool | Description | +|------|-------------| +| `list_dossiers` | List patient dossiers accessible to the account | +| `list_studies` | List imaging studies for a dossier | +| `list_series` | List series within a study (filter by T1, FLAIR, etc.) | +| `list_slices` | List slices in a series with position info | +| `fetch_image` | Fetch slice image as PNG with optional windowing | +| `fetch_contact_sheet` | Thumbnail grid for navigation (not diagnosis) | +| `list_lab_tests` | List available lab tests | +| `get_lab_results` | Get lab values with date range/latest filters | +| `get_categories` | Get observation categories (genome, etc.) | +| `query_genome` | Query variants by gene, rsid, or category | +| `get_version` | Bridge and server version info | + +## Test Account Setup (Manual Step) + +The Sophia dossier has comprehensive test data: +- **Imaging:** 17 studies, 91 series, 4601 slices (brain MRI, spine MRI, CT, X-rays) +- **Genome:** 5989 variants +- **Dossier ID:** 3b38234f2b0f7ee6 + +To enable reviewer access: +1. Set an email on the Sophia dossier that Anthropic reviewers can receive +2. Login uses magic link (email code) +3. Share credentials via 1Password with Anthropic + +SQL to set email: +```sql +UPDATE dossiers SET email = 'review@inou.com' WHERE dossier_id = '3b38234f2b0f7ee6'; +``` + +## Form Responses + +**Connector Name:** inou + +**Short Description:** +Access your medical imaging (MRI, CT, X-ray), lab results, and genomic data. Let Claude analyze brain scans, interpret lab trends, and explain genetic variants. + +**Category:** Health & Medical + +**Unique Value Proposition:** +- Only connector with DICOM imaging (MRI, CT, X-ray analysis) +- Genomic variant interpretation +- User-owned data (not tied to US health systems) +- Global availability +- FIPS 140-3 encryption + +**Example Prompts:** +1. "Can you look at my brain MRI and tell me if anything looks concerning?" +2. "Check my vitamin D levels over the past year. Have they improved?" +3. "I have MTHFR mutations. Can you explain what that means for my health?" +4. "Look at my lumbar spine MRI, specifically the L4-L5 level." +5. "What genetic factors might affect how I respond to antidepressants?" + +## Links to Include + +- Website: https://inou.com +- Privacy Policy: https://inou.com/privacy-policy +- Security: https://inou.com/security +- DPA: https://inou.com/legal/dpa +- Support: support@inou.com diff --git a/docs/api-design-2026-01.md b/docs/api-design-2026-01.md new file mode 100644 index 0000000..afe0bce --- /dev/null +++ b/docs/api-design-2026-01.md @@ -0,0 +1,234 @@ +# inou API Design Decisions +*January 2026* + +## Overview + +This document captures the API redesign decisions for inou, focusing on making health data AI-ready through progressive disclosure, efficient queries, and secure token-based access. + +--- + +## Authentication + +### Token-Based Access +```json +{ + "d": "dossier_id", + "exp": 1736470800 +} +``` + +- **d**: The authenticated dossier (who logged in) +- **exp**: Unix timestamp expiration (few hours for external LLMs like Grok) +- Token is encrypted using existing `lib.CryptoEncrypt` +- No raw dossier IDs in URLs that live forever +- Backend looks up permissions from `dossier_access` table (not in token) + +### Why Tokens? +- Grok/ChatGPT users were querying with raw dossier IDs days later +- Tokens expire, preventing stale access +- Simpler than passing dossier in every request + +--- + +## API Endpoints + +### REST Style with Versioning +``` +GET /api/v1/dossiers +GET /api/v1/dossiers/{id} +GET /api/v1/dossiers/{id}/entries +GET /api/v1/dossiers/{id}/entries/{entry_id} +GET /api/v1/dossiers/{id}/entries/{entry_id}?detail=full +``` + +### Token in Header +``` +Authorization: Bearer +``` + +### Query Parameters +| Param | Purpose | +|-------|---------| +| `detail=full` | Return full image/text data | +| `search=pons` | Search summaries/tags | +| `category=imaging` | Filter by category (English) | +| `anatomy=hypothalamus` | Find slices by anatomy | +| `W=200&L=500` | DICOM windowing for images | + +### Dossier Always Explicit +Even if user has only one dossier, it's in the URL. No special cases. + +--- + +## Progressive Disclosure + +LLM gets everything needed to **decide** in first call. Full detail only when needed. + +| Entry Type | Quick Glance | Full Detail | +|------------|--------------|-------------| +| Study | anatomy + summary | - | +| Series | slice count, orientation | - | +| Slice | thumbnail (150x150) | full image | +| Document | summary + tags | full text | +| Lab | values + ranges | historical trend | +| Genome | category + count | variant list | + +### Fewer Round-Trips +Before: LLM guesses slices, fetches one by one, backtracks +After: LLM sees anatomy index, requests exact slices needed + +--- + +## Thumbnails + +### Specification +- Size: **150x150** max dimension (preserve aspect ratio) +- Format: **PNG** (8-bit greyscale, lossless) +- Target: **~5KB** per thumbnail +- Storage: **Database** (in Data JSON field or BLOB column) + +### Why DB Not Filesystem? +- Batch queries: "get 50 slices with thumbnails" = 1 query +- Fewer IOPS (no 50 small file reads) +- DB file stays hot in cache +- 4000 slices x 5KB = 20MB (trivial) + +### Full Images +- Stay on filesystem (`/tank/inou/objects/`) +- Fetched one at a time with `?detail=full` + +--- + +## Anatomical Analysis + +### Problem +LLM spends many round-trips finding the right slice ("find the pons" → guess → wrong → try again) + +### Solution +Analyze reference slices at ingest, store anatomy with z-ranges. + +### Approach +1. For each orientation (SAG, COR, AX) present in study +2. Pick mid-slice from T2 (preferred) or T1 +3. Run vision API: "identify anatomical structures with z-ranges" +4. Store in study entry + +### Storage +```json +{ + "anatomy": { + "pons": {"z_min": -30, "z_max": -20}, + "cerebellum": {"z_min": -45, "z_max": -25}, + "hypothalamus": {"z_min": 20, "z_max": 26} + }, + "body_part": "brain" +} +``` + +### Query Time +- LLM asks "find pons in this series" +- Lookup: pons at z=-30 to -20 +- Find slices in series where `slice_location BETWEEN -30 AND -20` +- Return matching slices with thumbnails + +### Cost +- 1-3 vision API calls per study (~$0.01-0.03) +- Stored once, used forever +- No per-query cost + +### Why Not Position-Based Lookup? +Tried it, deleted it. Pediatric vs adult brains have different z-coordinates for same structures. + +### Generalization +- Brain: mid-sagittal reference +- Spine: mid-sagittal reference +- Knee: mid-sagittal reference +- Abdomen: may need coronal + axial references +- Animals: same principle, vision model identifies structures + +--- + +## Categories + +### Consolidated Structure +27 categories (down from 31), using integers internally. + +| Int | Name | Notes | +|-----|------|-------| +| 0 | imaging | Unified: slice, series, study (Type differentiates) | +| 1 | document | | +| 2 | lab | | +| 3 | genome | Unified: tier, rsid, variant (Type differentiates) | +| ... | ... | | + +### Translation +- **API Input**: Accept English OR any translated name +- **API Output**: Return translated name (user's language) +- **Navigation**: By entry ID, not category name + +```go +var categoryFromAny = map[string]int{} // "genome", "Геном", "ゲノム" → 3 +``` + +--- + +## Database + +### SQLite Stays +- Read-heavy workload (LLM queries) +- Occasional writes (imports) +- Single server, few users +- Millions of rows, no problem +- Single file backup +- WAL mode for concurrency + +### Alternatives Considered +- **FoundationDB**: Overkill for single server +- **bbolt/BadgerDB**: Lose SQL convenience, maintain indexes manually +- **Postgres**: "Beautiful tech from 15 years ago" - user preference + +### What We Actually Use +Just key-value with secondary indexes: +1. Get by ID +2. Get children (parent_id = X) +3. Filter by dossier +4. Filter by category +5. Order by ordinal/timestamp + +SQLite handles this perfectly. + +--- + +## RBAC (Future) + +### Concept +Category-level permissions per relationship. + +Example: Trainer can see exercise, nutrition, supplements but NOT hospitalization, fertility. + +### Implementation +- Presets by relationship type (trainer, doctor, family) +- User overrides per category +- Stored in `dossier_access` table +- Backend lookup (not in token) + +### Not Priority Now +Token + expiration first. RBAC layers on later. + +--- + +## Summary + +| Decision | Choice | +|----------|--------| +| Auth | Token with dossier + expiration | +| API Style | REST, versioned (`/api/v1/`) | +| Endpoints | `/dossiers`, `/entries` (2 main) | +| Thumbnails | 150x150 PNG, ~5KB, in DB | +| Full images | Filesystem, on-demand | +| Anatomy | Reference slice analysis at ingest | +| Categories | Integers internally, translated strings externally | +| Database | SQLite | +| RBAC | Future work | + +**Core Principle**: AI-ready health data through progressive disclosure. LLM gets context upfront, fetches details only when needed. diff --git a/docs/mcp-usage-examples.md b/docs/mcp-usage-examples.md new file mode 100644 index 0000000..3b27075 --- /dev/null +++ b/docs/mcp-usage-examples.md @@ -0,0 +1,221 @@ +# inou MCP Connector - Usage Examples + +These examples demonstrate how Claude can help users explore their health data through the inou connector. + +--- + +## Example 1: Brain MRI Analysis + +**User prompt:** +> "I had a brain MRI last year. Can you look at it and tell me if anything looks concerning? I've been having headaches." + +**Claude's approach:** + +1. Lists the user's dossiers to find their health record +2. Lists imaging studies, identifies the brain MRI +3. Fetches the contact sheet to see all slices at a glance +4. Identifies relevant axial slices through the ventricles and key structures +5. Fetches full-resolution images of specific slices for detailed analysis +6. Provides observations about brain structures, symmetry, and any visible findings + +**Sample interaction:** + +``` +Claude: I found your brain MRI from May 2024. Let me take a look at the axial +T2-weighted images, which are best for seeing brain tissue detail. + +[Fetches contact sheet, then specific slices] + +Looking at these images, I can see: +- The lateral ventricles appear symmetric and normal in size +- No midline shift is present +- The brain parenchyma shows normal signal intensity +- I don't see any obvious mass lesions or areas of abnormal enhancement + +However, I want to be clear: I'm an AI assistant, not a radiologist. For +headaches, the MRI findings are just one piece of the puzzle. Your doctor +can correlate these images with your symptoms, medical history, and physical +exam for a complete picture. + +Would you like me to look at any specific region more closely? +``` + +--- + +## Example 2: Lab Result Trends + +**User prompt:** +> "Can you check my vitamin D and iron levels? I've been feeling tired lately." + +**Claude's approach:** + +1. Lists available lab tests for the user +2. Fetches vitamin D and iron panel results (ferritin, serum iron, TIBC) +3. Identifies trends over time if multiple results exist +4. Explains the values in context with reference ranges + +**Sample interaction:** + +``` +Claude: I found your lab results. Here's what I see: + +**Vitamin D (25-OH):** +- Jan 2024: 18 ng/mL (low - reference: 30-100) +- Jul 2024: 32 ng/mL (normal) +- Dec 2024: 24 ng/mL (insufficient) + +Your vitamin D dropped again after summer, which is common in northern +latitudes. The insufficient level could contribute to fatigue. + +**Ferritin:** +- Dec 2024: 45 ng/mL (normal range: 12-150) + +**Serum Iron:** +- Dec 2024: 85 mcg/dL (normal range: 60-170) + +Your iron stores look healthy. The fatigue is more likely related to your +vitamin D insufficiency. Many people benefit from supplementing with D3 +during winter months - typically 1000-2000 IU daily, though your doctor +can recommend the right dose for you. + +Want me to look at any other lab values? +``` + +--- + +## Example 3: Genetic Insights for Medication Response + +**User prompt:** +> "I'm about to start an antidepressant. Can you check if I have any genetic factors that might affect how I respond to medications?" + +**Claude's approach:** + +1. Queries the genome data for pharmacogenomics-related variants +2. Looks specifically at CYP2D6, CYP2C19 (drug metabolism enzymes) +3. Checks for MTHFR variants that affect folate metabolism +4. Explains findings in practical terms + +**Sample interaction:** + +``` +Claude: I found some relevant genetic variants in your data: + +**CYP2D6 - Drug Metabolism:** +You have rs3892097 (CYP2D6*4) - one copy. This makes you an "intermediate +metabolizer" for many medications processed by CYP2D6, including some +antidepressants like: +- Fluoxetine (Prozac) +- Paroxetine (Paxil) +- Venlafaxine (Effexor) + +This means you may process these medications more slowly than average. Your +doctor might start with a lower dose. + +**CYP2C19:** +You have rs4244285 (CYP2C19*2) - one copy. This affects metabolism of: +- Citalopram (Celexa) +- Escitalopram (Lexapro) +- Sertraline (Zoloft) + +Again, intermediate metabolism - not a problem, but good to know. + +**MTHFR:** +You have rs1801133 (C677T) - heterozygous. This is very common and mildly +reduces folate processing. Some research suggests methylfolate supplementation +may support antidepressant response. + +I'd recommend sharing these results with your prescribing doctor. This +information can help them choose the right medication and starting dose +for you. +``` + +--- + +## Example 4: Cross-Domain Health Analysis + +**User prompt:** +> "I have MTHFR mutations. Can you look at my homocysteine levels and see if they correlate?" + +**Claude's approach:** + +1. Queries genome for MTHFR variants +2. Fetches homocysteine lab results +3. Correlates the genetic and lab findings +4. Provides context on the relationship + +**Sample interaction:** + +``` +Claude: Let me pull together your genetic and lab data on this. + +**MTHFR Genetic Status:** +- rs1801133 (C677T): T/T (homozygous) - reduces enzyme activity by ~70% +- rs1801131 (A1298C): A/C (heterozygous) - mild additional effect + +This combination significantly reduces your MTHFR enzyme function. + +**Homocysteine Lab Results:** +- Mar 2023: 14.2 umol/L (slightly elevated - reference: <10) +- Sep 2023: 11.8 umol/L (borderline) +- Mar 2024: 9.1 umol/L (normal) + +I can see your levels have improved over time. This pattern is consistent +with what happens when people with MTHFR variants supplement with +methylfolate (active B9) and methylcobalamin (active B12), which bypass +the impaired enzyme. + +The correlation here is clear: your genetic profile predicts higher +homocysteine risk, and your labs confirmed it until intervention brought +levels to normal. + +Would you like me to check other B-vitamin levels that might be relevant? +``` + +--- + +## Example 5: Spine MRI Navigation + +**User prompt:** +> "Can you look at my lumbar spine MRI? My doctor mentioned something about L4-L5." + +**Claude's approach:** + +1. Lists imaging studies, finds the spine MRI +2. Lists series to find sagittal and axial T2 sequences +3. Uses contact sheet to identify L4-L5 level +4. Fetches relevant slices at full resolution +5. Describes findings at the specific level + +**Sample interaction:** + +``` +Claude: I found your lumbar spine MRI from October 2024. Let me look at +the L4-L5 level specifically. + +[Fetches sagittal T2 for overview, then axial slices at L4-L5] + +At the L4-L5 level, I can see: +- There's a disc bulge that extends posteriorly (toward the back) +- The bulge appears to contact the thecal sac (the fluid-filled sac + containing the nerve roots) +- On the axial view, the bulge is slightly more prominent on the left side +- The neural foramina (nerve exit tunnels) appear mildly narrowed on the left + +This pattern is consistent with a disc bulge that could potentially affect +the L5 nerve root, which can cause symptoms down the leg. + +For comparison, your L3-L4 level above looks normal with good disc height +and no bulging. + +Want me to look at any other levels, or compare the left and right sides +in more detail? +``` + +--- + +## Notes for Users + +- Claude provides informational analysis, not medical diagnoses +- Always discuss findings with your healthcare provider +- Image quality and completeness affect analysis accuracy +- Genetic interpretations are based on current research, which evolves diff --git a/docs/rbac-design-2026-01.md b/docs/rbac-design-2026-01.md new file mode 100644 index 0000000..234ab46 --- /dev/null +++ b/docs/rbac-design-2026-01.md @@ -0,0 +1,317 @@ +# RBAC & Family Tree Redesign + +**Date:** January 2026 +**Status:** Design phase + +--- + +## Overview + +Redesign of the permission and family relationship model to: +1. Properly model genetic relationships via family tree +2. Implement granular, entry-level RBAC +3. Support custom roles with canned presets +4. Enforce all access control at the store layer + +--- + +## 1. Family Tree (Genetics) + +### Schema Change + +Add to `dossiers` table: +```sql +ALTER TABLE dossiers ADD COLUMN mother_id TEXT REFERENCES dossiers(dossier_id); +ALTER TABLE dossiers ADD COLUMN father_id TEXT REFERENCES dossiers(dossier_id); +``` + +### Derived Relationships + +All relationships computed via graph traversal: + +| Relationship | Derivation | +|--------------|------------| +| Children | `WHERE mother_id = X OR father_id = X` | +| Siblings | Share at least one parent | +| Full sibling | Share both parents | +| Half-sibling (maternal) | Share mother only | +| Half-sibling (paternal) | Share father only | +| Grandparents | Parent's mother_id / father_id | +| Cousins, aunts, etc. | Graph traversal | + +### UI + +Each dossier shows: +``` +Mother: [Name] [view] +Father: [_____] [+ add] [link: ____] +``` + +- **+ add**: Create new dossier, link as parent +- **link**: Enter existing dossier ID to link + +--- + +## 2. RBAC Model + +### Four Operations + +| Op | Code | Description | +|----|------|-------------| +| Read | r | View entry and children | +| Write | w | Add/edit entries | +| Delete | d | Remove entries | +| Manage | m | Share access, grant permissions | + +### Access Levels + +| Level | R | W | D | M | +|-------|---|---|---|---| +| Owner (self) | always | always | always | always | +| Can-manage flag | always | always | always | always | +| Entry grant | per grant | per grant | no | no | + +--- + +## 3. Schema: `access` Table + +Single flexible table for all access control: + +```sql +CREATE TABLE access ( + id TEXT PRIMARY KEY, + dossier_id TEXT NOT NULL REFERENCES dossiers(dossier_id), + grantee_id TEXT REFERENCES dossiers(dossier_id), -- null = role template + entry_id TEXT REFERENCES entries(entry_id), -- null = root level + role TEXT NOT NULL, -- "Trainer", "Family", custom + ops TEXT NOT NULL, -- "r", "rw", "rwdm" + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_access_grantee ON access(grantee_id); +CREATE INDEX idx_access_dossier ON access(dossier_id); +CREATE INDEX idx_access_entry ON access(entry_id); +``` + +### How It Works + +| grantee_id | entry_id | role | ops | Meaning | +|------------|----------|------|-----|---------| +| null | null | "Trainer" | r | Template: Trainer default | +| null | {exercise_root} | "Trainer" | rw | Template: Trainer exercise access | +| {john} | null | "Trainer" | r | Grant: John as Trainer | +| {john} | {exercise_root} | "Trainer" | rw | Grant: John exercise write | +| {mary} | null | "Family" | rwdm | Grant: Mary full access | + +### Role Templates + +- `grantee_id = null` → template definition +- Templates are per-dossier (user creates their own) +- System templates: `dossier_id = null` (Doctor, Coach, etc.) + +### Granting Access + +1. User selects a role (template) or creates custom +2. System copies template rows with grantee_id set +3. User can customize entry-level overrides + +--- + +## 4. Permission Resolution + +### Algorithm + +``` +canAccess(accessor, dossier, entry, op): + 1. if ctx.IsSystem → allow + 2. if accessor == dossier owner → allow + 3. lookup grants for (accessor, dossier) + 4. if grant exists for entry with op → allow + 5. walk up entry.parent_id, repeat step 4 + 6. if hit root (entry=null) and still no grant → deny +``` + +### Inheritance + +Access to a parent entry implies access to all children. + +Example: Grant read on "Exercise" category → can read all exercise entries. + +--- + +## 5. Caching Strategy (Option C) + +### Approach + +Load grants lazily per accessor-dossier pair, cache in memory. + +### Implementation + +```go +type permCache struct { + // [dossier_id][entry_id] → "rwdm" + grants map[string]map[string]string + loadedAt time.Time +} + +// Per accessor +var accessorCache = map[string]*permCache{} +var cacheTTL = 5 * time.Minute +``` + +### Load + +```go +func loadPerms(accessorID string) *permCache { + // SELECT dossier_id, entry_id, ops + // FROM access + // WHERE grantee_id = ? + // Build map, return +} +``` + +### Check + +```go +func canAccess(ctx, dossierID, entryID, op string) bool { + if ctx.IsSystem { return true } + if ctx.AccessorID == dossierOwner { return true } + + perms := getPerms(ctx.AccessorID, dossierID) + return walkTreeCheckPerms(perms, entryID, op) +} +``` + +### Invalidation + +```go +func invalidateCache(accessorID string) { + delete(accessorCache, accessorID) +} +// Called on any access table write +``` + +--- + +## 6. Store Layer Enforcement + +### Principle + +All RBAC checks happen in `store.go`. Impossible to bypass. + +### Context + +```go +type AccessContext struct { + AccessorID string // who's asking + IsSystem bool // bypass RBAC +} +``` + +### Wrapped Functions + +| Current | New | +|---------|-----| +| `EntryGet(id)` | `EntryGet(ctx, id)` | +| `EntryList(filter)` | `EntryList(ctx, filter)` | +| `EntrySave(e)` | `EntrySave(ctx, e)` | +| `EntryDelete(id)` | `EntryDelete(ctx, id)` | +| `DossierGet(id)` | `DossierGet(ctx, id)` | + +### Prerequisite + +Consolidate all SQL into `store.go`. Currently scattered across: +- `portal/main.go` +- `api/api_*.go` (14 files) +- `lib/data.go`, `lib/v2.go`, `lib/prompt.go` +- `portal/prompts.go`, `portal/genome.go` + +--- + +## 7. Canned Role Presets + +### System Roles (dossier_id = null) + +| Role | Default Ops | Entry Scope | +|------|-------------|-------------| +| Family | rwdm | root | +| Doctor | rw | root | +| Caregiver | rw | root | +| Trainer | r | root | +| Trainer | rw | exercise category | +| Trainer | rw | nutrition category | +| Friend | r | root | + +### Custom Roles + +Users can create their own role templates with custom entry-level permissions. + +--- + +## 8. Migration from Current Model + +### What Changes + +| Current | New | +|---------|-----| +| `dossier_access.relation` (family types) | `dossiers.mother_id/father_id` | +| `dossier_access.relation` (roles) | `access.role` | +| `dossier_access.can_edit` | `access.ops` includes 'w' | +| `dossier_access.is_care_receiver` | `access.ops` includes 'm' | + +### What Gets Removed + +- Family relation types in `dossier_access` (Parent, Child, Sibling, etc.) +- `lineage` field (never fully implemented) + +### Migration Steps + +1. Add `mother_id`, `father_id` to dossiers +2. Create `access` table +3. Migrate existing `dossier_access` → `access`: + - `can_edit=true` → ops includes 'w' + - `is_care_receiver=true` → ops='rwdm' + - role from relation label +4. Consolidate SQL into store.go +5. Add ctx parameter throughout +6. Deprecate `dossier_access` table + +--- + +## 9. API Impact + +### MCP Bridge + +Needs to pass accessor context from authentication. + +### REST API + +Needs to pass accessor context from token. + +### Portal + +Needs to create context from session. + +--- + +## 10. Open Questions + +1. How to handle "link existing dossier" for family tree? Merge conflicts? +2. Performance of tree traversal for permission checks - need benchmarks +3. UI for custom role creation - how complex? +4. Backward compatibility period - dual-write? + +--- + +## 11. Implementation Order + +1. [ ] Design review & sign-off +2. [ ] Consolidate SQL into store.go +3. [ ] Add `access` table +4. [ ] Implement permission cache +5. [ ] Add `mother_id/father_id` to dossiers +6. [ ] Migrate existing permissions +7. [ ] Update portal UI +8. [ ] Update API +9. [ ] Update MCP bridge +10. [ ] Deprecate old model diff --git a/docs/reference-mccalip-style.css b/docs/reference-mccalip-style.css new file mode 100644 index 0000000..62656e4 --- /dev/null +++ b/docs/reference-mccalip-style.css @@ -0,0 +1,41 @@ +/* + * Reference stylesheet from andrewmccalip.com/space-datacenters + * Saved 2025-12-19 for design inspiration + * Key patterns: colored borders, stat rows, bar charts, sliders + */ + +:root { + --orbital: #1e3a5f; + --ngcc: #8b4513; + --paper: #fdfbf7; + --paper-dark: #f5f0e8; + --ink: #1a1a1a; + --ink-muted: #4a4a4a; + --ink-light: #8a8a8a; + --rule: #d4cfc4; + --rule-light: #e8e4db; + --font-sans: Inter, -apple-system, sans-serif; + --font-mono: JetBrains Mono, Consolas, monospace; +} + +/* Cards with colored left border */ +.column-indicator { width: 4px; height: 40px; border-radius: 2px; } + +/* Bar Charts: label | bar | value */ +.bar-item { display: grid; grid-template-columns: 80px 1fr 80px; gap: 0.75rem; align-items: center; } +.bar-container { height: 24px; background: var(--rule-light); border-radius: 2px; } +.bar { height: 100%; transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); } +.bar-value { font-family: var(--font-mono); font-size: 0.75rem; text-align: right; } + +/* Stats: label ... value */ +.stat-row { display: flex; justify-content: space-between; padding: 0.4rem 0; } +.stat-label { font-size: 0.8rem; color: var(--ink-light); } +.stat-value { font-family: var(--font-mono); font-size: 0.85rem; font-weight: 500; } + +/* Section headers: SMALL CAPS · WITH DOTS */ +.section-heading { font-size: 0.85rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; } + +/* Sliders */ +.slider-header { display: flex; justify-content: space-between; margin-bottom: 1rem; } +.slider-track { height: 2px; background: var(--rule); } +.slider-ticks { display: flex; justify-content: space-between; font-size: 0.65rem; color: var(--ink-light); } diff --git a/docs/schema-auth.sql b/docs/schema-auth.sql new file mode 100644 index 0000000..d573e30 --- /dev/null +++ b/docs/schema-auth.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- Auth Database Schema (auth.db) +-- ============================================================================ +-- Separate from medical data. Contains volatile OAuth/session data. +-- Tables are NOT auto-created. Use this file manually if needed. +-- ============================================================================ + +-- OAuth Clients (Claude, Flutter app, etc.) +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_secret TEXT NOT NULL, + name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, -- JSON array + created_at INTEGER NOT NULL +); + +-- OAuth Authorization Codes (short-lived, single-use) +CREATE TABLE IF NOT EXISTS oauth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + dossier_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT, + code_challenge_method TEXT, + expires_at INTEGER NOT NULL, + used INTEGER DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_codes(client_id); + +-- OAuth Refresh Tokens (long-lived, rotatable) +CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + token_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + dossier_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked INTEGER DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_oauth_refresh_dossier ON oauth_refresh_tokens(dossier_id); +CREATE INDEX IF NOT EXISTS idx_oauth_refresh_client ON oauth_refresh_tokens(client_id); diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 0000000..ba14d0e --- /dev/null +++ b/docs/schema.sql @@ -0,0 +1,24 @@ +-- ============================================================================ +-- Inou Database Schema (inou.db) +-- ============================================================================ +-- Medical data. Tables are NOT auto-created. +-- Use this file manually if you ever need to recreate tables. +-- +-- OAuth tables are in separate auth.db (see schema-auth.sql) +-- ============================================================================ + +-- RBAC Access Grants +-- Stores who (grantee) can access whose (dossier) data with what permissions (ops) +CREATE TABLE IF NOT EXISTS access ( + access_id TEXT PRIMARY KEY, + dossier_id TEXT, -- whose data (encrypted) + grantee_id TEXT, -- who gets access (encrypted) + entry_id TEXT, -- specific entry, or empty for dossier-wide + role TEXT NOT NULL, -- role name (Family, Doctor, etc.) + ops TEXT NOT NULL, -- operations: r=read, w=write, d=delete, m=manage + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_access_grantee ON access(grantee_id); +CREATE INDEX IF NOT EXISTS idx_access_dossier ON access(dossier_id); +CREATE INDEX IF NOT EXISTS idx_access_entry ON access(entry_id); diff --git a/docs/soc2/._data-retention-policy.md b/docs/soc2/._data-retention-policy.md new file mode 100644 index 0000000..8b8912b Binary files /dev/null and b/docs/soc2/._data-retention-policy.md differ diff --git a/docs/soc2/._disaster-recovery-plan.md b/docs/soc2/._disaster-recovery-plan.md new file mode 100644 index 0000000..501595b Binary files /dev/null and b/docs/soc2/._disaster-recovery-plan.md differ diff --git a/docs/soc2/._incident-response-plan.md b/docs/soc2/._incident-response-plan.md new file mode 100644 index 0000000..a54397c Binary files /dev/null and b/docs/soc2/._incident-response-plan.md differ diff --git a/docs/soc2/._risk-assessment.md b/docs/soc2/._risk-assessment.md new file mode 100644 index 0000000..6ae2293 Binary files /dev/null and b/docs/soc2/._risk-assessment.md differ diff --git a/docs/soc2/._security-policy.md b/docs/soc2/._security-policy.md new file mode 100644 index 0000000..dad8132 Binary files /dev/null and b/docs/soc2/._security-policy.md differ diff --git a/docs/soc2/._soc2-self-assessment-2026.md b/docs/soc2/._soc2-self-assessment-2026.md new file mode 100644 index 0000000..50d9e9a Binary files /dev/null and b/docs/soc2/._soc2-self-assessment-2026.md differ diff --git a/docs/soc2/data-retention-policy.md b/docs/soc2/data-retention-policy.md new file mode 100644 index 0000000..aa927c6 --- /dev/null +++ b/docs/soc2/data-retention-policy.md @@ -0,0 +1,210 @@ +# Data Retention Policy + +**Version:** 1.0 +**Effective:** January 2026 +**Owner:** Johan Jongsma +**Review:** Annually + +--- + +## 1. Purpose + +Define how long inou retains user data and the procedures for data deletion. + +--- + +## 2. Scope + +All data stored in inou systems: +- User accounts (dossiers) +- Medical data (imaging, labs, genome, documents, vitals) +- Access logs +- Authentication tokens + +--- + +## 3. Retention Periods + +### User Data + +| Data Type | Retention Period | Rationale | +|-----------|------------------|-----------| +| Active user data | Indefinite while account active | User controls their data | +| Deleted user data | Immediately purged | User right to deletion | + +### System Data + +| Data Type | Retention Period | Rationale | +|-----------|------------------|-----------| +| HTTP access logs | 90 days | Security investigation window | +| Audit logs | 7 years | Regulatory compliance (HIPAA) | +| Error logs | 90 days | Debugging and monitoring | + +### Authentication Data + +| Data Type | Retention Period | Rationale | +|-----------|------------------|-----------| +| Session tokens | 30 days or until logout | Session lifetime | +| API tokens | Until expiry (4 hours) + 24 hours | Cleanup buffer | +| Verification codes | 10 minutes | Security | +| OAuth authorization codes | 10 minutes | OAuth specification | +| Refresh tokens | 30 days or until revoked | OAuth specification | + +### Backup Data + +| Data Type | Retention Period | Rationale | +|-----------|------------------|-----------| +| Local ZFS snapshots | 30 days | Recovery window | +| Off-site backups | 90 days | Extended recovery option | + +--- + +## 4. User-Initiated Deletion + +### Account Deletion + +When a user deletes their account: + +**Immediate actions:** +- Mark dossier as deleted +- Revoke all active sessions and tokens +- Remove from search and listing + +**Within 24 hours:** +- Purge all entries (imaging, labs, genome, documents) +- Remove access grants (given and received) +- Remove from active database + +**Retained for compliance:** +- Audit log entries (7 years, anonymized) + +### Individual Entry Deletion + +When a user deletes a specific entry: +- Entry removed from database immediately +- Removed from local backups per snapshot rotation (30 days) +- Removed from off-site backups per retention schedule (90 days) + +### Right to Erasure (GDPR Article 17) + +Users may request complete erasure: + +1. User submits request via security@inou.com +2. Identity verified +3. Deletion executed within 30 days +4. Confirmation sent to user +5. Request logged for compliance + +--- + +## 5. Automated Retention Enforcement + +### Daily Cleanup Jobs + +```sql +-- Remove expired auth tokens +DELETE FROM oauth_tokens WHERE expires_at < datetime('now', '-24 hours'); +DELETE FROM auth_codes WHERE expires_at < datetime('now', '-24 hours'); +DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-24 hours'); + +-- Remove expired sessions +DELETE FROM sessions WHERE expires_at < datetime('now'); + +-- Remove old rate limit entries +DELETE FROM rate_limits WHERE created_at < datetime('now', '-24 hours'); +``` + +### Log Rotation + +```bash +# Rotate logs older than 90 days +find /tank/inou/*.log -mtime +90 -delete +``` + +### Backup Rotation + +- ZFS snapshots: Automatic rotation, 30-day retention +- Off-site (Google Drive): 90-day retention, older backups removed + +--- + +## 6. Legal Holds + +When litigation or investigation requires data preservation: + +1. **Identify scope** - Which users/data affected +2. **Suspend deletion** - Exclude from automated purges +3. **Document hold** - Record reason, scope, authorizer, date +4. **Release hold** - When legal matter resolved, resume normal retention + +**Current legal holds:** None + +--- + +## 7. Data Export + +Users may export their data at any time: +- Full export available via portal +- Formats: JSON (structured data), original files (DICOM, PDFs, images) +- Export includes all user-uploaded data and derived analysis + +--- + +## 8. Backup Data Handling + +Deleted data may persist in backups until rotation completes: + +| Backup Type | Maximum Persistence After Deletion | +|-------------|-----------------------------------| +| ZFS snapshots | 30 days | +| Off-site backups | 90 days | + +Users are informed that complete purge from all backups occurs within 90 days of deletion request. + +--- + +## 9. Third-Party Data + +### Proton (SMTP) + +- Verification codes only (6-digit numbers) +- No health data transmitted +- Subject to Proton's retention policies + +### Google Drive (Backups) + +- Encrypted data only; Google cannot read contents +- Retention controlled by inou (90 days) +- Deleted per inou's backup rotation schedule + +--- + +## 10. Compliance Mapping + +| Regulation | Requirement | Implementation | +|------------|-------------|----------------| +| GDPR Art. 17 | Right to erasure | Immediate deletion on request | +| GDPR Art. 5(1)(e) | Storage limitation | Defined retention periods | +| HIPAA | 6-year record retention | 7-year audit log retention | +| CCPA | Deletion rights | Same as GDPR implementation | + +--- + +## 11. Verification + +### Monthly Review + +- [ ] Verify cleanup jobs running +- [ ] Check for orphaned data +- [ ] Review pending deletion requests +- [ ] Confirm backup rotation operating + +### Annual Review + +- [ ] Review retention periods for regulatory changes +- [ ] Update policy as needed +- [ ] Verify compliance with stated periods + +--- + +*Document end* diff --git a/docs/soc2/disaster-recovery-plan.md b/docs/soc2/disaster-recovery-plan.md new file mode 100644 index 0000000..0255b2f --- /dev/null +++ b/docs/soc2/disaster-recovery-plan.md @@ -0,0 +1,378 @@ +# Disaster Recovery Plan + +**Version:** 1.0 +**Effective:** January 2026 +**Owner:** Johan Jongsma +**Review:** Annually +**Last DR Test:** Not yet performed + +--- + +## 1. Purpose + +Define procedures to recover inou services and data following a disaster affecting production systems. + +--- + +## 2. Scope + +| System | Location | Criticality | +|--------|----------|-------------| +| Production server | 192.168.100.2 | Critical | +| Production database | /tank/inou/data/inou.db | Critical | +| Master encryption key | /tank/inou/master.key | Critical | +| Staging server | 192.168.1.253 | Medium | + +--- + +## 3. Recovery Objectives + +| Metric | Target | +|--------|--------| +| **RTO** (Recovery Time Objective) | 4 hours | +| **RPO** (Recovery Point Objective) | 24 hours | + +--- + +## 4. Backup Strategy + +### Backup Inventory + +| Data | Method | Frequency | Retention | Location | +|------|--------|-----------|-----------|----------| +| Database | ZFS snapshot | Daily | 30 days | Local (RAID-Z2) | +| Database | rclone sync | Daily | 90 days | Google Drive (encrypted) | +| Images | ZFS snapshot | Daily | 30 days | Local (RAID-Z2) | +| Images | rclone sync | Daily | 90 days | Google Drive (encrypted) | +| Master key | Manual copy | On change | Permanent | Proton Pass | +| Configuration | Git repository | Per change | Permanent | Local + remote | + +### Encryption + +All data is encrypted before leaving the server: +- Database fields: AES-256-GCM encryption +- Images: Stored encrypted +- Off-site backups: Already encrypted; Google cannot read contents +- Master key: Stored separately in Proton Pass (E2E encrypted) + +### ZFS Snapshot Management + +```bash +# List available snapshots +zfs list -t snapshot tank/inou + +# Create manual snapshot before major changes +zfs snapshot tank/inou@pre-change-$(date +%Y%m%d-%H%M) + +# Snapshots are automatically created daily +``` + +--- + +## 5. Disaster Scenarios + +### Scenario A: Hardware Failure (Single Component) + +**Symptoms:** Server unresponsive, disk errors, network failure + +**Recovery:** +1. Identify failed component +2. Replace hardware +3. Boot from existing ZFS pool or restore from snapshot +4. Verify services: `make test` + +**Estimated time:** 2-4 hours + +### Scenario B: Database Corruption + +**Symptoms:** Application errors, SQLite integrity failures + +**Recovery:** + +```bash +# 1. Stop services +ssh johan@192.168.100.2 "sudo systemctl stop inou-portal inou-api" + +# 2. Backup corrupted DB for analysis +ssh johan@192.168.100.2 "cp /tank/inou/data/inou.db /tank/inou/data/inou.db.corrupted" + +# 3. List available snapshots +ssh johan@192.168.100.2 "zfs list -t snapshot tank/inou" + +# 4. Restore from snapshot +ssh johan@192.168.100.2 "cp /tank/inou/.zfs/snapshot//data/inou.db /tank/inou/data/inou.db" + +# 5. Restart services +ssh johan@192.168.100.2 "sudo systemctl start inou-portal inou-api" + +# 6. Verify +make test +``` + +**Estimated time:** 1-2 hours + +### Scenario C: Complete Server Loss + +**Symptoms:** Server destroyed, stolen, or unrecoverable + +**Recovery:** + +```bash +# 1. Provision new server with Ubuntu 24.04 LTS +# 2. Apply OS hardening (see security-policy.md) + +# 3. Create directory structure +mkdir -p /tank/inou/{bin,data,static,templates,lang} + +# 4. Restore master key from Proton Pass +# Copy 32-byte key to /tank/inou/master.key +chmod 600 /tank/inou/master.key + +# 5. Restore database from Google Drive +rclone copy gdrive:inou-backup/inou.db /tank/inou/data/ + +# 6. Restore images from Google Drive +rclone copy gdrive:inou-backup/images/ /tank/inou/data/images/ + +# 7. Clone application and build +cd ~/dev +git clone inou +cd inou +make build + +# 8. Deploy +make deploy-prod + +# 9. Update DNS if IP changed + +# 10. Verify +make test +``` + +**Estimated time:** 4-8 hours + +### Scenario D: Ransomware/Compromise + +**Symptoms:** Encrypted files, unauthorized access, system tampering + +**Recovery:** +1. **Do not use compromised system** - assume attacker persistence +2. Provision fresh server from scratch +3. Restore from known-good backup (before compromise date) +4. Rotate master key and re-encrypt all data +5. Rotate all credentials +6. Apply additional hardening +7. Monitor closely for re-compromise + +**Estimated time:** 8-24 hours + +### Scenario E: Site Loss (Fire/Flood/Natural Disaster) + +**Symptoms:** Physical location destroyed or inaccessible + +**Recovery:** +1. Obtain replacement hardware +2. Restore from off-site backup (Google Drive) +3. Restore master key from Proton Pass +4. Rebuild and deploy application +5. Update DNS to new IP + +**Estimated time:** 24-48 hours + +--- + +## 6. Key Management + +### Master Key Recovery + +The master key (`/tank/inou/master.key`) is **critical**. Without it, all encrypted data is permanently unrecoverable. + +**Storage locations:** +1. Production server: `/tank/inou/master.key` +2. Secure backup: Proton Pass (E2E encrypted, separate from data backups) + +**Recovery procedure:** +1. Log into Proton Pass +2. Retrieve the 32-byte master key +3. Create file: `echo -n "" > /tank/inou/master.key` +4. Set permissions: `chmod 600 /tank/inou/master.key` +5. Verify length: `wc -c /tank/inou/master.key` (must be exactly 32 bytes) + +### Key Rotation (If Compromised) + +If the master key may be compromised: + +```bash +# 1. Generate new key +head -c 32 /dev/urandom > /tank/inou/master.key.new + +# 2. Run re-encryption migration (script to be created) +# This decrypts all data with old key and re-encrypts with new key + +# 3. Replace key +mv /tank/inou/master.key.new /tank/inou/master.key + +# 4. Update Proton Pass with new key + +# 5. Verify application functionality +make test +``` + +--- + +## 7. Recovery Procedures + +### Pre-Recovery Checklist + +- [ ] Incident documented and severity assessed +- [ ] Stakeholders notified +- [ ] Backup integrity verified +- [ ] Recovery environment prepared +- [ ] Master key accessible + +### Database Restore from ZFS + +```bash +# Stop services +sudo systemctl stop inou-portal inou-api + +# List snapshots +zfs list -t snapshot tank/inou + +# Restore from snapshot +cp /tank/inou/.zfs/snapshot//data/inou.db /tank/inou/data/inou.db + +# Start services +sudo systemctl start inou-portal inou-api + +# Verify +make test +``` + +### Database Restore from Off-site + +```bash +# Stop services +sudo systemctl stop inou-portal inou-api + +# Download from Google Drive +rclone copy gdrive:inou-backup/inou.db /tank/inou/data/ + +# Start services +sudo systemctl start inou-portal inou-api + +# Verify +make test +``` + +--- + +## 8. Communication During Disaster + +| Audience | Method | Message | +|----------|--------|---------| +| Users | Email + status page | "inou is experiencing technical difficulties. We expect to restore service by [time]." | +| Affected users | Direct email | Per incident response plan if data affected | + +--- + +## 9. Testing Schedule + +| Test Type | Frequency | Last Performed | Next Due | +|-----------|-----------|----------------|----------| +| Backup verification | Monthly | January 2026 | February 2026 | +| Database restore (local) | Quarterly | Not yet | Q1 2026 | +| Database restore (off-site) | Quarterly | Not yet | Q1 2026 | +| Full DR drill | Annually | Not yet | Q4 2026 | + +### Backup Verification Procedure + +```bash +# Monthly: Verify local snapshots exist and are readable +zfs list -t snapshot tank/inou +sqlite3 /tank/inou/.zfs/snapshot//data/inou.db "SELECT COUNT(*) FROM dossiers" + +# Monthly: Verify off-site backup exists +rclone ls gdrive:inou-backup/ +``` + +### Restore Test Procedure + +```bash +# Quarterly: Restore to staging and verify + +# 1. Copy from off-site to staging +rclone copy gdrive:inou-backup/inou.db /tmp/restore-test/ + +# 2. Verify database integrity +sqlite3 /tmp/restore-test/inou.db "PRAGMA integrity_check" + +# 3. Verify data is readable (requires master key) +# Test decryption of sample records + +# 4. Document results +# 5. Clean up test files +rm -rf /tmp/restore-test/ +``` + +--- + +## 10. Post-Recovery Checklist + +After any recovery: + +- [ ] All services operational (`make test` passes) +- [ ] Data integrity verified (spot-check records) +- [ ] Logs reviewed for errors +- [ ] Users notified if there was visible outage +- [ ] Incident documented +- [ ] Post-mortem scheduled if significant event +- [ ] This plan updated if gaps discovered + +--- + +## 11. Quick Reference + +### Critical Paths + +| Item | Path | +|------|------| +| Database | /tank/inou/data/inou.db | +| Auth database | /tank/inou/data/auth.db | +| Master key | /tank/inou/master.key | +| Binaries | /tank/inou/bin/ | +| Logs | /tank/inou/*.log | + +### Service Commands + +```bash +# Status +sudo systemctl status inou-portal inou-api + +# Stop +sudo systemctl stop inou-portal inou-api + +# Start +sudo systemctl start inou-portal inou-api + +# Logs +journalctl -u inou-portal -f +journalctl -u inou-api -f +``` + +### Off-site Backup Commands + +```bash +# List remote backups +rclone ls gdrive:inou-backup/ + +# Download specific file +rclone copy gdrive:inou-backup/inou.db /tank/inou/data/ + +# Upload backup manually +rclone copy /tank/inou/data/inou.db gdrive:inou-backup/ +``` + +--- + +*Document end* diff --git a/docs/soc2/incident-response-plan.md b/docs/soc2/incident-response-plan.md new file mode 100644 index 0000000..ab01fa3 --- /dev/null +++ b/docs/soc2/incident-response-plan.md @@ -0,0 +1,301 @@ +# Incident Response Plan + +**Version:** 1.0 +**Effective:** January 2026 +**Owner:** Johan Jongsma +**Review:** Annually + +--- + +## 1. Purpose + +Define procedures for detecting, responding to, and recovering from security incidents affecting inou systems or user data. + +--- + +## 2. Scope + +All inou systems: +- Production (inou.com / 192.168.100.2) +- Staging (dev.inou.com / 192.168.1.253) +- User data (medical imaging, labs, genome, documents) + +--- + +## 3. Incident Classification + +| Severity | Definition | Response Time | Examples | +|----------|------------|---------------|----------| +| **Critical** | Active breach, data exfiltration, system compromise | Immediate (< 1 hour) | Unauthorized data access, ransomware, credential theft | +| **High** | Potential breach, service outage, vulnerability exploited | < 4 hours | Failed intrusion attempt, DDoS, authentication bypass | +| **Medium** | Suspicious activity, policy violation | < 24 hours | Unusual access patterns, failed login spikes, tarpit triggers | +| **Low** | Minor issue, no data at risk | < 72 hours | Reconnaissance scans, policy clarification needed | + +--- + +## 4. Contact Information + +### Primary Contact + +| Role | Name | Email | Phone | +|------|------|-------|-------| +| Incident Commander | Johan Jongsma | security@inou.com | Signal: +1 727-225-2475 | +| AI Operations | James ⚡ | Via OpenClaw | 24/7 automated | + +### External Contacts + +| Service | Contact | +|---------|---------| +| Legal Counsel | To be established | +| Cyber Insurance | Not currently in place | +| Law Enforcement | Local police non-emergency | + +### Notification Addresses + +| Purpose | Address | +|---------|---------| +| Security incidents | security@inou.com | +| User support | support@inou.com | + +### Alert Flow + +``` +Uptime Kuma (Zurich) → Webhook → OpenClaw Gateway → James AI → Signal (Johan) +Nuclei scan → James AI reviews → Signal alert (if critical/high) +``` + +--- + +## 5. Detection + +### Automated Detection + +- **Uptime Kuma (Zurich):** 24/7 availability monitoring, 60-second intervals +- **Nuclei scans:** Weekly + monthly vulnerability scanning from Zurich +- **404 monitoring:** Alerts on suspicious path probes +- **Tarpit triggers:** Logs known attack patterns (PHP probes, config access attempts) +- **Rate limiting:** Flags excessive requests per IP +- **Log analysis:** HTTP access logs reviewed for anomalies +- **James AI:** Receives alerts, triages, escalates critical issues + +### Manual Detection + +- User reports of unauthorized access +- Unexpected system behavior +- External notification (security researcher, vendor) +- James AI analysis of scan results + +### Indicators of Compromise + +- Unexpected admin access or privilege escalation +- Unusual database queries or data exports +- New or modified files outside deployment +- Outbound connections to unknown hosts +- Authentication anomalies (successful logins from impossible locations) + +--- + +## 6. Response Procedures + +### Phase 1: Identification (0-30 minutes) + +1. **Acknowledge alert** - Confirm incident is real, not false positive +2. **Classify severity** - Use classification table above +3. **Document** - Start incident log with timestamp, initial observations +4. **Assess scope** - What systems/data potentially affected + +### Phase 2: Containment (30 min - 2 hours) + +**Immediate containment:** + +```bash +# Block suspicious IP +sudo ufw deny from + +# If compromise confirmed, isolate server +# Production: Consider taking offline + +# Preserve logs before any changes +cp /tank/inou/*.log /tank/inou/incident-$(date +%Y%m%d)/ +``` + +**Short-term containment:** +- Preserve evidence (copy logs before rotation) +- Identify scope (what systems/data affected) +- Prevent lateral movement + +### Phase 3: Eradication (2-24 hours) + +1. **Identify root cause** - How did attacker get in? +2. **Remove threat** - Malware, backdoors, unauthorized accounts +3. **Patch vulnerability** - Fix the entry point +4. **Verify clean** - Confirm no persistence mechanisms + +### Phase 4: Recovery (24-72 hours) + +1. **Restore from backup** if needed (see [Disaster Recovery Plan](disaster-recovery-plan.md)) +2. **Verify integrity** - Check data hasn't been modified +3. **Monitor closely** - Watch for re-compromise +4. **Gradual restoration** - Bring services back incrementally + +### Phase 5: Lessons Learned (1-2 weeks post-incident) + +1. **Post-mortem** - What happened, timeline, decisions made +2. **Update documentation** - Improve detection/response +3. **Implement improvements** - Technical and procedural changes +4. **Final report** - Document for compliance records + +--- + +## 7. Communication + +### Internal Communication + +- Document all decisions with timestamps +- Keep incident log updated +- Use secure communication channels + +### External Communication + +**To affected users (if data breach confirmed):** +- Notify within 72 hours (GDPR requirement) +- Include: What happened, what data affected, what we're doing, what they should do +- Template in Appendix A + +**To regulators (if required):** +- GDPR: Supervisory authority within 72 hours +- HIPAA: HHS within 60 days (if >500 individuals affected) + +--- + +## 8. Evidence Preservation + +**Preserve immediately:** +- System logs (`/var/log/`, application logs) +- Database state (ZFS snapshot) +- Network traffic captures (if available) +- Screenshots of anomalies + +**Chain of custody:** +- Document who accessed evidence and when +- Store copies in secure, separate location +- Hash files to prove integrity: `sha256sum ` + +--- + +## 9. Specific Scenarios + +### Scenario: Unauthorized Data Access + +1. Identify which dossier(s) accessed +2. Check audit logs for access scope +3. Determine if data was exfiltrated +4. Notify affected users within 72 hours +5. Document for compliance + +### Scenario: Ransomware + +1. **Immediately isolate** affected systems (network disconnect) +2. Do NOT pay ransom +3. Assess backup integrity (ZFS snapshots + off-site) +4. Restore from clean backup +5. Report to law enforcement + +### Scenario: DDoS Attack + +1. Enable additional rate limiting +2. Block attacking IP ranges via UFW +3. Contact ISP if upstream filtering needed +4. Document attack characteristics + +### Scenario: Vulnerability Disclosure + +1. Acknowledge receipt to researcher within 24 hours +2. Validate the vulnerability +3. Develop and test fix +4. Deploy fix +5. Thank researcher, coordinate disclosure timing + +--- + +## 10. Recovery Time Objectives + +| Scenario | RTO | RPO | +|----------|-----|-----| +| Hardware failure | 4 hours | 24 hours | +| Data corruption | 2 hours | 24 hours | +| Security breach | 24 hours | 0 (no data loss acceptable) | +| Complete site loss | 48 hours | 24 hours | + +--- + +## Appendix A: User Notification Template + +``` +Subject: Security Notice from inou + +Dear [Name], + +We are writing to inform you of a security incident that may have affected +your data stored with inou. + +WHAT HAPPENED +[Brief description of incident and date discovered] + +WHAT INFORMATION WAS INVOLVED +[Types of data potentially affected] + +WHAT WE ARE DOING +[Steps taken to address the incident and prevent recurrence] + +WHAT YOU CAN DO +[Recommended actions for the user] + +FOR MORE INFORMATION +Contact us at security@inou.com if you have questions. + +We sincerely apologize for any concern this may cause. + +Johan Jongsma +Founder, inou +``` + +--- + +## Appendix B: Incident Log Template + +``` +INCIDENT ID: INC-YYYY-MM-DD-001 +SEVERITY: [Critical/High/Medium/Low] +STATUS: [Active/Contained/Resolved] + +TIMELINE +- YYYY-MM-DD HH:MM - Initial detection +- YYYY-MM-DD HH:MM - [Action taken] +- YYYY-MM-DD HH:MM - [Action taken] + +DESCRIPTION +[What happened] + +AFFECTED SYSTEMS +[List systems] + +AFFECTED DATA +[Description of data, number of users if known] + +ROOT CAUSE +[How it happened] + +RESOLUTION +[How it was fixed] + +LESSONS LEARNED +[Improvements identified] + +REPORTED BY: [Name] +RESOLVED BY: [Name] +``` + +--- + +*Document end* diff --git a/docs/soc2/risk-assessment.md b/docs/soc2/risk-assessment.md new file mode 100644 index 0000000..7a19e95 --- /dev/null +++ b/docs/soc2/risk-assessment.md @@ -0,0 +1,200 @@ +# Risk Assessment + +**Version:** 1.0 +**Assessment Date:** January 2026 +**Assessor:** Johan Jongsma +**Next Review:** January 2027 + +--- + +## 1. Purpose + +Identify, assess, and document risks to inou systems and data, and the controls in place to mitigate them. + +--- + +## 2. Scope + +- inou production and staging systems +- User health data (medical imaging, labs, genome) +- Supporting infrastructure and processes + +--- + +## 3. Risk Assessment Methodology + +### Likelihood Scale + +| Rating | Description | Frequency | +|--------|-------------|-----------| +| 1 - Rare | Unlikely to occur | < 1% annually | +| 2 - Unlikely | Could occur | 1-10% annually | +| 3 - Possible | Might occur | 10-50% annually | +| 4 - Likely | Will probably occur | 50-90% annually | +| 5 - Almost Certain | Expected to occur | > 90% annually | + +### Impact Scale + +| Rating | Description | Effect | +|--------|-------------|--------| +| 1 - Negligible | Minimal impact | Minor inconvenience | +| 2 - Minor | Limited impact | Some users affected, quick recovery | +| 3 - Moderate | Significant impact | Service degraded, data at risk | +| 4 - Major | Serious impact | Extended outage, data breach | +| 5 - Catastrophic | Severe impact | Complete data loss, regulatory action | + +### Risk Score + +**Score = Likelihood x Impact** (Range: 1-25) + +| Score | Level | Response | +|-------|-------|----------| +| 1-4 | Low | Accept | +| 5-9 | Medium | Monitor | +| 10-16 | High | Mitigate | +| 17-25 | Critical | Immediate action | + +--- + +## 4. Risk Register + +### 4.1 Security Risks + +| ID | Risk | L | I | Score | Controls | Residual | +|----|------|---|---|-------|----------|----------| +| S1 | Unauthorized data access | 2 | 5 | 10 | RBAC, encryption, token auth, audit logging | Low | +| S2 | Application vulnerability exploited | 2 | 5 | 10 | Parameterized queries, input validation, tarpit | Low | +| S3 | Credential theft/phishing | 2 | 4 | 8 | Passwordless auth, short token expiry | Low | +| S4 | Insider threat | 1 | 5 | 5 | Single operator, automated db access controls | Low | +| S5 | Master key compromise | 1 | 5 | 5 | FDE, file permissions, network isolation, key in Proton Pass | Low | +| S6 | DDoS attack | 3 | 3 | 9 | Rate limiting, tarpit, UFW, Starlink failover | Low | +| S7 | Ransomware | 2 | 5 | 10 | FDE, off-site backups, network isolation | Low | +| S8 | Supply chain attack | 2 | 4 | 8 | Minimal dependencies, Go standard library, FIPS module | Low | + +### 4.2 Availability Risks + +| ID | Risk | L | I | Score | Controls | Residual | +|----|------|---|---|-------|----------|----------| +| A1 | Hardware failure | 3 | 3 | 9 | ZFS RAID-Z2, UPS, generator | Low | +| A2 | Network outage | 2 | 3 | 6 | Fiber + Starlink backup | Low | +| A3 | Power outage | 2 | 2 | 4 | UPS + natural gas generator (11s failover) | Low | +| A4 | Database corruption | 2 | 4 | 8 | Daily snapshots, off-site backups, integrity checks | Low | +| A5 | Site disaster | 1 | 5 | 5 | Off-site backups (Google Drive), key in Proton Pass | Low | + +### 4.3 Compliance Risks + +| ID | Risk | L | I | Score | Controls | Residual | +|----|------|---|---|-------|----------|----------| +| C1 | HIPAA violation | 2 | 5 | 10 | Encryption, access controls, audit logging | Low | +| C2 | GDPR violation | 2 | 4 | 8 | Consent, deletion rights, export, privacy policy | Low | +| C3 | Data request not fulfilled | 2 | 3 | 6 | Export functionality, 30-day response commitment | Low | +| C4 | Breach notification failure | 2 | 4 | 8 | Incident response plan, notification templates | Low | + +### 4.4 Operational Risks + +| ID | Risk | L | I | Score | Controls | Residual | +|----|------|---|---|-------|----------|----------| +| O1 | Key person dependency | 4 | 4 | 16 | Documentation, automated processes | Medium | +| O2 | Configuration error | 2 | 3 | 6 | Staging environment, automated tests, check-db | Low | +| O3 | Backup failure undetected | 2 | 4 | 8 | Monthly verification, monitoring planned | Low | +| O4 | Loss of encryption key | 1 | 5 | 5 | Key in Proton Pass, separate from data backups | Low | + +--- + +## 5. Risk Treatment Plan + +### High Priority + +| Risk ID | Risk | Score | Treatment | Status | +|---------|------|-------|-----------|--------| +| O1 | Key person dependency | 16 | Document all procedures, automate where possible | In progress | + +### Medium Priority (Monitoring) + +| Risk ID | Treatment | Timeline | +|---------|-----------|----------| +| S1 | Continue audit logging implementation | Q1 2026 | +| S7 | Perform restore test to verify backup integrity | Q1 2026 | +| O3 | Implement backup monitoring alerts | Q1 2026 | + +--- + +## 6. Control Summary + +### Preventive Controls + +| Control | Risks Mitigated | +|---------|-----------------| +| AES-256-GCM encryption | S1, S5, S7, C1 | +| Full disk encryption | S1, S4, S5, S7 | +| RBAC at data layer | S1, S4, C1 | +| Parameterized SQL queries | S2 | +| Token expiration (4 hours) | S1, S3 | +| Passwordless authentication | S3 | +| Network isolation (VLAN 10) | S1, S5, S7 | +| Tarpit for attack patterns | S2, S6 | +| UFW default deny | S2, S6 | +| AppArmor enforcement | S2 | +| Automatic security updates | S2, S8 | +| make check-db enforcement | S2, S4, O2 | + +### Detective Controls + +| Control | Risks Addressed | +|---------|-----------------| +| HTTP access logging | S1, S2, S6 | +| 404 monitoring alerts | S2 | +| Fail2ban | S3, S6 | +| Rate limiting | S3, S6 | +| Audit logging | S1, S4, C1 | + +### Corrective Controls + +| Control | Risks Addressed | +|---------|-----------------| +| ZFS snapshots (daily) | A4, S7 | +| Off-site backups (Google Drive) | A5, S7 | +| Incident response plan | S1-S8, C4 | +| Disaster recovery plan | A1-A5 | + +--- + +## 7. Accepted Residual Risk + +The following residual risks are formally accepted: + +| Risk | Level | Rationale | +|------|-------|-----------| +| O1 - Key person dependency | Medium | Mitigated by documentation; acceptable for current scale | +| S4 - Insider threat | Low | Single operator with strong controls | +| S5 - Key compromise | Low | Multiple layers of protection | +| A5 - Site disaster | Low | Off-site backups with separate key storage | + +**Accepted by:** Johan Jongsma +**Date:** January 25, 2026 + +--- + +## 8. Risk Monitoring + +### Ongoing Monitoring + +| Category | Method | Frequency | +|----------|--------|-----------| +| Security | Log review, 404 alerts | Daily | +| Availability | Service health checks | Continuous | +| Backups | Verification script | Monthly | +| Compliance | Policy review | Quarterly | + +### Risk Review Triggers + +Re-assess risks when: +- New features or systems added +- Security incident occurs +- Regulatory changes +- Significant infrastructure changes +- Annually (minimum) + +--- + +*Document end* diff --git a/docs/soc2/scans/2026-01/inou-com-20260131.txt b/docs/soc2/scans/2026-01/inou-com-20260131.txt new file mode 100644 index 0000000..3b86cbb --- /dev/null +++ b/docs/soc2/scans/2026-01/inou-com-20260131.txt @@ -0,0 +1,34 @@ +[missing-sri] [http] [info] https://inou.com ["https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap"] +[tls-version] [ssl] [info] inou.com:443 ["tls12"] +[tls-version] [ssl] [info] inou.com:443 ["tls13"] +[http-missing-security-headers:strict-transport-security] [http] [info] https://inou.com +[http-missing-security-headers:x-frame-options] [http] [info] https://inou.com +[http-missing-security-headers:referrer-policy] [http] [info] https://inou.com +[http-missing-security-headers:clear-site-data] [http] [info] https://inou.com +[http-missing-security-headers:cross-origin-opener-policy] [http] [info] https://inou.com +[http-missing-security-headers:content-security-policy] [http] [info] https://inou.com +[http-missing-security-headers:permissions-policy] [http] [info] https://inou.com +[http-missing-security-headers:x-content-type-options] [http] [info] https://inou.com +[http-missing-security-headers:x-permitted-cross-domain-policies] [http] [info] https://inou.com +[http-missing-security-headers:cross-origin-embedder-policy] [http] [info] https://inou.com +[http-missing-security-headers:cross-origin-resource-policy] [http] [info] https://inou.com +[oauth-authorization-server-exposure] [http] [info] https://inou.com/.well-known/oauth-authorization-server +[rdap-whois:registrationDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2001-06-29T10:49:20Z"] +[rdap-whois:lastChangeDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2025-07-24T06:29:31Z"] +[rdap-whois:expirationDate] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["2026-06-29T10:49:20Z"] +[rdap-whois:nameServers] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["NS3.OPENPROVIDER.EU","NS1.OPENPROVIDER.NL","NS2.OPENPROVIDER.BE"] +[rdap-whois:secureDNS] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["false"] +[rdap-whois:status] [http] [info] https://rdap.verisign.com/com/v1/domain/inou.com ["client transfer prohibited"] +[tech-detect:caddy] [http] [info] https://inou.com +[tech-detect:google-font-api] [http] [info] https://inou.com +[robots-txt] [http] [info] https://inou.com/robots.txt +[robots-txt-endpoint:endpoints] [http] [info] https://inou.com/robots.txt ["/dashboard","/onboard","/verify","/start","/set-lang","/api/","/connect","/share","/invite","/login","/privacy-policy","/dossier"] +[spf-record-detect] [dns] [info] inou.com ["v=spf1 include:_spf.protonmail.ch mx ~all""] +[txt-fingerprint] [dns] [info] inou.com [""v=spf1 include:_spf.protonmail.ch mx ~all"",""protonmail-verification=da8cc10ce04b8fdf2ac85303e3283a537cd30f52"",""google-site-verification=d3PKH4M7jVH88dGfGfsqGM71xsEyvgOspxZPEevGrlc""] +[nameserver-fingerprint] [dns] [info] inou.com ["ns3.openprovider.eu.","ns2.openprovider.be.","ns1.openprovider.nl."] +[mx-fingerprint] [dns] [info] inou.com ["20 mailsec.protonmail.ch.","10 mail.protonmail.ch."] +[mx-service-detector:ProtonMail] [dns] [info] inou.com +[caa-fingerprint] [dns] [info] inou.com +[dmarc-detect] [dns] [info] _dmarc.inou.com [""v=DMARC1; p=reject;""] +[ssl-issuer] [ssl] [info] inou.com:443 ["ZeroSSL"] +[ssl-dns-names] [ssl] [info] inou.com:443 ["inou.com"] diff --git a/docs/soc2/scans/2026-01/report.md b/docs/soc2/scans/2026-01/report.md new file mode 100644 index 0000000..385cae7 --- /dev/null +++ b/docs/soc2/scans/2026-01/report.md @@ -0,0 +1,95 @@ +# Vulnerability Scan Report — January 2026 + +**Scan Date:** January 31, 2026 +**Target:** https://inou.com +**Scanner:** Nuclei (ProjectDiscovery) +**Scanner Location:** zurich.inou.com (Zürich, Switzerland) + +--- + +## Executive Summary + +| Severity | Count | +|----------|------:| +| 🔴 Critical | 0 | +| 🟠 High | 0 | +| 🟡 Medium | 0 | +| 🔵 Low | 0 | +| ⚪ Informational | 34 | + +**Result:** No exploitable vulnerabilities detected. All findings are informational. + +--- + +## Findings & Remediation + +### HTTP Security Headers (11 findings) + +| Header | Status | Date | +|--------|:------:|------| +| Strict-Transport-Security | ✅ Remediated | Feb 1, 2026 | +| X-Content-Type-Options | ✅ Remediated | Feb 1, 2026 | +| X-Frame-Options | ✅ Remediated | Feb 1, 2026 | +| Referrer-Policy | ✅ Remediated | Feb 1, 2026 | +| Permissions-Policy | ✅ Remediated | Feb 1, 2026 | +| Cross-Origin-Opener-Policy | ✅ Remediated | Feb 1, 2026 | +| Cross-Origin-Resource-Policy | ✅ Remediated | Feb 1, 2026 | +| X-Permitted-Cross-Domain-Policies | ✅ Remediated | Feb 1, 2026 | +| Content-Security-Policy | ⏸️ Deferred | Requires app tuning | +| Cross-Origin-Embedder-Policy | ⏸️ Skipped | Breaks Google Fonts | +| Clear-Site-Data | ⏸️ N/A | Logout only | + +**Remediation:** Added headers to Caddy reverse proxy (192.168.0.2). + +### TLS/SSL (3 findings) + +| Finding | Status | +|---------|:------:| +| TLS 1.2 supported | ✅ Expected | +| TLS 1.3 supported | ✅ Expected | +| ZeroSSL certificate | ✅ Expected | + +### DNS Configuration (10 findings) + +| Finding | Status | +|---------|:------:| +| SPF configured | ✅ Good | +| DMARC (p=reject) | ✅ Good | +| ProtonMail MX | ✅ Expected | +| DNSSEC not enabled | ⏸️ Low priority | + +### Other Informational (10 findings) + +- Technology detection (Caddy, Google Fonts) — expected +- robots.txt endpoints — expected +- OAuth discovery endpoint — expected +- Domain WHOIS metadata — informational + +--- + +## Actions Taken + +| Date | Action | +|------|--------| +| Jan 31, 2026 | Initial baseline scan from Zurich | +| Feb 1, 2026 | Added 8 HTTP security headers to Caddy | +| Feb 1, 2026 | Verified headers via curl | +| Feb 1, 2026 | Set up automated weekly/monthly scans | + +--- + +## Next Steps + +1. **P2:** Implement Content-Security-Policy (requires app testing) +2. **P3:** Enable DNSSEC via Openprovider +3. **Continue:** Weekly and monthly automated scans + +--- + +## Raw Output + +See: [inou-com-20260131.txt](inou-com-20260131.txt) + +--- + +*Report generated by James ⚡ (AI Operations)* diff --git a/docs/soc2/security-policy.md b/docs/soc2/security-policy.md new file mode 100644 index 0000000..fb1d0c1 --- /dev/null +++ b/docs/soc2/security-policy.md @@ -0,0 +1,326 @@ +# Security Policy + +**Version:** 1.0 +**Effective:** January 2026 +**Owner:** Johan Jongsma +**Review:** Annually + +--- + +## 1. Purpose + +Establish security requirements for inou systems, data, and operations. + +--- + +## 2. Scope + +- All inou systems (production, staging) +- All data processed by inou +- All administrative access + +--- + +## 3. Roles and Responsibilities + +| Role | Responsibilities | +|------|------------------| +| Owner (Johan Jongsma) | Security policy, incident response, system administration, compliance | + +--- + +## 4. Access Control + +### 4.1 Administrative Access + +| System | Method | Requirements | +|--------|--------|--------------| +| Production server | SSH | Ed25519 key, admin subnet only | +| Staging server | SSH | Ed25519 key, admin subnet only | +| Database | Local only | No remote connections | +| Backups (Google Drive) | rclone | Encrypted credentials | +| Master key (Proton Pass) | Web/app | E2E encrypted, 2FA | + +### 4.2 User Authentication + +| Method | Specification | +|--------|---------------| +| Login | Email + 6-digit verification code | +| Code expiry | 10 minutes | +| Session duration | 30 days | +| API tokens | AES-256-GCM encrypted, 4-hour expiry | +| OAuth | Authorization code + PKCE | + +### 4.3 Principle of Least Privilege + +- Users access only their own data by default +- Explicit grants required for shared access +- RBAC enforced at data layer +- API tokens scoped to specific dossiers + +--- + +## 5. Data Protection + +### 5.1 Classification + +| Level | Examples | Protection | +|-------|----------|------------| +| Critical | Medical images, genome data, lab results | Encrypted at rest and transit | +| Confidential | Names, emails, dates of birth | Encrypted at rest and transit | +| Internal | Logs, configs | Access restricted | + +### 5.2 Encryption Standards + +| Layer | Standard | +|-------|----------| +| Disk | Full disk encryption | +| Database fields | AES-256-GCM | +| Transit | TLS 1.3 | +| Tokens | AES-256-GCM | +| Compliance | FIPS 140-3 | + +### 5.3 Key Management + +| Key | Storage | Backup | +|-----|---------|--------| +| Master key | /tank/inou/master.key (chmod 600) | Proton Pass | +| TLS certificates | Caddy auto-managed | Let's Encrypt renewal | +| SSH keys | ~/.ssh/ | Local backup | + +--- + +## 6. Infrastructure Security + +### 6.1 Network Architecture + +| Zone | Network | Purpose | +|------|---------|---------| +| Production | VLAN 10 (192.168.100.0/24) | Isolated production environment | +| Admin | 192.168.1.0/24 | Administrative access | +| DMZ | 192.168.0.0/24 | Reverse proxy (Caddy) | + +### 6.2 Firewall Policy + +**Default:** Deny all incoming + +**Application Server (192.168.100.2):** + +| Port | Source | Purpose | +|------|--------|---------| +| 22/tcp | Admin subnet only | SSH | +| 443/tcp | Any | HTTPS | +| 1080 | Caddy only | Internal proxy | + +### 6.3 OS Hardening + +| Control | Implementation | +|---------|----------------| +| Operating system | Ubuntu 24.04 LTS | +| Updates | Automatic (unattended-upgrades) | +| Firewall | UFW, default deny | +| SSH | Key-only, password disabled | +| MAC | AppArmor enforcing | +| Intrusion prevention | Fail2ban (sshd) | +| Kernel | SYN cookies, RP filter, ASLR | + +--- + +## 7. Application Security + +### 7.1 Secure Development + +| Practice | Implementation | +|----------|----------------| +| SQL injection prevention | Parameterized queries only | +| Input validation | All external input validated | +| Output encoding | Context-appropriate encoding | +| Cryptography | Go standard library, FIPS 140-3 | +| Dependencies | Minimal, reviewed | + +### 7.2 Prohibited Practices + +- Direct database access outside `lib/db_queries.go` +- Hardcoded credentials or keys +- Logging of sensitive data +- Custom cryptography implementations +- Disabled security controls + +### 7.3 Deployment Security + +| Control | Implementation | +|---------|----------------| +| Pre-deploy validation | `make check-db` (mandatory) | +| Testing | 18 integration tests | +| Staging | Required before production | +| Rollback | ZFS snapshots available | + +--- + +## 8. Physical Security + +### 8.1 Facility + +| Control | Implementation | +|---------|----------------| +| Location | Private secure facility | +| Access control | Alarm system with monitoring | +| Power | UPS + natural gas generator | +| Connectivity | Fiber + Starlink backup | + +### 8.2 Server Security + +| Control | Implementation | +|---------|----------------| +| Disk encryption | Full disk encryption | +| Physical access | Owner only | +| Console | Headless, no KVM | + +### 8.3 Media Disposal + +Failed or decommissioned storage media is physically destroyed. + +--- + +## 9. Incident Response + +See: [Incident Response Plan](incident-response-plan.md) + +**Contact:** security@inou.com + +### Severity Classification + +| Severity | Response Time | +|----------|---------------| +| Critical | < 1 hour | +| High | < 4 hours | +| Medium | < 24 hours | +| Low | < 72 hours | + +--- + +## 10. Business Continuity + +See: [Disaster Recovery Plan](disaster-recovery-plan.md) + +| Metric | Target | +|--------|--------| +| RTO | 4 hours | +| RPO | 24 hours | +| SLA | 99.9% (excluding maintenance) | + +--- + +## 11. Compliance + +### Regulatory Framework + +| Regulation | Applicability | +|------------|---------------| +| HIPAA | US health data | +| GDPR | EU residents | +| FADP | Swiss residents | +| CCPA | California residents | + +### Audit Requirements + +- Maintain audit logs for 7 years +- Annual security review +- Document all security incidents + +--- + +## 12. Third-Party Services + +| Vendor | Service | Data Exposure | Controls | +|--------|---------|---------------|----------| +| Proton | SMTP | Verification codes only | E2E encryption | +| Google | Backup storage | Encrypted blobs | Pre-encryption | +| Openprovider | DNS | None | N/A | + +### LLM Integration (Anthropic Claude) + +- User-initiated queries only +- Data flows from inou to user's AI session +- No PHI stored by Anthropic +- No BAA required (conduit model) + +--- + +## 13. Monitoring and Logging + +### Logged Events + +| Event | Retention | +|-------|-----------| +| HTTP requests | 90 days | +| Authentication | 90 days | +| Data access | 7 years | +| Security events | 7 years | + +### Alerting + +| Event | Alert Method | +|-------|--------------| +| Suspicious 404s | System notification | +| Tarpit triggers | Logged | +| Failed logins | Fail2ban action | +| Service outage | Uptime Kuma → James AI → Signal | +| Critical vulnerability | Nuclei → James AI → Signal | + +### External Monitoring (Zurich) + +| Service | Location | Purpose | +|---------|----------|---------| +| Uptime Kuma | zurich.inou.com:3001 | 24/7 availability monitoring | +| Nuclei | zurich.inou.com | Vulnerability scanning | + +--- + +## 13a. Vulnerability Management + +### Scanning Program + +| Schedule | Type | Tool | Action | +|----------|------|------|--------| +| Monthly (1st, 9am ET) | Full scan | Nuclei | Report + remediate | +| Weekly (Sun, 10am ET) | Critical/High/Medium | Nuclei | Alert if found | +| Pre-release | Full scan | Nuclei | Gate deployment | + +### Remediation SLAs + +| Severity | Response | Resolution | +|----------|----------|------------| +| Critical | 4 hours | 24 hours | +| High | 24 hours | 7 days | +| Medium | 7 days | 30 days | +| Low | 30 days | 90 days | + +### Scan Results + +Results stored in: `docs/soc2/scans/YYYY-MM/` + +--- + +## 14. Policy Maintenance + +### Review Schedule + +| Review | Frequency | +|--------|-----------| +| Full policy review | Annually | +| Risk assessment | Annually | +| Incident review | After each incident | +| Control testing | Quarterly | + +### Change Management + +Policy changes require: +1. Risk assessment of change +2. Documentation update +3. Version increment +4. Effective date notation + +--- + +*Document end* diff --git a/docs/soc2/soc2-self-assessment-2026.md b/docs/soc2/soc2-self-assessment-2026.md new file mode 100644 index 0000000..fac7087 --- /dev/null +++ b/docs/soc2/soc2-self-assessment-2026.md @@ -0,0 +1,585 @@ +# SOC 2 Type II Self-Assessment Report + +**Organization:** inou +**Report Period:** January 1, 2026 - Ongoing +**Assessment Date:** January 25, 2026 (Updated February 1, 2026) +**Prepared By:** Johan Jongsma, Founder & Owner +**Report Version:** 1.1 + +--- + +## Executive Summary + +inou is a medical imaging platform with AI-powered health data exploration. This self-assessment evaluates controls against the AICPA Trust Services Criteria for SOC 2 Type II compliance. + +| Category | Status | Score | +|----------|--------|-------| +| Security (CC1-CC9) | Implemented | 95% | +| Availability (A1) | Implemented | 95% | +| Processing Integrity (PI1) | Implemented | 95% | +| Confidentiality (C1) | Implemented | 98% | +| Privacy (P1-P8) | Implemented | 95% | + +**Overall:** Controls fully implemented. Minor action items identified for continuous improvement. + +--- + +## 1. Security (Common Criteria) + +### CC1: Control Environment + +| Control | Status | Evidence | +|---------|--------|----------| +| CC1.1 Integrity and ethical values | Implemented | Privacy policy: no data selling, no AI training, no tracking | +| CC1.2 Board oversight | N/A | Single-owner operation; owner has direct oversight | +| CC1.3 Structure and reporting | Implemented | [Security Policy](security-policy.md) defines roles | +| CC1.4 Commitment to competence | Implemented | Owner: 20+ years enterprise backup/recovery, CTO Backup at Kaseya, founder of IASO Backup (acquired by SolarWinds/N-able) | +| CC1.5 Personnel accountability | Implemented | Automated enforcement via `make check-db`; single admin access | + +### CC2: Communication and Information + +| Control | Status | Evidence | +|---------|--------|----------| +| CC2.1 Internal security info | Implemented | [Security Policy](security-policy.md), `CLAUDE.md` | +| CC2.2 Policy communication | Implemented | Policies in `docs/` directory | +| CC2.3 External communication | Implemented | `/privacy-policy`, `/security`, `/legal/dpa` | + +### CC3: Risk Assessment + +| Control | Status | Evidence | +|---------|--------|----------| +| CC3.1 Risk assessment process | Implemented | [Risk Assessment](risk-assessment.md) | +| CC3.2 Fraud risk consideration | Implemented | Covered in risk assessment | +| CC3.3 Change management risk | Implemented | `make check-db` validates changes | +| CC3.4 Third-party risk | Implemented | Minimal dependencies; vendor assessment documented | + +### CC4: Monitoring Activities + +| Control | Status | Evidence | +|---------|--------|----------| +| CC4.1 Ongoing monitoring | Implemented | HTTP logs, 404 alerts, rate limiting, Uptime Kuma (Zurich), Nuclei scans | +| CC4.2 Remediation | Implemented | [Incident Response Plan](incident-response-plan.md) | + +#### External Monitoring (Added February 2026) + +| Tool | Location | Purpose | Frequency | +|------|----------|---------|-----------| +| Uptime Kuma | zurich.inou.com:3001 | Availability monitoring | Continuous (60s) | +| Nuclei | zurich.inou.com | Vulnerability scanning | Weekly + Monthly | + +**Why Zurich?** External monitoring from Switzerland provides geographic independence and simulates external attacker perspective for vulnerability assessment. + +### CC5: Control Activities + +| Control | Status | Evidence | +|---------|--------|----------| +| CC5.1 Control selection | Implemented | Defense-in-depth architecture | +| CC5.2 Technology controls | Implemented | FIPS 140-3, AES-256-GCM, TLS 1.3 | +| CC5.3 Control deployment | Implemented | Data layer enforcement in `lib/` | + +### CC6: Logical and Physical Access + +| Control | Status | Evidence | +|---------|--------|----------| +| CC6.1 Logical access | Implemented | Token auth, OAuth 2.0 + PKCE, encrypted sessions | +| CC6.2 Authentication | Implemented | Passwordless (email codes), 4-hour token expiry | +| CC6.3 Access removal | Implemented | Automatic token expiration | +| CC6.4 Authorization | Implemented | RBAC with permission inheritance | +| CC6.5 Physical access | Implemented | Private secure facility with alarm, FDE; see [Physical Security](#physical-security) | +| CC6.6 Asset disposal | Implemented | Physical destruction of failed storage media | +| CC6.7 Malware protection | Implemented | OS hardening, AppArmor, auto-updates; see [OS Hardening](#os-hardening) | +| CC6.8 Infrastructure security | Implemented | Isolated VLAN, zone-based firewall, default-deny rules | + +### CC7: System Operations + +| Control | Status | Evidence | +|---------|--------|----------| +| CC7.1 Anomaly detection | Implemented | Tarpit, 404 monitoring, rate limiting, Uptime Kuma alerts | +| CC7.2 Incident monitoring | Implemented | Access logs, alert notifications, Uptime Kuma webhook → James AI | +| CC7.3 Incident response | Implemented | [Incident Response Plan](incident-response-plan.md) | +| CC7.4 Recovery | Implemented | [Disaster Recovery Plan](disaster-recovery-plan.md) | + +#### Vulnerability Scanning Program (Added February 2026) + +| Schedule | Scan Type | Tool | Targets | +|----------|-----------|------|---------| +| Monthly (1st) | Full vulnerability scan | Nuclei | inou.com | +| Weekly (Sunday) | Critical/High/Medium | Nuclei | inou.com | +| Ad-hoc | Pre-release | Nuclei | inou.com, dev.inou.com | + +**Baseline Scan (January 31, 2026):** +- 34 findings, all informational +- No critical, high, or medium vulnerabilities +- 11 missing HTTP security headers → 8 remediated (February 1, 2026) + +#### AI Operations Assistant (Added February 2026) + +James (AI assistant via OpenClaw) provides 24/7 operational support: +- Receives Uptime Kuma alerts via webhook +- Runs and reviews vulnerability scans +- Applies security remediations +- Escalates to owner via Signal for critical issues + +### CC8: Change Management + +| Control | Status | Evidence | +|---------|--------|----------| +| CC8.1 Change process | Implemented | Staging environment, `make deploy` workflow | +| CC8.2 Pre-deployment testing | Implemented | 18 integration tests, `make check-db` | +| CC8.3 Emergency changes | Implemented | Documented in IR plan | + +### CC9: Risk Mitigation + +| Control | Status | Evidence | +|---------|--------|----------| +| CC9.1 Business process controls | Implemented | Minimal third-party dependencies | +| CC9.2 Vendor management | Implemented | See [Third-Party Services](#third-party-services) | + +--- + +## 2. Availability + +| Control | Status | Evidence | +|---------|--------|----------| +| A1.1 Availability commitments | Implemented | 99.9% SLA (excluding planned maintenance) | +| A1.2 Capacity planning | Implemented | Single-tenant; monitored via system metrics | +| A1.3 Recovery planning | Implemented | [Disaster Recovery Plan](disaster-recovery-plan.md) | + +### Infrastructure Controls + +| Control | Implementation | +|---------|----------------| +| Power redundancy | UPS + natural gas generator (11-second failover) | +| Storage redundancy | ZFS RAID-Z2 (2-drive fault tolerance) | +| Network redundancy | 1Gbit fiber primary + Starlink satellite backup | +| Backups | Daily ZFS snapshots (30-day retention) + encrypted off-site | +| RTO | 4 hours | +| RPO | 24 hours | + +### Service Level Agreement + +| Metric | Commitment | +|--------|------------| +| Monthly uptime | 99.9% (excluding planned maintenance) | +| Unplanned downtime | Maximum 43 minutes per month | +| Planned maintenance | Excluded; 24-hour advance notice provided | +| Recovery time | 4 hours maximum | +| Data loss tolerance | 24 hours maximum | + +**Exclusions:** +- Scheduled maintenance with advance notice +- Force majeure events +- User device/connectivity issues +- Third-party service failures outside inou control + +--- + +## 3. Processing Integrity + +| Control | Status | Evidence | +|---------|--------|----------| +| PI1.1 Processing objectives | Implemented | API design documentation | +| PI1.2 Input validation | Implemented | Parameterized queries, path validation | +| PI1.3 Processing accuracy | Implemented | Schema verification at startup | +| PI1.4 Output completeness | Implemented | RBAC filtering | +| PI1.5 Error handling | Implemented | Structured error responses, logging | + +### Data Integrity Controls + +| Control | Implementation | +|---------|----------------| +| SQL injection prevention | Parameterized queries (`?` placeholders) | +| Schema enforcement | Runtime validation via reflection | +| Transaction integrity | SQLite ACID compliance | + +--- + +## 4. Confidentiality + +| Control | Status | Evidence | +|---------|--------|----------| +| C1.1 Confidentiality requirements | Implemented | All PII encrypted at rest | +| C1.2 Data classification | Implemented | 27 categories in `lib/types.go` | + +### Encryption Controls + +| Layer | Standard | Implementation | +|-------|----------|----------------| +| Disk | FDE | Full disk encryption on all servers | +| Database | AES-256-GCM | Auto-encryption in `db_queries.go` | +| Transit | TLS 1.3 | All HTTPS connections | +| Tokens | AES-256-GCM | Encrypted with expiration | +| Compliance | FIPS 140-3 | Verified via `make fips-check` | + +### Data Retention + +| Data Type | Retention | Reference | +|-----------|-----------|-----------| +| Active user data | Indefinite | [Data Retention Policy](data-retention-policy.md) | +| Deleted user data | Immediate purge | [Data Retention Policy](data-retention-policy.md) | +| Access logs | 90 days | [Data Retention Policy](data-retention-policy.md) | +| Backups | 30 days local, 90 days off-site | [Data Retention Policy](data-retention-policy.md) | + +--- + +## 5. Privacy + +| Principle | Status | Evidence | +|-----------|--------|----------| +| P1: Notice | Implemented | Privacy policy at `/privacy-policy` | +| P2: Choice/Consent | Implemented | Explicit consent, explicit grants | +| P3: Collection | Implemented | User-uploaded only | +| P4: Use/Retention/Disposal | Implemented | [Data Retention Policy](data-retention-policy.md) | +| P5: Access | Implemented | Self-service data export | +| P6: Third-party disclosure | Implemented | No sharing except legal orders | +| P7: Security | Implemented | FIPS 140-3 encryption | +| P8: Quality | Implemented | Self-service corrections | + +### Privacy Commitments + +| Commitment | Status | +|------------|--------| +| No advertiser sharing | Implemented | +| No AI training use | Implemented | +| No data sales | Implemented | +| No third-party tracking | Implemented | +| 30-day data request response | Implemented | + +### Regulatory Compliance + +| Regulation | Status | Evidence | +|------------|--------|----------| +| HIPAA | Implemented | Encryption, access controls, audit logs | +| GDPR | Implemented | Export, deletion, consent, notification | +| FADP (Swiss) | Implemented | Same as GDPR | +| CCPA | Implemented | Disclosure, deletion, opt-out | + +--- + +## 6. Physical Security + +### Facility Overview + +| Attribute | Description | +|-----------|-------------| +| Type | Private secure facility | +| Access control | Alarm system with monitoring | +| Power | UPS + natural gas generator (11-second failover) | +| Connectivity | 1Gbit fiber + Starlink satellite backup | +| Physical access | Restricted to owner | + +### Server Security + +| Control | Implementation | +|---------|----------------| +| Full disk encryption | All storage encrypted; data unreadable if hardware removed | +| Logical access | SSH key-based only; password authentication disabled | +| Console access | Headless servers; no KVM attached | +| Administrative access | Single administrator (owner) | + +### Asset Disposal + +Failed or decommissioned storage media is physically destroyed, rendering data unrecoverable. This exceeds NIST SP 800-88 requirements for media sanitization. + +--- + +## 7. OS Hardening + +### Application Server (192.168.100.2) + +| Control | Status | +|---------|--------| +| Operating system | Ubuntu 24.04.3 LTS | +| Automatic updates | Enabled (unattended-upgrades, daily) | +| Firewall | UFW active, default deny incoming | +| Intrusion prevention | Fail2ban enabled (sshd jail) | +| SSH hardening | Key-based only, password auth disabled | +| MAC enforcement | AppArmor loaded, 9 profiles enforcing | +| Kernel hardening | SYN cookies, RP filter, ASLR, ICMP restrictions | + +### Firewall Rules (Application Server) + +| Port | Rule | +|------|------| +| 22/tcp | Allow from admin subnet (192.168.1.0/24) only | +| 443/tcp | Allow (HTTPS) | +| 80/tcp | Rate limited | +| 1080 | Allow from reverse proxy (192.168.0.2) only | + +### Reverse Proxy (Caddy) + +| Control | Status | +|---------|--------| +| Operating system | Ubuntu 24.04.3 LTS | +| Automatic updates | Enabled | +| Firewall | UFW active, default deny incoming | +| SSH | Password auth disabled, rate limited | +| TLS | Automatic HTTPS via ZeroSSL, TLS 1.2+ | + +#### HTTP Security Headers (Added February 1, 2026) + +| Header | Value | +|--------|-------| +| Strict-Transport-Security | `max-age=31536000; includeSubDomains; preload` | +| X-Content-Type-Options | `nosniff` | +| X-Frame-Options | `SAMEORIGIN` | +| Referrer-Policy | `strict-origin-when-cross-origin` | +| Permissions-Policy | `geolocation=(), microphone=(), camera=()` | +| Cross-Origin-Opener-Policy | `same-origin-allow-popups` | +| Cross-Origin-Resource-Policy | `same-origin` | +| X-Permitted-Cross-Domain-Policies | `none` | + +### Network Security (UDM-Pro) + +| Control | Implementation | +|---------|----------------| +| VLAN isolation | Production on VLAN 10 (192.168.100.0/24) | +| Firewall | Zone-based, enabled | +| Inter-VLAN traffic | Default deny; explicit allow for admin and proxy only | +| Firmware | UniFi OS with auto-update | + +--- + +## 8. Third-Party Services + +### Service Inventory + +| Vendor | Service | Data Access | Risk | +|--------|---------|-------------|------| +| Proton | SMTP | Verification codes only (6-digit numbers) | Low | +| Openprovider | DNS | None | None | +| Google | Off-site backup storage | Encrypted data only; cannot decrypt | None | + +### Anthropic (Claude API) + +| Attribute | Detail | +|-----------|--------| +| Service | AI assistant via MCP integration | +| Data flow | User-initiated queries; LLM pulls data from inou API | +| Data storage | None; queries processed in real-time | +| BAA required | No; user controls data flow, no PHI stored by vendor | + +**Rationale:** The LLM integration is user-initiated. Users query their own data through their chosen AI tool. inou serves data via authenticated API; no PHI is stored or processed by Anthropic beyond the user's session. + +--- + +## 9. Backup and Recovery + +### Backup Strategy + +| Component | Method | Frequency | Retention | Location | +|-----------|--------|-----------|-----------|----------| +| Database | ZFS snapshot | Daily | 30 days | Local (RAID-Z2) | +| Database | rclone to cloud | Daily | 90 days | Google Drive (encrypted) | +| Images | ZFS snapshot | Daily | 30 days | Local (RAID-Z2) | +| Images | rclone to cloud | Daily | 90 days | Google Drive (encrypted) | +| Master key | Manual | On change | Permanent | Proton Pass (E2E encrypted) | + +### Encryption + +- All data encrypted at rest before backup (AES-256-GCM) +- Off-site backups transmitted encrypted +- Google cannot decrypt backup contents +- Master key stored separately from data backups + +### Recovery Objectives + +| Metric | Target | +|--------|--------| +| RTO (Recovery Time Objective) | 4 hours | +| RPO (Recovery Point Objective) | 24 hours | + +--- + +## 10. Action Items + +### Completed This Assessment (January 2026) + +| Item | Status | +|------|--------| +| Incident Response Plan | Created | +| Disaster Recovery Plan | Created | +| Data Retention Policy | Created | +| Risk Assessment | Created | +| Security Policy | Created | +| Physical security documentation | Documented | +| Vendor assessment | Documented | +| OS hardening documentation | Documented | + +### Completed (February 2026) + +| Item | Status | Date | +|------|--------|------| +| External vulnerability scanning | Nuclei from Zurich, automated | Feb 1, 2026 | +| HTTP security headers | 8 headers added to Caddy | Feb 1, 2026 | +| External availability monitoring | Uptime Kuma from Zurich | Feb 1, 2026 | +| Automated alerting | Webhook → James AI → Signal | Feb 1, 2026 | +| Weekly vulnerability scan | Cron job (Sundays 10am ET) | Feb 1, 2026 | +| Monthly vulnerability scan | Cron job (1st, 9am ET) | Feb 1, 2026 | + +### Recommended Actions + +| Item | Priority | Target Date | +|------|----------|-------------| +| Perform backup restore test | P1 | Q1 2026 | +| Complete audit logging in `lib/v2.go` | P2 | Q1 2026 | +| Implement key rotation procedure | P2 | Q2 2026 | +| Add Content-Security-Policy header | P2 | Q1 2026 | +| Enable DNSSEC on inou.com | P3 | Q2 2026 | +| Evaluate cyber liability insurance | P3 | Q2 2026 | + +--- + +## 11. Evidence Inventory + +### Policy Documents + +| Document | Location | +|----------|----------| +| Privacy Policy | `/privacy-policy` | +| Security Page | `/security` | +| Data Processing Agreement | `/legal/dpa` | +| Incident Response Plan | `docs/incident-response-plan.md` | +| Disaster Recovery Plan | `docs/disaster-recovery-plan.md` | +| Data Retention Policy | `docs/data-retention-policy.md` | +| Risk Assessment | `docs/risk-assessment.md` | +| Security Policy | `docs/security-policy.md` | + +### Technical Evidence + +| Evidence | Source | +|----------|--------| +| Encryption implementation | `lib/crypto.go` | +| FIPS 140-3 compliance | `make fips-check` | +| Access control | `lib/access.go`, `lib/v2.go` | +| Database access controls | `lib/db_queries.go` | +| Automated validation | `make check-db`, `make test` | +| Network isolation | VLAN 10 config, UDM-Pro firewall rules | +| Attack detection | `portal/tarpit.go` | + +--- + +## 12. Testing Summary + +### Automated Testing (Continuous) + +| Test | Frequency | Coverage | +|------|-----------|----------| +| Integration tests | Per deploy | Auth, data access, CRUD | +| DB access check | Per deploy | Blocks unauthorized patterns | +| FIPS validation | Per deploy | Cryptographic compliance | +| Schema verification | Per startup | Table/column integrity | + +### Manual Testing Schedule + +| Test | Frequency | Last Performed | Next Due | +|------|-----------|----------------|----------| +| Backup restore | Quarterly | Not yet | Q1 2026 | +| DR drill | Annually | Not yet | Q4 2026 | +| Access review | Quarterly | January 2026 | April 2026 | +| Penetration test | Annually | Not yet | Q2 2026 | + +### Automated Security Testing + +| Test | Frequency | Last Run | Next Run | +|------|-----------|----------|----------| +| Nuclei full scan | Monthly (1st) | Jan 31, 2026 | Feb 1, 2026 | +| Nuclei light scan | Weekly (Sun) | Feb 1, 2026 | Feb 2, 2026 | +| Uptime monitoring | Continuous | Live | Live | + +--- + +## 13. Conclusion + +### Strengths + +- **Encryption:** FIPS 140-3 compliant, AES-256-GCM at rest and in transit, full disk encryption +- **Access control:** RBAC enforced at data layer, single administrator, SSH key-only +- **Infrastructure:** Isolated VLAN, zone-based firewall, defense-in-depth +- **Automation:** `make check-db` prevents security regressions +- **Privacy:** No third-party tracking, explicit user commitments +- **Expertise:** Owner has 20+ years enterprise data protection experience + +### Assessment Result + +inou demonstrates comprehensive security controls appropriate for handling sensitive medical data. Technical controls meet or exceed SOC 2 requirements. Documentation has been completed as part of this assessment. + +**Recommendation:** Proceed with formal SOC 2 Type II audit when business requirements warrant. + +--- + +## Appendix A: Regulatory Crosswalks + +### HIPAA Technical Safeguards + +| HIPAA Requirement | Control | Status | +|-------------------|---------|--------| +| 164.312(a)(1) Access Control | CC6.1-6.4, RBAC | Implemented | +| 164.312(a)(2)(iv) Encryption | AES-256-GCM, FDE | Implemented | +| 164.312(b) Audit Controls | CC4.1, CC7.1-2 | Implemented | +| 164.312(c)(1) Integrity | PI1.2-1.5 | Implemented | +| 164.312(d) Authentication | CC6.2 | Implemented | +| 164.312(e)(1) Transmission | TLS 1.3 | Implemented | + +### GDPR Article Mapping + +| GDPR Article | Control | Status | +|--------------|---------|--------| +| Art. 5 (Principles) | P1-P8 | Implemented | +| Art. 15 (Access) | P5, Export | Implemented | +| Art. 17 (Erasure) | P4, Deletion | Implemented | +| Art. 32 (Security) | CC5, CC6 | Implemented | +| Art. 33 (Breach notification) | IR Plan | Implemented | + +--- + +## Appendix B: System Description + +### Overview + +inou enables individuals to store, view, and analyze their medical data: +- Medical imaging (DICOM: MRI, CT, X-ray) +- Lab results and vitals +- Genome data +- Medical documents + +### Architecture + +``` +User --> HTTPS (TLS 1.3) --> Caddy Proxy --> Portal/API --> RBAC --> Encrypted SQLite + | + Audit Log +``` + +### Components + +| Component | Technology | Purpose | +|-----------|------------|---------| +| Portal | Go, HTML | Web interface | +| API | Go, REST | External access | +| Viewer | Electron | DICOM viewing | +| Database | SQLite | Encrypted storage | +| Proxy | Caddy | TLS termination, routing | + +### Infrastructure + +| Environment | Address | Network | +|-------------|---------|---------| +| Production | 192.168.100.2 | Isolated VLAN 10 | +| Staging | 192.168.1.253 | Internal network | + +--- + +## Appendix C: Contact Information + +| Purpose | Contact | +|---------|---------| +| Security incidents | security@inou.com | +| General support | support@inou.com | +| Privacy requests | security@inou.com | + +--- + +**Prepared By:** Johan Jongsma, Founder & Owner +**Assessment Date:** January 25, 2026 +**Next Review:** January 2027 diff --git a/docs/store-consolidation-plan.md b/docs/store-consolidation-plan.md new file mode 100644 index 0000000..add04c2 --- /dev/null +++ b/docs/store-consolidation-plan.md @@ -0,0 +1,210 @@ +# Store Consolidation Plan + +Goal: Route all database access through `lib/store.go` to enable RBAC enforcement at a single layer. + +## Current State + +| Location | Raw SQL Ops | Notes | +|----------|-------------|-------| +| lib/data.go | 26 | Core domain functions (Entry, Dossier, Access) | +| lib/store.go | 6 | Generic ORM layer - this becomes the single point | +| lib/prompt.go | 8 | Prompt CRUD | +| lib/db.go | 5 | Helper wrappers | +| lib/v2.go | 1 | Cleanup utility | +| api/api_access.go | 5 | Access control queries | +| api/api_genome.go | 4 | Complex JOINs for variant queries | +| api/api_categories.go | 7 | Schema introspection (PRAGMA) | +| api/api_dossier.go | 4 | Dossier queries | +| portal/main.go | 6 | Rate limiting, sessions | + +**Total: 76 raw SQL operations** + +## Target Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ API / Portal │ +│ (no db.Query, db.Exec - only store.* calls) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ lib/store.go │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ RBAC Check Layer │ │ +│ │ - Check accessor permissions before query │ │ +│ │ - Filter results based on access grants │ │ +│ │ - Cache permissions per accessor-dossier pair │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Data Layer │ │ +│ │ - Auto-encrypt on write │ │ +│ │ - Auto-decrypt on read │ │ +│ │ - Reflection-based struct mapping │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────┐ + │ SQLite │ + └─────────────┘ +``` + +## Migration Phases + +### Phase 1: Consolidate lib/ (Week 1) + +Move all SQL from lib/*.go into store.go patterns. + +**1.1 - lib/data.go Entry functions → store.go** +- [ ] `EntryAdd()` → `store.Insert(entry, accessorID)` +- [ ] `EntryUpdate()` → `store.Update(entry, accessorID)` +- [ ] `EntryDelete()` → `store.Delete(entry, accessorID)` +- [ ] `EntryGet()` → `store.Get(entry, accessorID)` +- [ ] `EntryList()` → `store.List(filter, accessorID)` +- [ ] `EntryAddBatch()` → `store.InsertBatch(entries, accessorID)` + +**1.2 - lib/data.go Dossier functions → store.go** +- [ ] `DossierWrite()` → `store.Insert/Update(dossier, accessorID)` +- [ ] `DossierGet()` → `store.Get(dossier, accessorID)` +- [ ] `DossierGetByEmail()` → `store.GetBy(dossier, "email", accessorID)` +- [ ] `DossierList()` → `store.List(filter, accessorID)` + +**1.3 - lib/data.go Access functions → store.go** +- [ ] `AccessWrite()` → `store.GrantAccess(accessor, target, perms)` +- [ ] `AccessList()` → `store.ListAccess(dossierID)` +- [ ] `AccessRevoke()` → `store.RevokeAccess(accessor, target)` + +**1.4 - lib/prompt.go → store.go** +- [ ] Move prompt CRUD to store patterns +- [ ] Prompts are user-scoped, add accessor checks + +**1.5 - lib/db.go** +- [ ] Keep DBInit/DBClose/DB() +- [ ] Remove DBExec, DBInsert, DBUpdate, DBDelete, DBQuery, DBQueryRow +- [ ] These become internal to store.go only + +### Phase 2: Consolidate api/ (Week 2) + +Replace raw SQL in API handlers with store calls. + +**2.1 - api/api_access.go (5 ops)** +- [ ] SELECT dossier_access → `store.ListAccess()` +- [ ] INSERT dossier_access → `store.GrantAccess()` +- [ ] UPDATE accessed_at → `store.TouchAccess()` +- [ ] DELETE dossier_access → `store.RevokeAccess()` + +**2.2 - api/api_genome.go (4 ops)** +- [ ] Complex JOIN queries need special handling +- [ ] Option A: Add `store.QueryGenome(filter, accessorID)` specialized method +- [ ] Option B: Build query in store, return filtered results +- [ ] Must preserve tier-matching logic + +**2.3 - api/api_categories.go (7 ops)** +- [ ] PRAGMA queries for schema info - keep as utility +- [ ] Category listing → `store.ListCategories()` + +**2.4 - api/api_dossier.go (4 ops)** +- [ ] All should route through `store.Get/Update(dossier, accessorID)` + +### Phase 3: Consolidate portal/ (Week 2) + +**3.1 - portal/main.go (6 ops)** +- [ ] Session table: CREATE IF NOT EXISTS - keep in init +- [ ] Rate limiting: separate concern, could stay separate or move to store +- [ ] Session cleanup: utility function + +**3.2 - Unify db connection** +- [ ] Portal currently opens its own connection +- [ ] Should use lib.DB() like API does +- [ ] Single connection pool for both + +### Phase 4: Add RBAC Layer (Week 3) + +Once all access goes through store.go: + +**4.1 - Permission cache** +```go +type permCache struct { + mu sync.RWMutex + cache map[string]*permEntry // key: "accessor:target" + ttl time.Duration +} + +type permEntry struct { + perms uint8 // rwdm bitmask + expires time.Time +} +``` + +**4.2 - Check functions** +```go +func (s *Store) canRead(accessorID, targetDossierID, entryID string) bool +func (s *Store) canWrite(accessorID, targetDossierID, entryID string) bool +func (s *Store) canDelete(accessorID, targetDossierID, entryID string) bool +func (s *Store) canManage(accessorID, targetDossierID string) bool +``` + +**4.3 - Integrate checks** +- Every store.Get/List filters by accessor permissions +- Every store.Insert/Update checks write permission +- Every store.Delete checks delete permission +- Access grants require manage permission + +## Store API Design + +```go +// Context carries accessor identity +type StoreContext struct { + AccessorID string // who is making the request + BypassRBAC bool // for system operations only +} + +// Main store interface +func Get[T any](ctx StoreContext, id string) (*T, error) +func List[T any](ctx StoreContext, filter Filter) ([]*T, error) +func Insert[T any](ctx StoreContext, item *T) error +func Update[T any](ctx StoreContext, item *T) error +func Delete[T any](ctx StoreContext, id string) error + +// Access-specific +func GrantAccess(ctx StoreContext, grant AccessGrant) error +func RevokeAccess(ctx StoreContext, accessor, target string) error +func ListAccess(ctx StoreContext, dossierID string) ([]AccessGrant, error) +``` + +## Files to Modify + +### High Priority (direct SQL) +1. `lib/data.go` - Refactor 26 functions to use store internally +2. `lib/prompt.go` - Refactor 8 functions +3. `api/api_access.go` - Replace 5 raw queries +4. `api/api_genome.go` - Replace 4 raw queries +5. `api/api_dossier.go` - Replace 4 raw queries + +### Medium Priority +6. `api/api_categories.go` - 7 queries (some are PRAGMA) +7. `portal/main.go` - 6 queries (sessions, rate limiting) +8. `lib/db.go` - Remove public query helpers + +### Low Priority (already clean) +- `api/api_entries.go` - Uses lib functions +- `api/api_v1.go` - Uses lib functions +- `api/api_studies.go` - Uses lib functions +- etc. + +## Migration Strategy + +1. **Don't break existing code** - Keep old function signatures, change internals +2. **Add accessor parameter gradually** - Start with optional, make required later +3. **Test each phase** - Deploy after each phase, verify nothing breaks +4. **RBAC off by default** - Add checks but disable until fully migrated + +## Success Criteria + +- [ ] Zero `db.Query/Exec` calls outside lib/store.go +- [ ] All data access includes accessor identity +- [ ] Permission cache working with TTL +- [ ] RBAC checks enforced at store layer +- [ ] Audit log captures all access attempts diff --git a/docs/update-2026-01-19-portal-cleanup.md b/docs/update-2026-01-19-portal-cleanup.md new file mode 100644 index 0000000..5c942f2 --- /dev/null +++ b/docs/update-2026-01-19-portal-cleanup.md @@ -0,0 +1,120 @@ +# Portal Cleanup Update - January 19, 2026 + +## Summary + +Completed consolidation of portal's database access through lib functions. Removed all direct SQL calls and the duplicate database connection. + +## Changes Made + +### 1. New File: `portal/upload.go` (359 lines) + +Extracted all upload-related code from main.go into a dedicated file: + +- `Upload` struct - display model for uploads +- `UploadData` struct - JSON structure for Entry.Data +- `formatBytes()` - human-readable file sizes +- `getUploads()` - list uploads using `lib.EntryList()` +- `getUploadEntry()` - get single upload using `lib.EntryGet()` +- `findUploadByFilename()` - find existing uploads using `lib.EntryList()` +- `handleUploadPage()` - upload page handler +- `handleUploadPost()` - upload POST handler +- `handleDeleteFile()` - file deletion handler +- `handleUpdateFile()` - file update handler + +**Key improvements:** +- All SQL replaced with `lib.Entry*` functions +- `uuid.New().String()` replaced with `lib.NewID()` (Hex16 format) +- Proper JSON marshaling for upload metadata + +### 2. Modified: `portal/main.go` + +**Removed:** +- `db *sql.DB` variable declaration (line 29) +- `db, err = sql.Open(...)` from `initDB()` (line 125) +- `import "github.com/google/uuid"` +- `import "encoding/json"` (unused after extraction) +- `import "io"` (unused after extraction) +- `func generateUUID()` (dead code) +- `type Upload struct` (moved to upload.go) +- `func formatBytes()` (moved to upload.go) +- `func getUploads()` (moved to upload.go) +- `func getUploadEntry()` (moved to upload.go) +- `func handleUploadPage()` (moved to upload.go) +- `func handleUploadPost()` (moved to upload.go) +- `func handleDeleteFile()` (moved to upload.go) +- `func handleUpdateFile()` (moved to upload.go) + +**Changed:** +- `hasGenome` check: Direct SQL → `lib.EntryList()` with filter +- Language update: `lib.DB().Exec()` → `lib.DossierWrite()` + +**Result:** main.go reduced from ~1540 lines to ~1290 lines + +### 3. Modified: `portal/genome.go` + +**Removed:** +- Buggy cleanup code that queried integer `category` column as string +- Direct `db.Query()` and `db.Exec()` calls + +**Changed:** +- `db.Begin()` → `lib.DB().Begin()` (transactions for batch inserts) + +**Note:** Genome import still uses transactions for bulk inserts (performance). This is acceptable as it goes through `lib.DB()`. + +## Database Connection Architecture + +**Before:** +``` +portal/main.go: db *sql.DB ──┐ + ├──► same database file +lib/db.go: db *sql.DB ──┘ +``` + +**After:** +``` +lib/db.go: db *sql.DB ──► single connection +portal/*: lib.DB() ──► uses lib's connection +portal/main.go: rateDB ──► separate rate-limit database (unchanged) +``` + +## Remaining lib.DB() Usage + +Only 2 locations in portal still use `lib.DB()` directly: + +| File | Line | Purpose | +|------|------|---------| +| genome.go | 263 | `tx.Begin()` for batch insert | +| genome.go | 292 | `tx.Begin()` for batch insert (continuation) | + +These are performance-critical bulk operations using transactions. Acceptable. + +## File Naming Change + +**Before:** Uploaded files stored with UUID names like: +``` +uploads/a1b2c3d4e5f67890/550e8400-e29b-41d4-a716-446655440000 +``` + +**After:** Uploaded files stored with Hex16 names like: +``` +uploads/a1b2c3d4e5f67890/f1e2d3c4b5a69078 +``` + +Existing files with UUID names will continue to work (paths stored in entry data). + +## Verification + +- ✅ `go build ./portal` - passes +- ✅ `go build ./api` - passes +- ✅ No direct `db.Query/Exec/QueryRow` in portal/*.go +- ✅ All entry operations use `lib.Entry*` functions +- ✅ Single database connection via `lib.DB()` + +## Next Steps + +Per the store consolidation plan (`docs/store-consolidation-plan.md`): + +1. **Phase 2:** Consolidate remaining direct SQL in `api/` (21 operations in 4 files) +2. **Phase 4:** Add RBAC layer to lib/store.go + +The portal is now clean and ready for RBAC enforcement. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..58921ff --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module inou + +go 1.23.5 + +require ( + github.com/mattn/go-sqlite3 v1.14.33 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b18ec91 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/inou.db b/inou.db new file mode 100644 index 0000000..788fb97 Binary files /dev/null and b/inou.db differ diff --git a/inou.mcpb b/inou.mcpb new file mode 100644 index 0000000..8a1397e Binary files /dev/null and b/inou.mcpb differ diff --git a/lang/da.yaml b/lang/da.yaml new file mode 100644 index 0000000..aa22b64 --- /dev/null +++ b/lang/da.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Dine sundhedsdata." +headline_2: "Din AI." +headline_3: "Dine svar." +intro: "Upload billeddiagnostik, laboratorieresultater og mere. Forbind din AI for at hjælpe dig med at forstå, hvad du ser på." +email: "E-mail" +get_started: "Kom i gang" +data_yours: "Dine data forbliver dine" +never_training: "Bruges aldrig til træning" +never_training_desc: "Dine billeder bruges aldrig til at træne AI-modeller." +never_shared: "Deles aldrig" +never_shared_desc: "Vi deler aldrig dine data med nogen." +encrypted: "Krypteret lagring" +encrypted_desc: "Alle data krypteret i hvile." +delete: "Slet når som helst" +delete_desc: "Dine data, din kontrol." + +# Verify +check_email: "Tjek din e-mail" +code_sent_to: "Vi har sendt en 6-cifret kode til" +verification_code: "Bekræftelseskode" +verify: "Bekræft" +use_different_email: "Brug en anden e-mail" +invalid_code: "Ugyldig eller udløbet kode. Prøv igen." + +# Onboard +create_dossier: "Opret din dosje" +create_profile_intro: "Fortæl os om dig selv for at komme i gang." +name: "Navn" +name_placeholder: "Dit navn" +date_of_birth: "Fødselsdato" +sex_at_birth: "Køn ved fødslen" +female: "Kvinde" +male: "Mand" +create_my_dossier: "Opret min dosje" + +# Minor error +must_be_18: "Du skal være 18 for at oprette en konto" +minor_explanation: "Hvis du opretter dette for en anden, start med din egen profil først. Dette sikrer, at kun du kan få adgang til deres sundhedsdata." +minor_next_steps: "Efter at have oprettet din dosje kan du tilføje andre." +use_different_dob: "Brug en anden fødselsdato" + +# Minor login block +minor_login_blocked: "Du skal være 18 for at logge ind" +minor_ask_guardian: "Bed %s om adgang til din dosje." +minor_ask_guardian_generic: "Bed en forælder eller værge om adgang til din dosje." + +# Dashboard +dossiers: "Dosjer" +dossiers_intro: "Administrer sundhedsdata for dig selv eller andre" +you: "dig" +view: "Vis" +save: "Gem" +cancel: "Annuller" +add_dossier: "Tilføj dosje" +edit_dossier: "Rediger dosje" +care: "pleje" +logout: "Log ud" + +# Profile detail +back_to_dossiers: "Tilbage til dosjer" +born: "Født" +no_access_yet: "Kun du har adgang." +people_with_access: "Personer med adgang" +share_access: "Del adgang" +can_edit: "kan tilføje data" +remove: "Fjern" +confirm_revoke: "Fjern adgang?" + +# Dossier sections +section_imaging: "Billeddiagnostik" +section_labs: "Lab" +section_uploads: "Uploads" +section_vitals: "Vitale tegn" +section_medications: "Medicin" +section_records: "Journaler" +section_journal: "Dagbog" +section_genetics: "Genetik" +section_privacy: "Privatliv" + +# Section summaries +imaging_summary: "%d undersøgelser · %d snit" +no_imaging: "Ingen billeddata" +no_lab_data: "Ingen labdata" +no_genetics: "Ingen genetiske data" +no_files: "Ingen filer" +no_upload_access: "You don't have permission to upload" +files_summary: "%d filer (%s)" +series_count: "%d serier" +vitals_desc: "Blodtryk, puls, SpO₂, vægt, blodsukker" +medications_desc: "Recepter og kosttilskud" +records_desc: "Kliniske noter og journaler" +journal_desc: "Symptomer, smerte og observationer" + +# Buttons and actions +open_viewer: "Åbn visning" +manage: "Administrer" +show_all_studies: "Vis alle %d undersøgelser..." +coming_soon: "Kommer snart" + +# Upload page +upload_files: "Upload sundhedsdata" +upload_files_intro: "Upload medicinsk billeddiagnostik, laboratorieresultater, genomfiler eller sundhedsrelaterede dokumenter." +upload_hint_broad: "DICOM, PDF, CSV, VCF og mere" +uploading: "Uploader..." +files_uploaded: "filer uploadet" +upload_scans: "Upload scanninger" +upload_scans_intro: "Upload en mappe med DICOM-filer fra din billedundersøgelse." +upload_drop: "Klik eller træk en mappe hertil" +upload_hint: "Kun DICOM-mapper" + +# Add profile +add_dossier_intro: "Tilføj nogen, hvis sundhedsdata du vil administrere." +email_optional: "E-mail (valgfrit)" +email_optional_hint: "Hvis de er 18, kan de logge ind selv" +your_relation: "Dit forhold til dem" +select_relation: "Vælg..." +i_provide_care: "Jeg yder pleje til denne person" +i_am_their: "Jeg er deres..." + +# Share access +share_access_intro: "Inviter nogen til at få adgang" +their_relation: "Deres forhold til denne person" +can_add_data: "Kan tilføje data (kosttilskud, noter, osv.)" +send_invitation: "Send invitation" +back_to_dossier: "Tilbage til dosje" + +# Relations +my_role: "min rolle" +role: "role" + +# Invitation email +invite_email_subject: "%s tilføjede dig til inou" +invite_email_body: "%s tilføjede din sundhedsdosje til inou, så du kan se og administrere dine medicinske data." +invite_email_cta: "Log ind for at se" +continue: "Fortsæt" + +# Access management +people_with_access_count: "personer med adgang" +view_audit_log: "Vis aktivitetslog" +export_data: "Download my data" +relation_with: "Forhold til" +audit_log: "Aktivitetslog" +audit_log_intro: "Aktivitetshistorik for" +audit_log_desc: "Spor hvem der har haft adgang til eller ændret denne dosje" + +# Install / Connect +install_title: "Forbind til Claude" +install_intro: "Opsæt inou-broen for at lade Claude analysere dine sundhedsdata" +install_step1: "Trin 1: Download" +install_step1_desc: "Hent broen til din platform" +install_download_intro: "Download inou-broen til dit operativsystem:" +install_step2: "Trin 2: Konfigurer" +install_step2_desc: "Tilføj til Claude Desktop-konfigurationen" +install_config_intro: "Tilføj dette til din Claude Desktop-konfigurationsfil:" +install_step3: "Trin 3: Test" +install_step3_desc: "Bekræft forbindelsen" +install_test_intro: "Genstart Claude Desktop og spørg: 'Vis mig mine inou-profiler'" +nav_install: "Forbind til Claude" +nav_home: "Hjem" + +# Status +pending: "afventer" +rate_limit_exceeded: "For mange tilmeldingsforsøg fra din placering. Prøv igen i morgen." + +# Sex display +sex_male: "mand" +sex_female: "kvinde" +sex_na: "andet" + +# Friend invite email +friend_invite_subject: "Tjek dette ud — %s" +friend_invite_p1: "Jeg bruger inou, den sikre måde at opbevare sundhedsdata og udforske dem med AI. Det holder al min families sundhedsinformation ét sted — billedstudier, laboratorieresultater, journaler — og jeg tænkte, det måske også kunne være nyttigt for dig." +friend_invite_p2: "Den virkelige styrke ligger i at kunne bruge AI til at forstå det hele: forstå hvad en rapport faktisk betyder, opdage tendenser over tid, eller bare stille spørgsmål på almindeligt dansk og få klare svar." +friend_invite_btn: "Opdag inou" +friend_invite_dear: "Hej %s," +rel_0: "du" +rel_1: "Forælder" +rel_2: "Barn" +rel_3: "Ægtefælle" +rel_4: "Søskende" +rel_5: "Værge" +rel_6: "Omsorgsgiver" +rel_7: "Coach" +rel_8: "Læge" +rel_9: "Ven" +rel_10: "Andet" +rel_99: "Demo" +select_relation: "Vælg relation..." + +# Kategorier +category000: Billeddiagnostik +category001: Dokument +category002: Laboratorieresultat +category003: Genom +category004: Upload +category005: Konsultation +category006: Diagnose +category007: Billedresultat +category008: EEG-resultat +category009: Vitalværdi +category010: Motion +category011: Medicin +category012: Tilskud +category013: Ernæring +category014: Fertilitet +category015: Symptom +category016: Note +category017: Sygehistorie +category018: Familiehistorie +category019: Kirurgi +category020: Hospitalsindlæggelse +category021: Fødselsdata +category022: Medicinsk udstyr +category023: Terapi +category024: Vurdering +category025: Sundhedsudbyder +category026: Spørgsmål + +# Genome +genome_english_only: "Al genetisk information er på engelsk. Brug Claude til at diskutere det på dansk." +genome_variants: "varianter" +genome_hidden: "skjulte" +genome_show_all_categories: "Vis alle %d kategorier" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/de.yaml b/lang/de.yaml new file mode 100644 index 0000000..aa9a447 --- /dev/null +++ b/lang/de.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Ihre Gesundheitsdaten." +headline_2: "Ihre KI." +headline_3: "Ihre Antworten." +intro: "Laden Sie Bildgebung, Laborergebnisse und mehr hoch. Verbinden Sie Ihre KI, um zu verstehen, was Sie sehen." +email: "E-Mail" +get_started: "Loslegen" +data_yours: "Ihre Daten bleiben Ihre" +never_training: "Nie für Training verwendet" +never_training_desc: "Ihre Bilder werden nie zum Trainieren von KI-Modellen verwendet." +never_shared: "Nie geteilt" +never_shared_desc: "Wir teilen Ihre Daten mit niemandem." +encrypted: "Verschlüsselte Speicherung" +encrypted_desc: "Alle Daten werden verschlüsselt gespeichert." +delete: "Jederzeit löschen" +delete_desc: "Ihre Daten, Ihre Kontrolle." + +# Verify +check_email: "Überprüfen Sie Ihre E-Mail" +code_sent_to: "Wir haben einen 6-stelligen Code gesendet an" +verification_code: "Bestätigungscode" +verify: "Bestätigen" +use_different_email: "Andere E-Mail verwenden" +invalid_code: "Ungültiger oder abgelaufener Code. Bitte versuchen Sie es erneut." + +# Onboard +create_dossier: "Erstellen Sie Ihr Dossier" +create_profile_intro: "Erzählen Sie uns von sich, um loszulegen." +name: "Name" +name_placeholder: "Ihr Name" +date_of_birth: "Geburtsdatum" +sex_at_birth: "Geschlecht bei Geburt" +female: "Weiblich" +male: "Männlich" +create_my_dossier: "Mein Dossier erstellen" + +# Minor error +must_be_18: "Sie müssen 18 sein, um ein Konto zu erstellen" +minor_explanation: "Wenn Sie dies für jemand anderen einrichten, beginnen Sie zuerst mit Ihrem eigenen Profil. So stellen Sie sicher, dass nur Sie auf deren Gesundheitsdaten zugreifen können." +minor_next_steps: "Nach der Erstellung Ihres Dossiers können Sie weitere hinzufügen." +use_different_dob: "Anderes Geburtsdatum verwenden" + +# Minor login block +minor_login_blocked: "Sie müssen 18 sein, um sich anzumelden" +minor_ask_guardian: "Bitten Sie %s, auf Ihr Dossier zuzugreifen." +minor_ask_guardian_generic: "Bitten Sie einen Elternteil oder Vormund, auf Ihr Dossier zuzugreifen." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Verwalten Sie Gesundheitsdaten für sich selbst oder andere" +you: "Sie" +view: "Ansehen" +save: "Speichern" +cancel: "Abbrechen" +add_dossier: "Dossier hinzufügen" +edit_dossier: "Dossier bearbeiten" +care: "Pflege" +logout: "Abmelden" + +# Profile detail +back_to_dossiers: "Zurück zu Dossiers" +born: "Geboren" +no_access_yet: "Nur Sie haben Zugriff." +people_with_access: "Personen mit Zugriff" +share_access: "Zugriff teilen" +can_edit: "kann Daten hinzufügen" +remove: "Entfernen" +confirm_revoke: "Zugriff entfernen?" + +# Dossier sections +section_imaging: "Bildgebung" +section_labs: "Labor" +section_uploads: "Uploads" +section_vitals: "Vitalwerte" +section_medications: "Medikamente" +section_records: "Unterlagen" +section_journal: "Tagebuch" +section_genetics: "Genetik" +section_privacy: "Datenschutz" + +# Section summaries +imaging_summary: "%d Studien · %d Schichten" +no_imaging: "Keine Bildgebungsdaten" +no_lab_data: "Keine Labordaten" +no_genetics: "Keine genetischen Daten" +no_files: "Keine Dateien" +no_upload_access: "You don't have permission to upload" +files_summary: "%d Dateien (%s)" +series_count: "%d Serien" +vitals_desc: "Blutdruck, Herzfrequenz, SpO₂, Gewicht, Glukose" +medications_desc: "Rezepte und Nahrungsergänzungsmittel" +records_desc: "Klinische Notizen und Krankenakten" +journal_desc: "Symptome, Schmerzen und Beobachtungen" + +# Buttons and actions +open_viewer: "Viewer öffnen" +manage: "Verwalten" +show_all_studies: "Alle %d Studien anzeigen..." +coming_soon: "Demnächst" + +# Upload page +upload_files: "Gesundheitsdaten hochladen" +upload_files_intro: "Laden Sie medizinische Bildgebung, Laborergebnisse, Genomdateien oder andere gesundheitsbezogene Dokumente hoch." +upload_hint_broad: "DICOM, PDF, CSV, VCF und mehr" +uploading: "Wird hochgeladen..." +files_uploaded: "Dateien hochgeladen" +upload_scans: "Scans hochladen" +upload_scans_intro: "Laden Sie einen Ordner mit DICOM-Dateien aus Ihrer Bildgebungsstudie hoch." +upload_drop: "Klicken oder Ordner hierher ziehen" +upload_hint: "Nur DICOM-Ordner" + +# Add profile +add_dossier_intro: "Fügen Sie jemanden hinzu, dessen Gesundheitsdaten Sie verwalten möchten." +email_optional: "E-Mail (optional)" +email_optional_hint: "Wenn sie 18 sind, können sie sich selbst anmelden" +your_relation: "Ihre Beziehung zu dieser Person" +select_relation: "Auswählen..." +i_provide_care: "Ich pflege diese Person" +i_am_their: "Ich bin deren..." + +# Share access +share_access_intro: "Jemanden zum Zugriff einladen" +their_relation: "Deren Beziehung zu dieser Person" +can_add_data: "Kann Daten hinzufügen (Nahrungsergänzungsmittel, Notizen, usw.)" +send_invitation: "Einladung senden" +back_to_dossier: "Zurück zum Dossier" + +# Relations +my_role: "meine Rolle" +role: "role" + +# Invitation email +invite_email_subject: "%s hat Sie zu inou hinzugefügt" +invite_email_body: "%s hat Ihr Gesundheitsdossier zu inou hinzugefügt, damit Sie Ihre medizinischen Daten einsehen und verwalten können." +invite_email_cta: "Anmelden zum Ansehen" +continue: "Weiter" + +# Access management +people_with_access_count: "Personen mit Zugriff" +view_audit_log: "Aktivitätsprotokoll ansehen" +export_data: "Download my data" +relation_with: "Beziehung zu" +audit_log: "Aktivitätsprotokoll" +audit_log_intro: "Aktivitätsverlauf für" +audit_log_desc: "Verfolgen Sie, wer auf dieses Dossier zugegriffen oder es geändert hat" + +# Install / Connect +install_title: "Mit Claude verbinden" +install_intro: "Richten Sie die inou-Bridge ein, damit Claude Ihre Gesundheitsdaten analysieren kann" +install_step1: "Schritt 1: Herunterladen" +install_step1_desc: "Laden Sie die Bridge für Ihre Plattform herunter" +install_download_intro: "Laden Sie die inou-Bridge für Ihr Betriebssystem herunter:" +install_step2: "Schritt 2: Konfigurieren" +install_step2_desc: "Zur Claude Desktop-Konfiguration hinzufügen" +install_config_intro: "Fügen Sie dies zu Ihrer Claude Desktop-Konfigurationsdatei hinzu:" +install_step3: "Schritt 3: Testen" +install_step3_desc: "Verbindung überprüfen" +install_test_intro: "Starten Sie Claude Desktop neu und fragen Sie: 'Zeige mir meine inou-Profile'" +nav_install: "Mit Claude verbinden" +nav_home: "Startseite" + +# Status +pending: "ausstehend" +rate_limit_exceeded: "Zu viele Anmeldeversuche von Ihrem Standort. Bitte versuchen Sie es morgen erneut." + +# Sex display +sex_male: "männlich" +sex_female: "weiblich" +sex_na: "andere" + +# Friend invite email +friend_invite_subject: "Schau dir das an — %s" +friend_invite_p1: "Ich nutze inou, die sichere Art, Gesundheitsdaten zu speichern und mit KI zu erkunden. Es hält alle Gesundheitsinformationen meiner Familie an einem Ort — Bildgebung, Laborergebnisse, Krankenakten — und ich dachte, es könnte auch für dich nützlich sein." +friend_invite_p2: "Die wahre Stärke liegt darin, KI nutzen zu können, um alles zu verstehen: zu verstehen, was ein Bericht wirklich bedeutet, Trends über die Zeit zu erkennen, oder einfach Fragen in normaler Sprache zu stellen und klare Antworten zu bekommen." +friend_invite_btn: "Entdecke inou" +friend_invite_dear: "Liebe/r %s," +rel_0: "du" +rel_1: "Elternteil" +rel_2: "Kind" +rel_3: "Ehepartner" +rel_4: "Geschwister" +rel_5: "Vormund" +rel_6: "Betreuer" +rel_7: "Coach" +rel_8: "Arzt" +rel_9: "Freund" +rel_10: "Andere" +rel_99: "Demo" +select_relation: "Beziehung auswählen..." + +# Kategorien +category000: Bildgebung +category001: Dokument +category002: Laborergebnis +category003: Genom +category004: Upload +category005: Konsultation +category006: Diagnose +category007: Bildgebungsergebnis +category008: EEG-Ergebnis +category009: Vitalwert +category010: Bewegung +category011: Medikament +category012: Nahrungsergänzung +category013: Ernährung +category014: Fruchtbarkeit +category015: Symptom +category016: Notiz +category017: Krankengeschichte +category018: Familienanamnese +category019: Operation +category020: Krankenhausaufenthalt +category021: Geburtsdaten +category022: Medizinisches Gerät +category023: Therapie +category024: Bewertung +category025: Gesundheitsdienstleister +category026: Frage + +# Genome +genome_english_only: "Alle genetischen Informationen sind auf Englisch. Verwenden Sie Claude, um sie auf Deutsch zu besprechen." +genome_variants: "Varianten" +genome_hidden: "verborgen" +genome_show_all_categories: "Alle %d Kategorien anzeigen" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/en.yaml b/lang/en.yaml new file mode 100644 index 0000000..9d5e38a --- /dev/null +++ b/lang/en.yaml @@ -0,0 +1,269 @@ +# Landing +headline_1: "Your health data." +headline_2: "Your AI." +headline_3: "Your answers." +intro: "Upload imaging, labs, and more. Connect your AI to help you understand what you're looking at." +email: "Email" +get_started: "Get started" +data_yours: "Your data stays yours" +never_training: "Never used for training" +never_training_desc: "Your images are never used to train AI models." +never_shared: "Never shared" +never_shared_desc: "We never share your data with anyone." +encrypted: "Military-grade encryption" +encrypted_desc: "At rest and in transit. Your data never travels unprotected." +delete: "Delete anytime" +delete_desc: "Your data, your control." + +# Verify +check_email: "Check your email" +code_sent_to: "We sent a 6-digit code to" +verification_code: "Verification code" +verify: "Verify" +use_different_email: "Use a different email" +invalid_code: "Invalid or expired code. Please try again." + +# Onboard +create_dossier: "Create your dossier" +create_profile_intro: "Tell us about yourself to get started." +name: "Name" +name_placeholder: "Your name" +date_of_birth: "Date of birth" +sex_at_birth: "Sex at birth" +female: "Female" +male: "Male" +create_my_dossier: "Create my dossier" + +# Minor error +must_be_18: "You must be 18 to create an account" +minor_explanation: "If you're setting this up for someone else, start with your own profile first. This ensures only you can access their health data." +minor_next_steps: "After creating your dossier, you can add others." +use_different_dob: "Use a different date of birth" + +# Minor login block +minor_login_blocked: "You must be 18 to log in" +minor_ask_guardian: "Ask %s to access your dossier." +minor_ask_guardian_generic: "Ask a parent or guardian to access your dossier." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Manage health data for yourself or others" +you: "you" +view: "View" +save: "Save" +cancel: "Cancel" +add_dossier: "Add dossier" +edit_dossier: "Edit dossier" +care: "care" +logout: "Sign out" + +# Profile detail +back_to_dossiers: "Back to dossiers" +born: "Born" +no_access_yet: "Only you have access." +people_with_access: "People with access" +share_access: "Share access" +manage_permissions: "Manage permissions" +can_edit: "can add data" +remove: "Remove" +confirm_revoke: "Remove access?" + +# Dossier sections +section_imaging: "Imaging" +section_labs: "Labs" +section_uploads: "Uploads" +section_vitals: "Vitals" +section_medications: "Medications" +section_records: "Records" +section_journal: "Journal" + +# Section summaries +imaging_summary: "%d studies · %d slices" +no_imaging: "No imaging data" +no_lab_data: "No lab data" +no_files: "No files" +no_upload_access: "You don't have permission to upload" +files_summary: "%d files (%s)" +series_count: "%d series" +vitals_desc: "Blood pressure, heart rate, SpO₂, weight, glucose" +medications_desc: "Prescriptions and supplements" +records_desc: "Clinical notes and medical records" +journal_desc: "Symptoms, pain, and observations" + +# Buttons and actions +open_viewer: "Open viewer" +manage: "Manage" +show_all_studies: "Show all %d studies..." +coming_soon: "Coming soon" + +# Upload page +upload_files: "Upload health data" +upload_files_intro: "Upload medical imaging, lab results, genome files, or any health-related documents." +upload_hint_broad: "DICOM, PDF, CSV, VCF, and more" +uploading: "Uploading..." +files_uploaded: "files uploaded" +upload_scans: "Upload scans" +upload_scans_intro: "Upload a folder containing DICOM files from your imaging study." +upload_drop: "Click or drag a folder here" +upload_hint: "DICOM folders only" + +# Add profile +add_dossier_intro: "Add someone whose health data you want to manage." +email_optional: "Email (optional)" +email_optional_hint: "If they're 18+, they can log in themselves" +your_relation: "Your relationship to them" +select_relation: "Select..." +i_provide_care: "I provide care for this person" + +# Share access +share_access_intro: "Invite someone to access" +their_relation: "Their relationship to this person" +can_add_data: "Can add data (supplements, notes, etc.)" +send_invitation: "Send invitation" +back_to_dossier: "Back to dossier" + +# Relations + +# Invitation email +invite_email_subject: "%s added you to inou" +invite_email_body: "%s added your health dossier to inou so you can view and manage your medical data." +invite_email_cta: "Sign in to view" +continue: "Continue" +i_am_their: "I am their..." + +# Simple relation names (for display) +my_role: "my role" +role: "role" +section_privacy: "Privacy" +people_with_access_count: "people with access" +view_audit_log: "View audit log" +export_data: "Download my data" +relation_with: "Relation with" +audit_log: "Audit log" +audit_log_intro: "Activity history for" +audit_log_desc: "Track who accessed or modified this dossier" + +# Permissions (RBAC) +permissions_title: "Permissions" +permissions_subtitle: "Control who can access this dossier and what they can do" +current_access: "Current access" +grant_access: "Grant access" +no_grantees: "No one else has access to this dossier." +person_email: "Email address" +person_email_hint: "If they don't have an account, they'll be invited to create one." +person_name: "Name" +select_role: "Select a role..." +custom_role: "Custom permissions" +permissions: "Permissions" +op_read: "Read" +op_write: "Write" +op_delete: "Delete" +op_manage: "Manage" +grant: "Grant access" +revoke: "Revoke" +role_descriptions: "Role descriptions" +ops_legend: "Permission legend" +op_read_desc: "View data" +op_write_desc: "Add/edit data" +op_delete_desc: "Remove data" +op_manage_desc: "Manage who has access" +permissions_updated: "Permissions updated successfully." +back: "Back" +can_add_data: "Can add data" +install_title: "Connect to Claude" +install_intro: "Set up the inou bridge to let Claude analyze your health data" +install_step1: "Step 1: Download" +install_step1_desc: "Get the bridge for your platform" +install_download_intro: "Download the inou bridge for your operating system:" +install_step2: "Step 2: Configure" +install_step2_desc: "Add to Claude Desktop config" +install_config_intro: "Add this to your Claude Desktop configuration file:" +install_step3: "Step 3: Test" +install_step3_desc: "Verify the connection" +install_test_intro: "Restart Claude Desktop and ask: 'Show me my inou profiles'" +nav_install: "Connect to Claude" +nav_home: "Home" +pending: "pending" +rate_limit_exceeded: "Too many sign-up attempts from your location. Please try again tomorrow." +section_genetics: Genetics +no_genetics: No genetic data + +sex_male: "male" +sex_female: "female" +sex_na: "other" + +# Friend invite email +friend_invite_subject: "Check this out — %s" +friend_invite_p1: "I've been using inou, the secure way to store health data and explore it with AI. It keeps all my family's health information in one place — imaging studies, lab results, medical records — and I thought you might find it useful too." +friend_invite_p2: "The real power is being able to use AI to make sense of it all: understand what a report actually means, spot trends over time, or just ask questions in plain language and get clear answers." +friend_invite_btn: "Check out inou" +friend_invite_dear: "Dear %s," +rel_0: "you" +rel_1: "Parent" +rel_2: "Child" +rel_3: "Spouse" +rel_4: "Sibling" +rel_5: "Guardian" +rel_6: "Caregiver" +rel_7: "Coach" +rel_8: "Doctor" +rel_9: "Friend" +rel_10: "Other" +rel_99: "Demo" +select_relation: "Select relationship..." +audit_dossier_added: "A new dossier for %s created by %s" +audit_dossier_edited: "Dossier %s edited by %s" +audit_access_granted: "Access to %s granted to %s" +audit_dossier_created: Account created by %s +audit_access_revoked: Access for %s to %s revoked +audit_file_upload: File %s uploaded by %s +audit_file_delete: File %s deleted by %s +audit_file_category_change: File %s category changed by %s +audit_genome_import: %s genetic variants imported + +# Categories (category000 = imaging, etc.) +category000: Imaging +category001: Document +category002: Lab result +category003: Genome +category004: Upload +category005: Consultation +category006: Diagnosis +category007: Imaging finding +category008: EEG finding +category009: Vital sign +category010: Exercise +category011: Medication +category012: Supplement +category013: Nutrition +category014: Fertility +category015: Symptom +category016: Note +category017: Medical history +category018: Family history +category019: Surgery +category020: Hospitalization +category021: Birth record +category022: Medical device +category023: Therapy +category024: Assessment +category025: Provider +category026: Question + +# Genome +genome_english_only: "" +genome_variants: "variants" +genome_hidden: "hidden" +genome_show_all_categories: "Show all %d categories" + +# API +api_token: "API Token" +api_token_use: "Use this token to authenticate API requests:" +api_token_warning: "Keep this private. Anyone with this token can access your health data." +api_token_none: "Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/es.yaml b/lang/es.yaml new file mode 100644 index 0000000..f5e0f89 --- /dev/null +++ b/lang/es.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Tus datos de salud." +headline_2: "Tu IA." +headline_3: "Tus respuestas." +intro: "Sube imágenes médicas, análisis y más. Conecta tu IA para ayudarte a entender lo que estás viendo." +email: "Correo electrónico" +get_started: "Comenzar" +data_yours: "Tus datos son tuyos" +never_training: "Nunca usados para entrenamiento" +never_training_desc: "Tus imágenes nunca se usan para entrenar modelos de IA." +never_shared: "Nunca compartidos" +never_shared_desc: "Nunca compartimos tus datos con nadie." +encrypted: "Almacenamiento cifrado" +encrypted_desc: "Todos los datos cifrados en reposo." +delete: "Eliminar en cualquier momento" +delete_desc: "Tus datos, tu control." + +# Verify +check_email: "Revisa tu correo" +code_sent_to: "Enviamos un código de 6 dígitos a" +verification_code: "Código de verificación" +verify: "Verificar" +use_different_email: "Usar otro correo" +invalid_code: "Código inválido o expirado. Por favor, inténtalo de nuevo." + +# Onboard +create_dossier: "Crea tu expediente" +create_profile_intro: "Cuéntanos sobre ti para comenzar." +name: "Nombre" +name_placeholder: "Tu nombre" +date_of_birth: "Fecha de nacimiento" +sex_at_birth: "Sexo al nacer" +female: "Femenino" +male: "Masculino" +create_my_dossier: "Crear mi expediente" + +# Minor error +must_be_18: "Debes tener 18 años para crear una cuenta" +minor_explanation: "Si estás configurando esto para otra persona, comienza primero con tu propio perfil. Esto asegura que solo tú puedas acceder a sus datos de salud." +minor_next_steps: "Después de crear tu expediente, puedes agregar otros." +use_different_dob: "Usar otra fecha de nacimiento" + +# Minor login block +minor_login_blocked: "Debes tener 18 años para iniciar sesión" +minor_ask_guardian: "Pide a %s que acceda a tu expediente." +minor_ask_guardian_generic: "Pide a un padre o tutor que acceda a tu expediente." + +# Dashboard +dossiers: "Expedientes" +dossiers_intro: "Gestiona datos de salud para ti o para otros" +you: "tú" +view: "Ver" +save: "Guardar" +cancel: "Cancelar" +add_dossier: "Agregar expediente" +edit_dossier: "Editar expediente" +care: "cuidado" +logout: "Cerrar sesión" + +# Profile detail +back_to_dossiers: "Volver a expedientes" +born: "Nacido/a" +no_access_yet: "Solo tú tienes acceso." +people_with_access: "Personas con acceso" +share_access: "Compartir acceso" +can_edit: "puede agregar datos" +remove: "Eliminar" +confirm_revoke: "¿Eliminar acceso?" + +# Dossier sections +section_imaging: "Imágenes" +section_labs: "Laboratorio" +section_uploads: "Archivos" +section_vitals: "Signos vitales" +section_medications: "Medicamentos" +section_records: "Registros" +section_journal: "Diario" +section_genetics: "Genética" +section_privacy: "Privacidad" + +# Section summaries +imaging_summary: "%d estudios · %d cortes" +no_imaging: "Sin datos de imágenes" +no_lab_data: "Sin datos de laboratorio" +no_genetics: "Sin datos genéticos" +no_files: "Sin archivos" +no_upload_access: "You don't have permission to upload" +files_summary: "%d archivos (%s)" +series_count: "%d series" +vitals_desc: "Presión arterial, frecuencia cardíaca, SpO₂, peso, glucosa" +medications_desc: "Recetas y suplementos" +records_desc: "Notas clínicas e historiales médicos" +journal_desc: "Síntomas, dolor y observaciones" + +# Buttons and actions +open_viewer: "Abrir visor" +manage: "Gestionar" +show_all_studies: "Mostrar los %d estudios..." +coming_soon: "Próximamente" + +# Upload page +upload_files: "Subir datos de salud" +upload_files_intro: "Sube imágenes médicas, resultados de laboratorio, archivos genómicos o cualquier documento relacionado con la salud." +upload_hint_broad: "DICOM, PDF, CSV, VCF y más" +uploading: "Subiendo..." +files_uploaded: "archivos subidos" +upload_scans: "Subir estudios" +upload_scans_intro: "Sube una carpeta con archivos DICOM de tu estudio de imágenes." +upload_drop: "Haz clic o arrastra una carpeta aquí" +upload_hint: "Solo carpetas DICOM" + +# Add profile +add_dossier_intro: "Agrega a alguien cuyos datos de salud quieras gestionar." +email_optional: "Correo (opcional)" +email_optional_hint: "Si tienen 18, pueden iniciar sesión ellos mismos" +your_relation: "Tu relación con esta persona" +select_relation: "Seleccionar..." +i_provide_care: "Proporciono cuidado a esta persona" +i_am_their: "Soy su..." + +# Share access +share_access_intro: "Invitar a alguien a acceder" +their_relation: "Su relación con esta persona" +can_add_data: "Puede agregar datos (suplementos, notas, etc.)" +send_invitation: "Enviar invitación" +back_to_dossier: "Volver al expediente" + +# Relations +my_role: "mi rol" +role: "role" + +# Invitation email +invite_email_subject: "%s te agregó a inou" +invite_email_body: "%s agregó tu expediente de salud a inou para que puedas ver y gestionar tus datos médicos." +invite_email_cta: "Iniciar sesión para ver" +continue: "Continuar" + +# Access management +people_with_access_count: "personas con acceso" +view_audit_log: "Ver registro de actividad" +export_data: "Download my data" +relation_with: "Relación con" +audit_log: "Registro de actividad" +audit_log_intro: "Historial de actividad para" +audit_log_desc: "Rastrea quién accedió o modificó este expediente" + +# Install / Connect +install_title: "Conectar con Claude" +install_intro: "Configura el puente inou para que Claude analice tus datos de salud" +install_step1: "Paso 1: Descargar" +install_step1_desc: "Obtén el puente para tu plataforma" +install_download_intro: "Descarga el puente inou para tu sistema operativo:" +install_step2: "Paso 2: Configurar" +install_step2_desc: "Agregar a la configuración de Claude Desktop" +install_config_intro: "Agrega esto a tu archivo de configuración de Claude Desktop:" +install_step3: "Paso 3: Probar" +install_step3_desc: "Verificar la conexión" +install_test_intro: "Reinicia Claude Desktop y pregunta: 'Muéstrame mis perfiles de inou'" +nav_install: "Conectar con Claude" +nav_home: "Inicio" + +# Status +pending: "pendiente" +rate_limit_exceeded: "Demasiados intentos de registro desde tu ubicación. Por favor, inténtalo mañana." + +# Sex display +sex_male: "masculino" +sex_female: "femenino" +sex_na: "otro" + +# Friend invite email +friend_invite_subject: "Mira esto — %s" +friend_invite_p1: "Estoy usando inou, la forma segura de guardar datos de salud y explorarlos con IA. Mantiene toda la información de salud de mi familia en un solo lugar — estudios de imagen, resultados de laboratorio, historiales médicos — y pensé que también te podría ser útil." +friend_invite_p2: "El verdadero poder está en poder usar IA para entenderlo todo: comprender qué significa realmente un informe, detectar tendencias a lo largo del tiempo, o simplemente hacer preguntas en lenguaje sencillo y obtener respuestas claras." +friend_invite_btn: "Descubre inou" +friend_invite_dear: "Querido/a %s," +rel_0: "tú" +rel_1: "Padre/Madre" +rel_2: "Hijo/a" +rel_3: "Cónyuge" +rel_4: "Hermano/a" +rel_5: "Tutor" +rel_6: "Cuidador" +rel_7: "Coach" +rel_8: "Médico" +rel_9: "Amigo" +rel_10: "Otro" +rel_99: "Demo" +select_relation: "Seleccionar relación..." + +# Categorías +category000: Imagen médica +category001: Documento +category002: Resultado de laboratorio +category003: Genoma +category004: Carga +category005: Consulta +category006: Diagnóstico +category007: Resultado de imagen +category008: Resultado de EEG +category009: Signo vital +category010: Ejercicio +category011: Medicamento +category012: Suplemento +category013: Nutrición +category014: Fertilidad +category015: Síntoma +category016: Nota +category017: Historial médico +category018: Antecedentes familiares +category019: Cirugía +category020: Hospitalización +category021: Datos de nacimiento +category022: Dispositivo médico +category023: Terapia +category024: Evaluación +category025: Proveedor de salud +category026: Pregunta + +# Genome +genome_english_only: "Toda la información genética está en inglés. Usa Claude para discutirla en español." +genome_variants: "variantes" +genome_hidden: "ocultas" +genome_show_all_categories: "Mostrar las %d categorías" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/fi.yaml b/lang/fi.yaml new file mode 100644 index 0000000..87c6b0c --- /dev/null +++ b/lang/fi.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Sinun terveystietosi." +headline_2: "Sinun tekoälysi." +headline_3: "Sinun vastauksesi." +intro: "Lataa kuvantamista, laboratoriotuloksia ja muuta. Yhdistä tekoäly auttamaan ymmärtämään, mitä näet." +email: "Sähköposti" +get_started: "Aloita" +data_yours: "Tietosi pysyvät sinun" +never_training: "Ei koskaan käytetä koulutukseen" +never_training_desc: "Kuviasi ei koskaan käytetä tekoälymallien koulutukseen." +never_shared: "Ei koskaan jaeta" +never_shared_desc: "Emme koskaan jaa tietojasi kenellekään." +encrypted: "Salattu tallennus" +encrypted_desc: "Kaikki tiedot salattu levossa." +delete: "Poista milloin tahansa" +delete_desc: "Sinun tietosi, sinun hallintasi." + +# Verify +check_email: "Tarkista sähköpostisi" +code_sent_to: "Lähetimme 6-numeroisen koodin osoitteeseen" +verification_code: "Vahvistuskoodi" +verify: "Vahvista" +use_different_email: "Käytä toista sähköpostia" +invalid_code: "Virheellinen tai vanhentunut koodi. Yritä uudelleen." + +# Onboard +create_dossier: "Luo kansiosi" +create_profile_intro: "Kerro meille itsestäsi aloittaaksesi." +name: "Nimi" +name_placeholder: "Nimesi" +date_of_birth: "Syntymäaika" +sex_at_birth: "Sukupuoli syntymähetkellä" +female: "Nainen" +male: "Mies" +create_my_dossier: "Luo kansioni" + +# Minor error +must_be_18: "Sinun täytyy olla 18 luodaksesi tilin" +minor_explanation: "Jos luot tämän jollekin toiselle, aloita omasta profiilistasi ensin. Tämä varmistaa, että vain sinä voit käyttää heidän terveystietojaan." +minor_next_steps: "Kansion luomisen jälkeen voit lisätä muita." +use_different_dob: "Käytä toista syntymäaikaa" + +# Minor login block +minor_login_blocked: "Sinun täytyy olla 18 kirjautuaksesi sisään" +minor_ask_guardian: "Pyydä %s pääsyä kansioosi." +minor_ask_guardian_generic: "Pyydä vanhempaa tai huoltajaa pääsyä kansioosi." + +# Dashboard +dossiers: "Kansiot" +dossiers_intro: "Hallitse terveystietoja itsellesi tai muille" +you: "sinä" +view: "Näytä" +save: "Tallenna" +cancel: "Peruuta" +add_dossier: "Lisää kansio" +edit_dossier: "Muokkaa kansiota" +care: "hoito" +logout: "Kirjaudu ulos" + +# Profile detail +back_to_dossiers: "Takaisin kansioihin" +born: "Syntynyt" +no_access_yet: "Vain sinulla on pääsy." +people_with_access: "Henkilöt, joilla on pääsy" +share_access: "Jaa pääsy" +can_edit: "voi lisätä tietoja" +remove: "Poista" +confirm_revoke: "Poista pääsy?" + +# Dossier sections +section_imaging: "Kuvantaminen" +section_labs: "Laboratorio" +section_uploads: "Lataukset" +section_vitals: "Elintoiminnot" +section_medications: "Lääkkeet" +section_records: "Asiakirjat" +section_journal: "Päiväkirja" +section_genetics: "Genetiikka" +section_privacy: "Yksityisyys" + +# Section summaries +imaging_summary: "%d tutkimusta · %d leikettä" +no_imaging: "Ei kuvatietoja" +no_lab_data: "Ei laboratoriotietoja" +no_genetics: "Ei geneettisiä tietoja" +no_files: "Ei tiedostoja" +no_upload_access: "You don't have permission to upload" +files_summary: "%d tiedostoa (%s)" +series_count: "%d sarjaa" +vitals_desc: "Verenpaine, syke, SpO₂, paino, verensokeri" +medications_desc: "Reseptit ja ravintolisät" +records_desc: "Kliiniset muistiinpanot ja potilasasiakirjat" +journal_desc: "Oireet, kipu ja havainnot" + +# Buttons and actions +open_viewer: "Avaa katselin" +manage: "Hallitse" +show_all_studies: "Näytä kaikki %d tutkimusta..." +coming_soon: "Tulossa pian" + +# Upload page +upload_files: "Lataa terveystietoja" +upload_files_intro: "Lataa lääketieteellistä kuvantamista, laboratoriotuloksia, genomitiedostoja tai terveyteen liittyviä asiakirjoja." +upload_hint_broad: "DICOM, PDF, CSV, VCF ja muut" +uploading: "Ladataan..." +files_uploaded: "tiedostoa ladattu" +upload_scans: "Lataa kuvauksia" +upload_scans_intro: "Lataa kansio, joka sisältää DICOM-tiedostoja kuvantamistutkimuksestasi." +upload_drop: "Napsauta tai vedä kansio tähän" +upload_hint: "Vain DICOM-kansiot" + +# Add profile +add_dossier_intro: "Lisää henkilö, jonka terveystietoja haluat hallita." +email_optional: "Sähköposti (valinnainen)" +email_optional_hint: "Jos he ovat 18, he voivat kirjautua itse" +your_relation: "Suhteesi heihin" +select_relation: "Valitse..." +i_provide_care: "Hoidan tätä henkilöä" +i_am_their: "Olen heidän..." + +# Share access +share_access_intro: "Kutsu joku käyttämään" +their_relation: "Heidän suhteensa tähän henkilöön" +can_add_data: "Voi lisätä tietoja (ravintolisät, muistiinpanot, jne.)" +send_invitation: "Lähetä kutsu" +back_to_dossier: "Takaisin kansioon" + +# Relations +my_role: "roolini" +role: "role" + +# Invitation email +invite_email_subject: "%s lisäsi sinut inouun" +invite_email_body: "%s lisäsi terveyskansiosi inouun, jotta voit katsella ja hallita lääketieteellisiä tietojasi." +invite_email_cta: "Kirjaudu sisään nähdäksesi" +continue: "Jatka" + +# Access management +people_with_access_count: "henkilöä, joilla on pääsy" +view_audit_log: "Näytä tapahtumaloki" +export_data: "Download my data" +relation_with: "Suhde henkilöön" +audit_log: "Tapahtumaloki" +audit_log_intro: "Toimintahistoria" +audit_log_desc: "Seuraa, kuka on käyttänyt tai muokannut tätä kansiota" + +# Install / Connect +install_title: "Yhdistä Claudeen" +install_intro: "Määritä inou-silta, jotta Claude voi analysoida terveystietojasi" +install_step1: "Vaihe 1: Lataa" +install_step1_desc: "Hanki silta alustallesi" +install_download_intro: "Lataa inou-silta käyttöjärjestelmällesi:" +install_step2: "Vaihe 2: Määritä" +install_step2_desc: "Lisää Claude Desktop -asetuksiin" +install_config_intro: "Lisää tämä Claude Desktop -asetustiedostoosi:" +install_step3: "Vaihe 3: Testaa" +install_step3_desc: "Vahvista yhteys" +install_test_intro: "Käynnistä Claude Desktop uudelleen ja kysy: 'Näytä inou-profiilini'" +nav_install: "Yhdistä Claudeen" +nav_home: "Koti" + +# Status +pending: "odottaa" +rate_limit_exceeded: "Liian monta rekisteröitymisyritystä sijainnistasi. Yritä uudelleen huomenna." + +# Sex display +sex_male: "mies" +sex_female: "nainen" +sex_na: "muu" + +# Friend invite email +friend_invite_subject: "Katso tämä — %s" +friend_invite_p1: "Olen käyttänyt inoua, turvallista tapaa tallentaa terveystietoja ja tutkia niitä tekoälyn avulla. Se pitää kaikki perheeni terveystiedot yhdessä paikassa — kuvantamistutkimukset, laboratoriotulokset, potilaskertomukset — ja ajattelin, että siitä voisi olla hyötyä sinullekin." +friend_invite_p2: "Todellinen voima on siinä, että voit käyttää tekoälyä ymmärtääksesi kaiken: ymmärtää mitä raportti todella tarkoittaa, havaita trendejä ajan myötä, tai vain esittää kysymyksiä tavallisella suomella ja saada selkeitä vastauksia." +friend_invite_btn: "Tutustu inouun" +friend_invite_dear: "Hei %s," +rel_0: "sinä" +rel_1: "Vanhempi" +rel_2: "Lapsi" +rel_3: "Puoliso" +rel_4: "Sisarus" +rel_5: "Huoltaja" +rel_6: "Hoitaja" +rel_7: "Valmentaja" +rel_8: "Lääkäri" +rel_9: "Ystävä" +rel_10: "Muu" +rel_99: "Demo" +select_relation: "Valitse suhde..." + +# Kategoriat +category000: Kuvantaminen +category001: Asiakirja +category002: Laboratoriotulos +category003: Genomi +category004: Lataus +category005: Konsultaatio +category006: Diagnoosi +category007: Kuvantamistulos +category008: EEG-tulos +category009: Elintoiminto +category010: Liikunta +category011: Lääke +category012: Lisäravinne +category013: Ravitsemus +category014: Hedelmällisyys +category015: Oire +category016: Muistiinpano +category017: Sairaushistoria +category018: Sukuhistoria +category019: Leikkaus +category020: Sairaalahoito +category021: Syntymätiedot +category022: Lääkinnällinen laite +category023: Terapia +category024: Arviointi +category025: Terveydenhuollon tarjoaja +category026: Kysymys + +# Genome +genome_english_only: "Kaikki geneettinen tieto on englanniksi. Käytä Claudea keskustellaksesi siitä suomeksi." +genome_variants: "varianttia" +genome_hidden: "piilotettua" +genome_show_all_categories: "Näytä kaikki %d kategoriaa" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/fr.yaml b/lang/fr.yaml new file mode 100644 index 0000000..451e198 --- /dev/null +++ b/lang/fr.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Vos données de santé." +headline_2: "Votre IA." +headline_3: "Vos réponses." +intro: "Téléchargez imagerie, analyses et plus encore. Connectez votre IA pour vous aider à comprendre ce que vous regardez." +email: "E-mail" +get_started: "Commencer" +data_yours: "Vos données restent les vôtres" +never_training: "Jamais utilisées pour l'entraînement" +never_training_desc: "Vos images ne sont jamais utilisées pour entraîner des modèles d'IA." +never_shared: "Jamais partagées" +never_shared_desc: "Nous ne partageons jamais vos données avec personne." +encrypted: "Stockage chiffré" +encrypted_desc: "Toutes les données sont chiffrées au repos." +delete: "Supprimer à tout moment" +delete_desc: "Vos données, votre contrôle." + +# Verify +check_email: "Vérifiez votre e-mail" +code_sent_to: "Nous avons envoyé un code à 6 chiffres à" +verification_code: "Code de vérification" +verify: "Vérifier" +use_different_email: "Utiliser un autre e-mail" +invalid_code: "Code invalide ou expiré. Veuillez réessayer." + +# Onboard +create_dossier: "Créez votre dossier" +create_profile_intro: "Parlez-nous de vous pour commencer." +name: "Nom" +name_placeholder: "Votre nom" +date_of_birth: "Date de naissance" +sex_at_birth: "Sexe à la naissance" +female: "Féminin" +male: "Masculin" +create_my_dossier: "Créer mon dossier" + +# Minor error +must_be_18: "Vous devez avoir 18 ans pour créer un compte" +minor_explanation: "Si vous configurez ceci pour quelqu'un d'autre, commencez d'abord par votre propre profil. Cela garantit que vous seul pouvez accéder à leurs données de santé." +minor_next_steps: "Après avoir créé votre dossier, vous pouvez en ajouter d'autres." +use_different_dob: "Utiliser une autre date de naissance" + +# Minor login block +minor_login_blocked: "Vous devez avoir 18 ans pour vous connecter" +minor_ask_guardian: "Demandez à %s d'accéder à votre dossier." +minor_ask_guardian_generic: "Demandez à un parent ou tuteur d'accéder à votre dossier." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Gérez les données de santé pour vous-même ou pour d'autres" +you: "vous" +view: "Voir" +save: "Enregistrer" +cancel: "Annuler" +add_dossier: "Ajouter un dossier" +edit_dossier: "Modifier le dossier" +care: "soins" +logout: "Se déconnecter" + +# Profile detail +back_to_dossiers: "Retour aux dossiers" +born: "Né(e)" +no_access_yet: "Vous seul avez accès." +people_with_access: "Personnes ayant accès" +share_access: "Partager l'accès" +can_edit: "peut ajouter des données" +remove: "Supprimer" +confirm_revoke: "Supprimer l'accès ?" + +# Dossier sections +section_imaging: "Imagerie" +section_labs: "Analyses" +section_uploads: "Fichiers" +section_vitals: "Signes vitaux" +section_medications: "Médicaments" +section_records: "Dossiers" +section_journal: "Journal" +section_genetics: "Génétique" +section_privacy: "Confidentialité" + +# Section summaries +imaging_summary: "%d études · %d coupes" +no_imaging: "Aucune donnée d'imagerie" +no_lab_data: "Aucune donnée de laboratoire" +no_genetics: "Aucune donnée génétique" +no_files: "Aucun fichier" +no_upload_access: "You don't have permission to upload" +files_summary: "%d fichiers (%s)" +series_count: "%d séries" +vitals_desc: "Tension artérielle, fréquence cardiaque, SpO₂, poids, glycémie" +medications_desc: "Ordonnances et compléments alimentaires" +records_desc: "Notes cliniques et dossiers médicaux" +journal_desc: "Symptômes, douleurs et observations" + +# Buttons and actions +open_viewer: "Ouvrir le visualiseur" +manage: "Gérer" +show_all_studies: "Afficher les %d études..." +coming_soon: "Bientôt disponible" + +# Upload page +upload_files: "Télécharger des données de santé" +upload_files_intro: "Téléchargez imagerie médicale, résultats d'analyses, fichiers génomiques ou tout document lié à la santé." +upload_hint_broad: "DICOM, PDF, CSV, VCF et plus" +uploading: "Téléchargement..." +files_uploaded: "fichiers téléchargés" +upload_scans: "Télécharger des examens" +upload_scans_intro: "Téléchargez un dossier contenant des fichiers DICOM de votre étude d'imagerie." +upload_drop: "Cliquez ou glissez un dossier ici" +upload_hint: "Dossiers DICOM uniquement" + +# Add profile +add_dossier_intro: "Ajoutez quelqu'un dont vous souhaitez gérer les données de santé." +email_optional: "E-mail (optionnel)" +email_optional_hint: "S'ils ont 18 ans, ils peuvent se connecter eux-mêmes" +your_relation: "Votre relation avec cette personne" +select_relation: "Sélectionner..." +i_provide_care: "Je m'occupe de cette personne" +i_am_their: "Je suis son/sa..." + +# Share access +share_access_intro: "Inviter quelqu'un à accéder" +their_relation: "Leur relation avec cette personne" +can_add_data: "Peut ajouter des données (compléments, notes, etc.)" +send_invitation: "Envoyer l'invitation" +back_to_dossier: "Retour au dossier" + +# Relations +my_role: "mon rôle" +role: "role" + +# Invitation email +invite_email_subject: "%s vous a ajouté à inou" +invite_email_body: "%s a ajouté votre dossier de santé à inou afin que vous puissiez consulter et gérer vos données médicales." +invite_email_cta: "Se connecter pour voir" +continue: "Continuer" + +# Access management +people_with_access_count: "personnes ayant accès" +view_audit_log: "Voir le journal d'activité" +export_data: "Download my data" +relation_with: "Relation avec" +audit_log: "Journal d'activité" +audit_log_intro: "Historique d'activité pour" +audit_log_desc: "Suivez qui a accédé ou modifié ce dossier" + +# Install / Connect +install_title: "Connecter à Claude" +install_intro: "Configurez le pont inou pour permettre à Claude d'analyser vos données de santé" +install_step1: "Étape 1 : Télécharger" +install_step1_desc: "Obtenez le pont pour votre plateforme" +install_download_intro: "Téléchargez le pont inou pour votre système d'exploitation :" +install_step2: "Étape 2 : Configurer" +install_step2_desc: "Ajouter à la configuration de Claude Desktop" +install_config_intro: "Ajoutez ceci à votre fichier de configuration Claude Desktop :" +install_step3: "Étape 3 : Tester" +install_step3_desc: "Vérifier la connexion" +install_test_intro: "Redémarrez Claude Desktop et demandez : 'Montre-moi mes profils inou'" +nav_install: "Connecter à Claude" +nav_home: "Accueil" + +# Status +pending: "en attente" +rate_limit_exceeded: "Trop de tentatives d'inscription depuis votre emplacement. Veuillez réessayer demain." + +# Sex display +sex_male: "masculin" +sex_female: "féminin" +sex_na: "autre" + +# Friend invite email +friend_invite_subject: "Regarde ça — %s" +friend_invite_p1: "J'utilise inou, la façon sécurisée de stocker des données de santé et de les explorer avec l'IA. Ça garde toutes les informations de santé de ma famille au même endroit — imagerie, résultats de labo, dossiers médicaux — et je me suis dit que ça pourrait t'être utile aussi." +friend_invite_p2: "La vraie puissance, c'est de pouvoir utiliser l'IA pour tout comprendre : comprendre ce qu'un rapport signifie vraiment, repérer les tendances dans le temps, ou simplement poser des questions en langage courant et obtenir des réponses claires." +friend_invite_btn: "Découvrir inou" +friend_invite_dear: "Cher/Chère %s," +rel_0: "toi" +rel_1: "Parent" +rel_2: "Enfant" +rel_3: "Conjoint" +rel_4: "Frère/Sœur" +rel_5: "Tuteur" +rel_6: "Aidant" +rel_7: "Coach" +rel_8: "Médecin" +rel_9: "Ami" +rel_10: "Autre" +rel_99: "Demo" +select_relation: "Sélectionner la relation..." + +# Catégories +category000: Imagerie +category001: Document +category002: Résultat de laboratoire +category003: Génome +category004: Téléchargement +category005: Consultation +category006: Diagnostic +category007: Résultat d'imagerie +category008: Résultat EEG +category009: Signe vital +category010: Exercice +category011: Médicament +category012: Supplément +category013: Nutrition +category014: Fertilité +category015: Symptôme +category016: Note +category017: Antécédents médicaux +category018: Antécédents familiaux +category019: Chirurgie +category020: Hospitalisation +category021: Données de naissance +category022: Dispositif médical +category023: Thérapie +category024: Évaluation +category025: Prestataire de soins +category026: Question + +# Genome +genome_english_only: "Toutes les informations génétiques sont en anglais. Utilisez Claude pour en discuter en français." +genome_variants: "variantes" +genome_hidden: "masquées" +genome_show_all_categories: "Afficher les %d catégories" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/it.yaml b/lang/it.yaml new file mode 100644 index 0000000..7e77ef2 --- /dev/null +++ b/lang/it.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "I tuoi dati sanitari." +headline_2: "La tua IA." +headline_3: "Le tue risposte." +intro: "Carica immagini diagnostiche, analisi e altro. Connetti la tua IA per aiutarti a capire cosa stai guardando." +email: "Email" +get_started: "Inizia" +data_yours: "I tuoi dati restano tuoi" +never_training: "Mai usati per l'addestramento" +never_training_desc: "Le tue immagini non vengono mai usate per addestrare modelli IA." +never_shared: "Mai condivisi" +never_shared_desc: "Non condividiamo mai i tuoi dati con nessuno." +encrypted: "Archiviazione crittografata" +encrypted_desc: "Tutti i dati crittografati a riposo." +delete: "Elimina quando vuoi" +delete_desc: "I tuoi dati, il tuo controllo." + +# Verify +check_email: "Controlla la tua email" +code_sent_to: "Abbiamo inviato un codice a 6 cifre a" +verification_code: "Codice di verifica" +verify: "Verifica" +use_different_email: "Usa un'altra email" +invalid_code: "Codice non valido o scaduto. Riprova." + +# Onboard +create_dossier: "Crea il tuo dossier" +create_profile_intro: "Parlaci di te per iniziare." +name: "Nome" +name_placeholder: "Il tuo nome" +date_of_birth: "Data di nascita" +sex_at_birth: "Sesso alla nascita" +female: "Femmina" +male: "Maschio" +create_my_dossier: "Crea il mio dossier" + +# Minor error +must_be_18: "Devi avere 18 anni per creare un account" +minor_explanation: "Se stai configurando questo per qualcun altro, inizia prima con il tuo profilo. Questo assicura che solo tu possa accedere ai loro dati sanitari." +minor_next_steps: "Dopo aver creato il tuo dossier, puoi aggiungerne altri." +use_different_dob: "Usa un'altra data di nascita" + +# Minor login block +minor_login_blocked: "Devi avere 18 anni per accedere" +minor_ask_guardian: "Chiedi a %s di accedere al tuo dossier." +minor_ask_guardian_generic: "Chiedi a un genitore o tutore di accedere al tuo dossier." + +# Dashboard +dossiers: "Dossier" +dossiers_intro: "Gestisci i dati sanitari per te o altri" +you: "tu" +view: "Visualizza" +save: "Salva" +cancel: "Annulla" +add_dossier: "Aggiungi dossier" +edit_dossier: "Modifica dossier" +care: "assistenza" +logout: "Esci" + +# Profile detail +back_to_dossiers: "Torna ai dossier" +born: "Nato/a" +no_access_yet: "Solo tu hai accesso." +people_with_access: "Persone con accesso" +share_access: "Condividi accesso" +can_edit: "può aggiungere dati" +remove: "Rimuovi" +confirm_revoke: "Rimuovere l'accesso?" + +# Dossier sections +section_imaging: "Immagini" +section_labs: "Analisi" +section_uploads: "File" +section_vitals: "Parametri vitali" +section_medications: "Farmaci" +section_records: "Cartelle" +section_journal: "Diario" +section_genetics: "Genetica" +section_privacy: "Privacy" + +# Section summaries +imaging_summary: "%d studi · %d sezioni" +no_imaging: "Nessun dato di imaging" +no_lab_data: "Nessun dato di laboratorio" +no_genetics: "Nessun dato genetico" +no_files: "Nessun file" +no_upload_access: "You don't have permission to upload" +files_summary: "%d file (%s)" +series_count: "%d serie" +vitals_desc: "Pressione sanguigna, frequenza cardiaca, SpO₂, peso, glicemia" +medications_desc: "Prescrizioni e integratori" +records_desc: "Note cliniche e cartelle mediche" +journal_desc: "Sintomi, dolore e osservazioni" + +# Buttons and actions +open_viewer: "Apri visualizzatore" +manage: "Gestisci" +show_all_studies: "Mostra tutti i %d studi..." +coming_soon: "Prossimamente" + +# Upload page +upload_files: "Carica dati sanitari" +upload_files_intro: "Carica immagini mediche, risultati di laboratorio, file genomici o documenti sanitari." +upload_hint_broad: "DICOM, PDF, CSV, VCF e altro" +uploading: "Caricamento..." +files_uploaded: "file caricati" +upload_scans: "Carica scansioni" +upload_scans_intro: "Carica una cartella contenente file DICOM dal tuo studio di imaging." +upload_drop: "Clicca o trascina una cartella qui" +upload_hint: "Solo cartelle DICOM" + +# Add profile +add_dossier_intro: "Aggiungi qualcuno di cui vuoi gestire i dati sanitari." +email_optional: "Email (opzionale)" +email_optional_hint: "Se ha 18 anni, può accedere autonomamente" +your_relation: "La tua relazione con questa persona" +select_relation: "Seleziona..." +i_provide_care: "Mi prendo cura di questa persona" +i_am_their: "Sono il/la loro..." + +# Share access +share_access_intro: "Invita qualcuno ad accedere" +their_relation: "La loro relazione con questa persona" +can_add_data: "Può aggiungere dati (integratori, note, ecc.)" +send_invitation: "Invia invito" +back_to_dossier: "Torna al dossier" + +# Relations +my_role: "il mio ruolo" +role: "role" + +# Invitation email +invite_email_subject: "%s ti ha aggiunto a inou" +invite_email_body: "%s ha aggiunto il tuo dossier sanitario a inou così puoi visualizzare e gestire i tuoi dati medici." +invite_email_cta: "Accedi per visualizzare" +continue: "Continua" + +# Access management +people_with_access_count: "persone con accesso" +view_audit_log: "Visualizza registro attività" +export_data: "Download my data" +relation_with: "Relazione con" +audit_log: "Registro attività" +audit_log_intro: "Cronologia attività per" +audit_log_desc: "Traccia chi ha acceduto o modificato questo dossier" + +# Install / Connect +install_title: "Connetti a Claude" +install_intro: "Configura il bridge inou per permettere a Claude di analizzare i tuoi dati sanitari" +install_step1: "Passo 1: Scarica" +install_step1_desc: "Ottieni il bridge per la tua piattaforma" +install_download_intro: "Scarica il bridge inou per il tuo sistema operativo:" +install_step2: "Passo 2: Configura" +install_step2_desc: "Aggiungi alla configurazione di Claude Desktop" +install_config_intro: "Aggiungi questo al file di configurazione di Claude Desktop:" +install_step3: "Passo 3: Testa" +install_step3_desc: "Verifica la connessione" +install_test_intro: "Riavvia Claude Desktop e chiedi: 'Mostrami i miei profili inou'" +nav_install: "Connetti a Claude" +nav_home: "Home" + +# Status +pending: "in attesa" +rate_limit_exceeded: "Troppi tentativi di registrazione dalla tua posizione. Riprova domani." + +# Sex display +sex_male: "maschio" +sex_female: "femmina" +sex_na: "altro" + +# Friend invite email +friend_invite_subject: "Dai un'occhiata — %s" +friend_invite_p1: "Sto usando inou, il modo sicuro per archiviare dati sanitari ed esplorarli con l'IA. Tiene tutte le informazioni sulla salute della mia famiglia in un unico posto — esami di imaging, risultati di laboratorio, cartelle cliniche — e ho pensato che potrebbe essere utile anche a te." +friend_invite_p2: "Il vero potere sta nel poter usare l'IA per dare senso a tutto: capire cosa significa veramente un referto, individuare tendenze nel tempo, o semplicemente fare domande in linguaggio semplice e ottenere risposte chiare." +friend_invite_btn: "Scopri inou" +friend_invite_dear: "Caro/a %s," +rel_0: "tu" +rel_1: "Genitore" +rel_2: "Figlio/a" +rel_3: "Coniuge" +rel_4: "Fratello/Sorella" +rel_5: "Tutore" +rel_6: "Caregiver" +rel_7: "Coach" +rel_8: "Medico" +rel_9: "Amico" +rel_10: "Altro" +rel_99: "Demo" +select_relation: "Seleziona relazione..." + +# Categorie +category000: Diagnostica per immagini +category001: Documento +category002: Risultato di laboratorio +category003: Genoma +category004: Caricamento +category005: Consultazione +category006: Diagnosi +category007: Risultato di imaging +category008: Risultato EEG +category009: Segno vitale +category010: Esercizio +category011: Farmaco +category012: Integratore +category013: Nutrizione +category014: Fertilità +category015: Sintomo +category016: Nota +category017: Storia medica +category018: Storia familiare +category019: Chirurgia +category020: Ospedalizzazione +category021: Dati di nascita +category022: Dispositivo medico +category023: Terapia +category024: Valutazione +category025: Fornitore sanitario +category026: Domanda + +# Genome +genome_english_only: "Tutte le informazioni genetiche sono in inglese. Usa Claude per discuterne in italiano." +genome_variants: "varianti" +genome_hidden: "nascoste" +genome_show_all_categories: "Mostra tutte le %d categorie" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/ja.yaml b/lang/ja.yaml new file mode 100644 index 0000000..9294b5e --- /dev/null +++ b/lang/ja.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "あなたの健康データ。" +headline_2: "あなたのAI。" +headline_3: "あなたの答え。" +intro: "画像診断、検査結果などをアップロード。AIに接続して、見ているものを理解する手助けをしてもらいましょう。" +email: "メールアドレス" +get_started: "始める" +data_yours: "あなたのデータはあなたのもの" +never_training: "トレーニングには使用されません" +never_training_desc: "あなたの画像はAIモデルのトレーニングには使用されません。" +never_shared: "共有されません" +never_shared_desc: "あなたのデータは誰とも共有しません。" +encrypted: "暗号化ストレージ" +encrypted_desc: "すべてのデータは保存時に暗号化されます。" +delete: "いつでも削除可能" +delete_desc: "あなたのデータ、あなたの管理。" + +# Verify +check_email: "メールを確認してください" +code_sent_to: "6桁のコードを送信しました:" +verification_code: "確認コード" +verify: "確認" +use_different_email: "別のメールアドレスを使用" +invalid_code: "無効または期限切れのコードです。もう一度お試しください。" + +# Onboard +create_dossier: "ドシエを作成" +create_profile_intro: "始めるにあたって、あなたについて教えてください。" +name: "名前" +name_placeholder: "あなたの名前" +date_of_birth: "生年月日" +sex_at_birth: "出生時の性別" +female: "女性" +male: "男性" +create_my_dossier: "ドシエを作成" + +# Minor error +must_be_18: "アカウントを作成するには18歳以上である必要があります" +minor_explanation: "他の人のために設定する場合は、まず自分のプロフィールから始めてください。これにより、あなただけが彼らの健康データにアクセスできるようになります。" +minor_next_steps: "ドシエを作成した後、他の人を追加できます。" +use_different_dob: "別の生年月日を使用" + +# Minor login block +minor_login_blocked: "ログインするには18歳以上である必要があります" +minor_ask_guardian: "%sにドシエへのアクセスを依頼してください。" +minor_ask_guardian_generic: "親または保護者にドシエへのアクセスを依頼してください。" + +# Dashboard +dossiers: "ドシエ" +dossiers_intro: "自分や他の人の健康データを管理" +you: "あなた" +view: "表示" +save: "保存" +cancel: "キャンセル" +add_dossier: "ドシエを追加" +edit_dossier: "ドシエを編集" +care: "ケア" +logout: "ログアウト" + +# Profile detail +back_to_dossiers: "ドシエに戻る" +born: "生年月日" +no_access_yet: "アクセスできるのはあなただけです。" +people_with_access: "アクセス権のある人" +share_access: "アクセスを共有" +can_edit: "データを追加可能" +remove: "削除" +confirm_revoke: "アクセスを削除しますか?" + +# Dossier sections +section_imaging: "画像診断" +section_labs: "検査結果" +section_uploads: "アップロード" +section_vitals: "バイタル" +section_medications: "薬" +section_records: "記録" +section_journal: "日記" +section_genetics: "遺伝子" +section_privacy: "プライバシー" + +# Section summaries +imaging_summary: "%d件の検査 · %dスライス" +no_imaging: "画像データなし" +no_lab_data: "検査データなし" +no_genetics: "遺伝子データなし" +no_files: "ファイルなし" +no_upload_access: "You don't have permission to upload" +files_summary: "%dファイル(%s)" +series_count: "%dシリーズ" +vitals_desc: "血圧、心拍数、SpO₂、体重、血糖値" +medications_desc: "処方薬とサプリメント" +records_desc: "診療記録と医療記録" +journal_desc: "症状、痛み、観察" + +# Buttons and actions +open_viewer: "ビューアを開く" +manage: "管理" +show_all_studies: "すべての%d件の検査を表示..." +coming_soon: "近日公開" + +# Upload page +upload_files: "健康データをアップロード" +upload_files_intro: "医療画像、検査結果、ゲノムファイル、または健康関連の文書をアップロードしてください。" +upload_hint_broad: "DICOM、PDF、CSV、VCFなど" +uploading: "アップロード中..." +files_uploaded: "ファイルがアップロードされました" +upload_scans: "スキャンをアップロード" +upload_scans_intro: "画像検査のDICOMファイルを含むフォルダをアップロードしてください。" +upload_drop: "クリックまたはフォルダをここにドラッグ" +upload_hint: "DICOMフォルダのみ" + +# Add profile +add_dossier_intro: "健康データを管理したい人を追加してください。" +email_optional: "メール(任意)" +email_optional_hint: "18歳以上なら、本人がログインできます" +your_relation: "この人とのあなたの関係" +select_relation: "選択..." +i_provide_care: "この人のケアを提供しています" +i_am_their: "私は彼らの..." + +# Share access +share_access_intro: "アクセスする人を招待" +their_relation: "この人との関係" +can_add_data: "データを追加可能(サプリメント、メモなど)" +send_invitation: "招待を送信" +back_to_dossier: "ドシエに戻る" + +# Relations +my_role: "私の役割" +role: "role" + +# Invitation email +invite_email_subject: "%sがあなたをinouに追加しました" +invite_email_body: "%sがあなたの健康ドシエをinouに追加しました。医療データを表示・管理できます。" +invite_email_cta: "ログインして表示" +continue: "続ける" + +# Access management +people_with_access_count: "アクセス権のある人" +view_audit_log: "監査ログを表示" +export_data: "Download my data" +relation_with: "との関係" +audit_log: "監査ログ" +audit_log_intro: "のアクティビティ履歴" +audit_log_desc: "このドシエにアクセスまたは変更した人を追跡" + +# Install / Connect +install_title: "Claudeに接続" +install_intro: "inouブリッジを設定して、Claudeがあなたの健康データを分析できるようにします" +install_step1: "ステップ1:ダウンロード" +install_step1_desc: "お使いのプラットフォーム用のブリッジを取得" +install_download_intro: "お使いのオペレーティングシステム用のinouブリッジをダウンロード:" +install_step2: "ステップ2:設定" +install_step2_desc: "Claude Desktopの設定に追加" +install_config_intro: "これをClaude Desktopの設定ファイルに追加してください:" +install_step3: "ステップ3:テスト" +install_step3_desc: "接続を確認" +install_test_intro: "Claude Desktopを再起動して、「inouプロフィールを表示」と尋ねてください" +nav_install: "Claudeに接続" +nav_home: "ホーム" + +# Status +pending: "保留中" +rate_limit_exceeded: "お住まいの地域からの登録試行が多すぎます。明日もう一度お試しください。" + +# Sex display +sex_male: "男性" +sex_female: "女性" +sex_na: "その他" + +# Friend invite email +friend_invite_subject: "これ見て — %s" +friend_invite_p1: "inouを使っています。健康データを安全に保存してAIで分析できるサービスです。家族の健康情報をすべて一か所に保管できます — 画像検査、検査結果、医療記録など。あなたにも役立つかもしれないと思いました。" +friend_invite_p2: "本当の力は、AIを使ってすべてを理解できることです:レポートが実際に何を意味するのか理解したり、時間の経過に伴う傾向を見つけたり、普通の言葉で質問して明確な回答を得たりできます。" +friend_invite_btn: "inouを見る" +friend_invite_dear: "%sさん、" +rel_0: "あなた" +rel_1: "親" +rel_2: "子供" +rel_3: "配偶者" +rel_4: "兄弟姉妹" +rel_5: "後見人" +rel_6: "介護者" +rel_7: "コーチ" +rel_8: "医師" +rel_9: "友人" +rel_10: "その他" +rel_99: "Demo" +select_relation: "関係を選択..." + +# カテゴリー +category000: 画像診断 +category001: 文書 +category002: 検査結果 +category003: ゲノム +category004: アップロード +category005: 相談 +category006: 診断 +category007: 画像検査結果 +category008: 脳波検査結果 +category009: バイタルサイン +category010: 運動 +category011: 薬 +category012: サプリメント +category013: 栄養 +category014: 妊娠・出産 +category015: 症状 +category016: メモ +category017: 病歴 +category018: 家族歴 +category019: 手術 +category020: 入院 +category021: 出生データ +category022: 医療機器 +category023: 治療 +category024: 評価 +category025: 医療提供者 +category026: 質問 + +# Genome +genome_english_only: "遺伝子情報はすべて英語です。Claudeを使って日本語で相談できます。" +genome_variants: "バリアント" +genome_hidden: "非表示" +genome_show_all_categories: "全%dカテゴリを表示" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/ko.yaml b/lang/ko.yaml new file mode 100644 index 0000000..d0677ac --- /dev/null +++ b/lang/ko.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "당신의 건강 데이터." +headline_2: "당신의 AI." +headline_3: "당신의 답변." +intro: "영상, 검사 결과 등을 업로드하세요. AI를 연결하여 보고 있는 내용을 이해하는 데 도움을 받으세요." +email: "이메일" +get_started: "시작하기" +data_yours: "당신의 데이터는 당신의 것" +never_training: "훈련에 사용되지 않음" +never_training_desc: "당신의 이미지는 AI 모델 훈련에 사용되지 않습니다." +never_shared: "공유되지 않음" +never_shared_desc: "당신의 데이터를 누구와도 공유하지 않습니다." +encrypted: "암호화 저장" +encrypted_desc: "모든 데이터가 저장 시 암호화됩니다." +delete: "언제든 삭제 가능" +delete_desc: "당신의 데이터, 당신의 통제." + +# Verify +check_email: "이메일을 확인하세요" +code_sent_to: "6자리 코드를 보냈습니다:" +verification_code: "인증 코드" +verify: "인증" +use_different_email: "다른 이메일 사용" +invalid_code: "잘못되었거나 만료된 코드입니다. 다시 시도하세요." + +# Onboard +create_dossier: "서류철 만들기" +create_profile_intro: "시작하려면 자신에 대해 알려주세요." +name: "이름" +name_placeholder: "이름" +date_of_birth: "생년월일" +sex_at_birth: "출생 시 성별" +female: "여성" +male: "남성" +create_my_dossier: "내 서류철 만들기" + +# Minor error +must_be_18: "계정을 만들려면 18세 이상이어야 합니다" +minor_explanation: "다른 사람을 위해 설정하는 경우, 먼저 본인의 프로필부터 시작하세요. 이렇게 하면 본인만 그들의 건강 데이터에 접근할 수 있습니다." +minor_next_steps: "서류철을 만든 후 다른 사람을 추가할 수 있습니다." +use_different_dob: "다른 생년월일 사용" + +# Minor login block +minor_login_blocked: "로그인하려면 18세 이상이어야 합니다" +minor_ask_guardian: "%s에게 서류철 접근을 요청하세요." +minor_ask_guardian_generic: "부모님이나 보호자에게 서류철 접근을 요청하세요." + +# Dashboard +dossiers: "서류철" +dossiers_intro: "본인 또는 다른 사람의 건강 데이터 관리" +you: "나" +view: "보기" +save: "저장" +cancel: "취소" +add_dossier: "서류철 추가" +edit_dossier: "서류철 편집" +care: "돌봄" +logout: "로그아웃" + +# Profile detail +back_to_dossiers: "서류철로 돌아가기" +born: "출생" +no_access_yet: "본인만 접근할 수 있습니다." +people_with_access: "접근 권한이 있는 사람" +share_access: "접근 권한 공유" +can_edit: "데이터 추가 가능" +remove: "제거" +confirm_revoke: "접근 권한을 제거하시겠습니까?" + +# Dossier sections +section_imaging: "영상" +section_labs: "검사" +section_uploads: "업로드" +section_vitals: "활력징후" +section_medications: "약물" +section_records: "기록" +section_journal: "일지" +section_genetics: "유전" +section_privacy: "개인정보" + +# Section summaries +imaging_summary: "%d건의 검사 · %d장의 슬라이스" +no_imaging: "영상 데이터 없음" +no_lab_data: "검사 데이터 없음" +no_genetics: "유전 데이터 없음" +no_files: "파일 없음" +no_upload_access: "You don't have permission to upload" +files_summary: "%d개 파일 (%s)" +series_count: "%d개 시리즈" +vitals_desc: "혈압, 심박수, 산소포화도, 체중, 혈당" +medications_desc: "처방약 및 보충제" +records_desc: "임상 기록 및 의무 기록" +journal_desc: "증상, 통증 및 관찰" + +# Buttons and actions +open_viewer: "뷰어 열기" +manage: "관리" +show_all_studies: "모든 %d건의 검사 보기..." +coming_soon: "곧 출시" + +# Upload page +upload_files: "건강 데이터 업로드" +upload_files_intro: "의료 영상, 검사 결과, 유전체 파일 또는 건강 관련 문서를 업로드하세요." +upload_hint_broad: "DICOM, PDF, CSV, VCF 등" +uploading: "업로드 중..." +files_uploaded: "파일이 업로드됨" +upload_scans: "스캔 업로드" +upload_scans_intro: "영상 검사의 DICOM 파일이 포함된 폴더를 업로드하세요." +upload_drop: "클릭하거나 폴더를 여기로 드래그하세요" +upload_hint: "DICOM 폴더만" + +# Add profile +add_dossier_intro: "건강 데이터를 관리하고 싶은 사람을 추가하세요." +email_optional: "이메일 (선택사항)" +email_optional_hint: "18세 이상이면 직접 로그인할 수 있습니다" +your_relation: "이 사람과의 관계" +select_relation: "선택..." +i_provide_care: "이 사람을 돌봅니다" +i_am_their: "나는 그들의..." + +# Share access +share_access_intro: "접근할 사람 초대" +their_relation: "이 사람과의 관계" +can_add_data: "데이터 추가 가능 (보충제, 메모 등)" +send_invitation: "초대 보내기" +back_to_dossier: "서류철로 돌아가기" + +# Relations +my_role: "내 역할" +role: "role" + +# Invitation email +invite_email_subject: "%s님이 inou에 당신을 추가했습니다" +invite_email_body: "%s님이 당신의 건강 서류철을 inou에 추가하여 의료 데이터를 보고 관리할 수 있습니다." +invite_email_cta: "로그인하여 보기" +continue: "계속" + +# Access management +people_with_access_count: "접근 권한이 있는 사람" +view_audit_log: "감사 로그 보기" +export_data: "Download my data" +relation_with: "와의 관계" +audit_log: "감사 로그" +audit_log_intro: "활동 기록" +audit_log_desc: "이 서류철에 접근하거나 수정한 사람 추적" + +# Install / Connect +install_title: "Claude에 연결" +install_intro: "inou 브릿지를 설정하여 Claude가 건강 데이터를 분석할 수 있게 합니다" +install_step1: "1단계: 다운로드" +install_step1_desc: "플랫폼용 브릿지 받기" +install_download_intro: "운영 체제용 inou 브릿지를 다운로드하세요:" +install_step2: "2단계: 구성" +install_step2_desc: "Claude Desktop 설정에 추가" +install_config_intro: "Claude Desktop 설정 파일에 다음을 추가하세요:" +install_step3: "3단계: 테스트" +install_step3_desc: "연결 확인" +install_test_intro: "Claude Desktop을 재시작하고 'inou 프로필 보여줘'라고 물어보세요" +nav_install: "Claude에 연결" +nav_home: "홈" + +# Status +pending: "대기 중" +rate_limit_exceeded: "현재 위치에서 가입 시도가 너무 많습니다. 내일 다시 시도하세요." + +# Sex display +sex_male: "남성" +sex_female: "여성" +sex_na: "기타" + +# Friend invite email +friend_invite_subject: "이것 좀 봐 — %s" +friend_invite_p1: "inou를 사용하고 있어요. 건강 데이터를 안전하게 저장하고 AI로 분석할 수 있는 서비스예요. 우리 가족의 모든 건강 정보를 한 곳에 보관할 수 있어요 — 영상 검사, 검사 결과, 의료 기록 등. 당신에게도 유용할 것 같아서 알려드려요." +friend_invite_p2: "진정한 힘은 AI를 사용해서 모든 것을 이해할 수 있다는 거예요: 보고서가 실제로 무엇을 의미하는지 이해하고, 시간에 따른 추세를 파악하거나, 일상적인 언어로 질문하고 명확한 답변을 얻을 수 있어요." +friend_invite_btn: "inou 알아보기" +friend_invite_dear: "%s님," +rel_0: "당신" +rel_1: "부모" +rel_2: "자녀" +rel_3: "배우자" +rel_4: "형제자매" +rel_5: "후견인" +rel_6: "간병인" +rel_7: "코치" +rel_8: "의사" +rel_9: "친구" +rel_10: "기타" +rel_99: "Demo" +select_relation: "관계 선택..." + +# 카테고리 +category000: 영상 +category001: 문서 +category002: 검사 결과 +category003: 게놈 +category004: 업로드 +category005: 상담 +category006: 진단 +category007: 영상 검사 결과 +category008: 뇌파 검사 결과 +category009: 활력 징후 +category010: 운동 +category011: 약물 +category012: 보충제 +category013: 영양 +category014: 생식력 +category015: 증상 +category016: 메모 +category017: 병력 +category018: 가족력 +category019: 수술 +category020: 입원 +category021: 출생 데이터 +category022: 의료 기기 +category023: 치료 +category024: 평가 +category025: 의료 제공자 +category026: 질문 + +# Genome +genome_english_only: "모든 유전자 정보는 영어로 되어 있습니다. Claude를 사용하여 한국어로 상담하세요." +genome_variants: "변이" +genome_hidden: "숨김" +genome_show_all_categories: "전체 %d개 카테고리 표시" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/nl.yaml b/lang/nl.yaml new file mode 100644 index 0000000..933081a --- /dev/null +++ b/lang/nl.yaml @@ -0,0 +1,240 @@ +# Landing +headline_1: "Jouw gezondheidsdata." +headline_2: "Jouw AI." +headline_3: "Jouw antwoorden." +intro: "Upload beeldvorming, labresultaten en meer. Verbind je AI om te begrijpen wat je ziet." +email: "E-mail" +get_started: "Aan de slag" +data_yours: "Jouw data blijft van jou" +never_training: "Nooit gebruikt voor training" +never_training_desc: "Je beelden worden nooit gebruikt om AI-modellen te trainen." +never_shared: "Nooit gedeeld" +never_shared_desc: "We delen je data nooit met anderen." +encrypted: "Versleutelde opslag" +encrypted_desc: "Alle data versleuteld opgeslagen." +delete: "Altijd verwijderen" +delete_desc: "Jouw data, jouw controle." + +# Verify +check_email: "Controleer je e-mail" +code_sent_to: "We hebben een 6-cijferige code gestuurd naar" +verification_code: "Verificatiecode" +verify: "Verifiëren" +use_different_email: "Ander e-mailadres gebruiken" +invalid_code: "Ongeldige of verlopen code. Probeer opnieuw." + +# Onboard +create_dossier: "Maak je dossier aan" +create_profile_intro: "Vertel ons over jezelf om te beginnen." +name: "Naam" +name_placeholder: "Je naam" +date_of_birth: "Geboortedatum" +sex_at_birth: "Geslacht bij geboorte" +female: "Vrouw" +male: "Man" +create_my_dossier: "Mijn dossier aanmaken" + +# Minor error +must_be_18: "Je moet 18 zijn om een account aan te maken" +minor_explanation: "Als je dit voor iemand anders instelt, begin dan eerst met je eigen profiel. Zo heb alleen jij toegang tot hun gezondheidsgegevens." +minor_next_steps: "Na het aanmaken van je dossier kun je anderen toevoegen." +use_different_dob: "Andere geboortedatum gebruiken" + +# Minor login block +minor_login_blocked: "Je moet 18 zijn om in te loggen" +minor_ask_guardian: "Vraag %s om toegang tot je dossier." +minor_ask_guardian_generic: "Vraag een ouder of voogd om toegang tot je dossier." + +# Dashboard +dossiers: "Dossiers" +dossiers_intro: "Beheer de gezondheidsgegevens van jezelf of voor anderen" +you: "jij" +view: "Bekijken" +save: "Opslaan" +cancel: "Annuleren" +add_dossier: "Dossier toevoegen" +edit_dossier: "Dossier bewerken" +care: "zorg" +logout: "Uitloggen" + +# Profile detail +back_to_dossiers: "Terug naar dossiers" +born: "Geboren" +no_access_yet: "Alleen jij hebt toegang." +people_with_access: "Personen met toegang" +share_access: "Toegang delen" +can_edit: "kan gegevens toevoegen" +remove: "Verwijderen" +confirm_revoke: "Toegang intrekken?" + +# Dossier sections +section_imaging: "Radiologie" +section_labs: "Labresultaten" +section_uploads: "Uploads" +section_vitals: "Vitale functies" +section_medications: "Medicatie" +section_records: "Dossiers" +section_journal: "Dagboek" + +# Section summaries +imaging_summary: "%d onderzoeken · %d beelden" +no_imaging: "Geen beeldvorming" +no_lab_data: "Geen labresultaten" +no_files: "Geen bestanden" +no_upload_access: "You don't have permission to upload" +files_summary: "%d bestanden (%s)" +series_count: "%d series" +vitals_desc: "Bloeddruk, hartslag, SpO₂, gewicht, glucose" +medications_desc: "Recepten en supplementen" +records_desc: "Klinische notities en medische dossiers" +journal_desc: "Symptomen, pijn en observaties" + +# Buttons and actions +open_viewer: "Viewer openen" +manage: "Beheren" +show_all_studies: "Toon alle %d onderzoeken..." +coming_soon: "Binnenkort beschikbaar" + +# Upload page +upload_files: "Gezondheidsgegevens uploaden" +upload_files_intro: "Upload medische beeldvorming, labresultaten, genoombestanden of andere gezondheidsgerelateerde documenten." +upload_hint_broad: "DICOM, PDF, CSV, VCF en meer" +uploading: "Uploaden..." +files_uploaded: "bestanden geüpload" +upload_scans: "Scans uploaden" +upload_scans_intro: "Upload een map met DICOM-bestanden van je beeldvormend onderzoek." +upload_drop: "Klik of sleep een map hierheen" +upload_hint: "Alleen DICOM-mappen" + +# Add profile +add_dossier_intro: "Voeg iemand toe wiens gezondheidsgegevens je wilt beheren." +email_optional: "E-mail (optioneel)" +email_optional_hint: "Als ze 18+ zijn, kunnen ze zelf inloggen" +your_relation: "Jouw relatie met hen" +select_relation: "Selecteer..." +i_provide_care: "Ik zorg voor deze persoon" + +# Share access +share_access_intro: "Nodig iemand uit voor toegang tot" +their_relation: "Hun relatie met deze persoon" +can_add_data: "Kan gegevens toevoegen (supplementen, notities, etc.)" +send_invitation: "Uitnodiging versturen" +back_to_dossier: "Terug naar dossier" + +# Relations + +# Invitation email +invite_email_subject: "%s heeft je toegevoegd aan inou" +invite_email_body: "%s heeft je gezondheidsdossier toegevoegd aan inou zodat je je medische gegevens kunt bekijken en beheren." +invite_email_cta: "Inloggen om te bekijken" +continue: "Doorgaan" +i_am_their: "Ik ben hun..." + +# Simple relation names (for display) +my_role: "mijn rol" +role: "role" +section_privacy: "Privacy" +people_with_access_count: "personen met toegang" +view_audit_log: "Bekijk auditlog" +export_data: "Download my data" +relation_with: "Relatie met" +audit_log: "Auditlog" +audit_log_intro: "Activiteitengeschiedenis voor" +audit_log_desc: "Bekijk wie dit dossier heeft bekeken of gewijzigd" +install_title: "Verbind met Claude" +install_intro: "Stel de inou-bridge in zodat Claude je gezondheidsgegevens kan analyseren" +install_step1: "Stap 1: Download" +install_step1_desc: "Download de bridge voor jouw platform" +install_download_intro: "Download de inou-bridge voor jouw besturingssysteem:" +install_step2: "Stap 2: Configureer" +install_step2_desc: "Voeg toe aan Claude Desktop configuratie" +install_config_intro: "Voeg dit toe aan je Claude Desktop configuratiebestand:" +install_step3: "Stap 3: Test" +install_step3_desc: "Controleer de verbinding" +install_test_intro: "Herstart Claude Desktop en vraag: 'Toon mijn inou profielen'" +nav_install: "Verbind met Claude" +nav_home: "Home" +pending: "in afwachting" +rate_limit_exceeded: "Te veel aanmeldpogingen vanaf uw locatie. Probeer het morgen opnieuw." +section_genetics: Genetica +no_genetics: Geen genetische gegevens + +sex_male: "mannelijk" +sex_female: "vrouwelijk" +sex_na: "anders" + +# Friend invite email +friend_invite_subject: "Kijk hier eens naar — %s" +friend_invite_p1: "Ik gebruik inou, de veilige manier om gezondheidsgegevens op te slaan en te verkennen met AI. Het houdt alle gezondheidsinformatie van mijn familie op één plek — beeldvorming, labresultaten, medische dossiers — en ik dacht dat jij het misschien ook handig zou vinden." +friend_invite_p2: "De echte kracht is dat je AI kunt gebruiken om alles te begrijpen: begrijpen wat een rapport echt betekent, trends in de tijd ontdekken, of gewoon vragen stellen in gewone taal en duidelijke antwoorden krijgen." +friend_invite_btn: "Bekijk inou" +friend_invite_dear: "Beste %s," +rel_0: "jij" +rel_1: "Ouder" +rel_2: "Kind" +rel_3: "Partner" +rel_4: "Broer/Zus" +rel_5: "Voogd" +rel_6: "Verzorger" +rel_7: "Coach" +rel_8: "Arts" +rel_9: "Vriend" +rel_10: "Anders" +rel_99: "Demo" +select_relation: "Selecteer relatie..." +audit_dossier_added: "Nieuw dossier voor %s aangemaakt door %s" +audit_dossier_edited: "Dossier %s bewerkt door %s" +audit_access_granted: "Toegang tot %s verleend aan %s" +audit_dossier_created: Account aangemaakt door %s +audit_access_revoked: Toegang voor %s tot %s ingetrokken +audit_file_upload: Bestand %s geüpload door %s +audit_file_delete: Bestand %s verwijderd door %s +audit_file_category_change: Bestandscategorie %s gewijzigd door %s +audit_genome_import: %s genetische varianten geïmporteerd + +# Categorieën +category000: Beeldvorming +category001: Document +category002: Labuitslag +category003: Genoom +category004: Upload +category005: Consult +category006: Diagnose +category007: Beeldvormingsresultaat +category008: EEG-resultaat +category009: Vitale waarde +category010: Beweging +category011: Medicatie +category012: Supplement +category013: Voeding +category014: Vruchtbaarheid +category015: Symptoom +category016: Notitie +category017: Medische geschiedenis +category018: Familiegeschiedenis +category019: Operatie +category020: Ziekenhuisopname +category021: Geboortegegevens +category022: Medisch hulpmiddel +category023: Therapie +category024: Beoordeling +category025: Zorgverlener +category026: Vraag + +# Genome +genome_english_only: "Alle genetische informatie is in het Engels. Gebruik Claude om het in het Nederlands te bespreken." +genome_variants: "varianten" +genome_hidden: "verborgen" +genome_show_all_categories: "Toon alle %d categorieën" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/no.yaml b/lang/no.yaml new file mode 100644 index 0000000..c1eb88a --- /dev/null +++ b/lang/no.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Dine helsedata." +headline_2: "Din AI." +headline_3: "Dine svar." +intro: "Last opp bildediagnostikk, labresultater og mer. Koble til din AI for å hjelpe deg forstå hva du ser på." +email: "E-post" +get_started: "Kom i gang" +data_yours: "Dine data forblir dine" +never_training: "Brukes aldri til trening" +never_training_desc: "Bildene dine brukes aldri til å trene AI-modeller." +never_shared: "Deles aldri" +never_shared_desc: "Vi deler aldri dataene dine med noen." +encrypted: "Kryptert lagring" +encrypted_desc: "Alle data kryptert i hvile." +delete: "Slett når som helst" +delete_desc: "Dine data, din kontroll." + +# Verify +check_email: "Sjekk e-posten din" +code_sent_to: "Vi sendte en 6-sifret kode til" +verification_code: "Verifiseringskode" +verify: "Verifiser" +use_different_email: "Bruk en annen e-post" +invalid_code: "Ugyldig eller utløpt kode. Prøv igjen." + +# Onboard +create_dossier: "Opprett din dosje" +create_profile_intro: "Fortell oss om deg selv for å komme i gang." +name: "Navn" +name_placeholder: "Ditt navn" +date_of_birth: "Fødselsdato" +sex_at_birth: "Kjønn ved fødsel" +female: "Kvinne" +male: "Mann" +create_my_dossier: "Opprett min dosje" + +# Minor error +must_be_18: "Du må være 18 for å opprette en konto" +minor_explanation: "Hvis du setter dette opp for noen andre, start med din egen profil først. Dette sikrer at bare du kan få tilgang til deres helsedata." +minor_next_steps: "Etter at du har opprettet din dosje, kan du legge til andre." +use_different_dob: "Bruk en annen fødselsdato" + +# Minor login block +minor_login_blocked: "Du må være 18 for å logge inn" +minor_ask_guardian: "Be %s om tilgang til din dosje." +minor_ask_guardian_generic: "Be en forelder eller foresatt om tilgang til din dosje." + +# Dashboard +dossiers: "Dosjer" +dossiers_intro: "Administrer helsedata for deg selv eller andre" +you: "deg" +view: "Vis" +save: "Lagre" +cancel: "Avbryt" +add_dossier: "Legg til dosje" +edit_dossier: "Rediger dosje" +care: "omsorg" +logout: "Logg ut" + +# Profile detail +back_to_dossiers: "Tilbake til dosjer" +born: "Født" +no_access_yet: "Bare du har tilgang." +people_with_access: "Personer med tilgang" +share_access: "Del tilgang" +can_edit: "kan legge til data" +remove: "Fjern" +confirm_revoke: "Fjerne tilgang?" + +# Dossier sections +section_imaging: "Bildediagnostikk" +section_labs: "Lab" +section_uploads: "Opplastinger" +section_vitals: "Vitaltegn" +section_medications: "Medisiner" +section_records: "Journaler" +section_journal: "Dagbok" +section_genetics: "Genetikk" +section_privacy: "Personvern" + +# Section summaries +imaging_summary: "%d undersøkelser · %d snitt" +no_imaging: "Ingen bildedata" +no_lab_data: "Ingen labdata" +no_genetics: "Ingen genetiske data" +no_files: "Ingen filer" +no_upload_access: "You don't have permission to upload" +files_summary: "%d filer (%s)" +series_count: "%d serier" +vitals_desc: "Blodtrykk, puls, SpO₂, vekt, blodsukker" +medications_desc: "Resepter og kosttilskudd" +records_desc: "Kliniske notater og journaler" +journal_desc: "Symptomer, smerte og observasjoner" + +# Buttons and actions +open_viewer: "Åpne visning" +manage: "Administrer" +show_all_studies: "Vis alle %d undersøkelser..." +coming_soon: "Kommer snart" + +# Upload page +upload_files: "Last opp helsedata" +upload_files_intro: "Last opp medisinsk bildediagnostikk, labresultater, genomfiler eller helserelaterte dokumenter." +upload_hint_broad: "DICOM, PDF, CSV, VCF og mer" +uploading: "Laster opp..." +files_uploaded: "filer lastet opp" +upload_scans: "Last opp skanninger" +upload_scans_intro: "Last opp en mappe med DICOM-filer fra din bildeundersøkelse." +upload_drop: "Klikk eller dra en mappe hit" +upload_hint: "Kun DICOM-mapper" + +# Add profile +add_dossier_intro: "Legg til noen hvis helsedata du vil administrere." +email_optional: "E-post (valgfritt)" +email_optional_hint: "Hvis de er 18, kan de logge inn selv" +your_relation: "Ditt forhold til dem" +select_relation: "Velg..." +i_provide_care: "Jeg gir omsorg til denne personen" +i_am_their: "Jeg er deres..." + +# Share access +share_access_intro: "Inviter noen til å få tilgang" +their_relation: "Deres forhold til denne personen" +can_add_data: "Kan legge til data (kosttilskudd, notater, etc.)" +send_invitation: "Send invitasjon" +back_to_dossier: "Tilbake til dosje" + +# Relations +my_role: "min rolle" +role: "role" + +# Invitation email +invite_email_subject: "%s la deg til i inou" +invite_email_body: "%s la til din helsedosje i inou slik at du kan se og administrere dine medisinske data." +invite_email_cta: "Logg inn for å se" +continue: "Fortsett" + +# Access management +people_with_access_count: "personer med tilgang" +view_audit_log: "Vis aktivitetslogg" +export_data: "Download my data" +relation_with: "Forhold til" +audit_log: "Aktivitetslogg" +audit_log_intro: "Aktivitetshistorikk for" +audit_log_desc: "Spor hvem som har hatt tilgang til eller endret denne dosjen" + +# Install / Connect +install_title: "Koble til Claude" +install_intro: "Sett opp inou-broen for å la Claude analysere helsedataene dine" +install_step1: "Steg 1: Last ned" +install_step1_desc: "Hent broen for din plattform" +install_download_intro: "Last ned inou-broen for ditt operativsystem:" +install_step2: "Steg 2: Konfigurer" +install_step2_desc: "Legg til i Claude Desktop-konfigurasjonen" +install_config_intro: "Legg til dette i Claude Desktop-konfigurasjonsfilen din:" +install_step3: "Steg 3: Test" +install_step3_desc: "Verifiser tilkoblingen" +install_test_intro: "Start Claude Desktop på nytt og spør: 'Vis meg mine inou-profiler'" +nav_install: "Koble til Claude" +nav_home: "Hjem" + +# Status +pending: "venter" +rate_limit_exceeded: "For mange registreringsforsøk fra din lokasjon. Prøv igjen i morgen." + +# Sex display +sex_male: "mann" +sex_female: "kvinne" +sex_na: "annet" + +# Friend invite email +friend_invite_subject: "Sjekk dette — %s" +friend_invite_p1: "Jeg bruker inou, den sikre måten å lagre helsedata og utforske dem med AI. Det holder all helseinformasjonen til familien min på ett sted — bildestudier, labresultater, journaler — og jeg tenkte det kanskje kunne være nyttig for deg også." +friend_invite_p2: "Den virkelige kraften ligger i å kunne bruke AI til å forstå alt: forstå hva en rapport faktisk betyr, oppdage trender over tid, eller bare stille spørsmål på vanlig norsk og få klare svar." +friend_invite_btn: "Oppdag inou" +friend_invite_dear: "Hei %s," +rel_0: "du" +rel_1: "Forelder" +rel_2: "Barn" +rel_3: "Ektefelle" +rel_4: "Søsken" +rel_5: "Verge" +rel_6: "Omsorgsgiver" +rel_7: "Coach" +rel_8: "Lege" +rel_9: "Venn" +rel_10: "Annet" +rel_99: "Demo" +select_relation: "Velg relasjon..." + +# Kategorier +category000: Bildediagnostikk +category001: Dokument +category002: Labresultat +category003: Genom +category004: Opplasting +category005: Konsultasjon +category006: Diagnose +category007: Bilderesultat +category008: EEG-resultat +category009: Vitalverdi +category010: Trening +category011: Medisin +category012: Tilskudd +category013: Ernæring +category014: Fertilitet +category015: Symptom +category016: Notat +category017: Sykehistorie +category018: Familiehistorie +category019: Kirurgi +category020: Sykehusopphold +category021: Fødselsdata +category022: Medisinsk utstyr +category023: Terapi +category024: Vurdering +category025: Helsepersonell +category026: Spørsmål + +# Genome +genome_english_only: "All genetisk informasjon er på engelsk. Bruk Claude for å diskutere det på norsk." +genome_variants: "varianter" +genome_hidden: "skjult" +genome_show_all_categories: "Vis alle %d kategorier" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/pt.yaml b/lang/pt.yaml new file mode 100644 index 0000000..2417885 --- /dev/null +++ b/lang/pt.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Seus dados de saúde." +headline_2: "Sua IA." +headline_3: "Suas respostas." +intro: "Carregue exames de imagem, laboratórios e mais. Conecte sua IA para ajudar a entender o que você está vendo." +email: "E-mail" +get_started: "Começar" +data_yours: "Seus dados permanecem seus" +never_training: "Nunca usado para treinamento" +never_training_desc: "Suas imagens nunca são usadas para treinar modelos de IA." +never_shared: "Nunca compartilhado" +never_shared_desc: "Nunca compartilhamos seus dados com ninguém." +encrypted: "Armazenamento criptografado" +encrypted_desc: "Todos os dados criptografados em repouso." +delete: "Exclua a qualquer momento" +delete_desc: "Seus dados, seu controle." + +# Verify +check_email: "Verifique seu e-mail" +code_sent_to: "Enviamos um código de 6 dígitos para" +verification_code: "Código de verificação" +verify: "Verificar" +use_different_email: "Usar outro e-mail" +invalid_code: "Código inválido ou expirado. Tente novamente." + +# Onboard +create_dossier: "Crie seu dossiê" +create_profile_intro: "Conte-nos sobre você para começar." +name: "Nome" +name_placeholder: "Seu nome" +date_of_birth: "Data de nascimento" +sex_at_birth: "Sexo ao nascer" +female: "Feminino" +male: "Masculino" +create_my_dossier: "Criar meu dossiê" + +# Minor error +must_be_18: "Você deve ter 18 anos para criar uma conta" +minor_explanation: "Se você está configurando isso para outra pessoa, comece com seu próprio perfil primeiro. Isso garante que só você possa acessar os dados de saúde dela." +minor_next_steps: "Após criar seu dossiê, você pode adicionar outros." +use_different_dob: "Usar outra data de nascimento" + +# Minor login block +minor_login_blocked: "Você deve ter 18 anos para entrar" +minor_ask_guardian: "Peça a %s para acessar seu dossiê." +minor_ask_guardian_generic: "Peça a um pai ou responsável para acessar seu dossiê." + +# Dashboard +dossiers: "Dossiês" +dossiers_intro: "Gerencie dados de saúde para você ou outros" +you: "você" +view: "Ver" +save: "Salvar" +cancel: "Cancelar" +add_dossier: "Adicionar dossiê" +edit_dossier: "Editar dossiê" +care: "cuidado" +logout: "Sair" + +# Profile detail +back_to_dossiers: "Voltar aos dossiês" +born: "Nascido" +no_access_yet: "Apenas você tem acesso." +people_with_access: "Pessoas com acesso" +share_access: "Compartilhar acesso" +can_edit: "pode adicionar dados" +remove: "Remover" +confirm_revoke: "Remover acesso?" + +# Dossier sections +section_imaging: "Imagens" +section_labs: "Laboratório" +section_uploads: "Arquivos" +section_vitals: "Sinais vitais" +section_medications: "Medicamentos" +section_records: "Prontuários" +section_journal: "Diário" +section_genetics: "Genética" +section_privacy: "Privacidade" + +# Section summaries +imaging_summary: "%d estudos · %d cortes" +no_imaging: "Sem dados de imagem" +no_lab_data: "Sem dados de laboratório" +no_genetics: "Sem dados genéticos" +no_files: "Sem arquivos" +no_upload_access: "You don't have permission to upload" +files_summary: "%d arquivos (%s)" +series_count: "%d séries" +vitals_desc: "Pressão arterial, frequência cardíaca, SpO₂, peso, glicose" +medications_desc: "Prescrições e suplementos" +records_desc: "Notas clínicas e prontuários médicos" +journal_desc: "Sintomas, dor e observações" + +# Buttons and actions +open_viewer: "Abrir visualizador" +manage: "Gerenciar" +show_all_studies: "Mostrar todos os %d estudos..." +coming_soon: "Em breve" + +# Upload page +upload_files: "Carregar dados de saúde" +upload_files_intro: "Carregue imagens médicas, resultados de laboratório, arquivos genômicos ou quaisquer documentos de saúde." +upload_hint_broad: "DICOM, PDF, CSV, VCF e mais" +uploading: "Carregando..." +files_uploaded: "arquivos carregados" +upload_scans: "Carregar exames" +upload_scans_intro: "Carregue uma pasta contendo arquivos DICOM do seu estudo de imagem." +upload_drop: "Clique ou arraste uma pasta aqui" +upload_hint: "Apenas pastas DICOM" + +# Add profile +add_dossier_intro: "Adicione alguém cujos dados de saúde você deseja gerenciar." +email_optional: "E-mail (opcional)" +email_optional_hint: "Se tiver 18 anos, pode entrar sozinho" +your_relation: "Seu parentesco com esta pessoa" +select_relation: "Selecione..." +i_provide_care: "Eu cuido desta pessoa" +i_am_their: "Eu sou..." + +# Share access +share_access_intro: "Convidar alguém para acessar" +their_relation: "Parentesco desta pessoa com o dossiê" +can_add_data: "Pode adicionar dados (suplementos, notas, etc.)" +send_invitation: "Enviar convite" +back_to_dossier: "Voltar ao dossiê" + +# Relations +my_role: "meu papel" +role: "role" + +# Invitation email +invite_email_subject: "%s adicionou você ao inou" +invite_email_body: "%s adicionou seu dossiê de saúde ao inou para que você possa visualizar e gerenciar seus dados médicos." +invite_email_cta: "Entrar para ver" +continue: "Continuar" + +# Access management +people_with_access_count: "pessoas com acesso" +view_audit_log: "Ver registro de atividades" +export_data: "Download my data" +relation_with: "Relação com" +audit_log: "Registro de atividades" +audit_log_intro: "Histórico de atividades para" +audit_log_desc: "Acompanhe quem acessou ou modificou este dossiê" + +# Install / Connect +install_title: "Conectar ao Claude" +install_intro: "Configure a ponte inou para permitir que o Claude analise seus dados de saúde" +install_step1: "Passo 1: Baixar" +install_step1_desc: "Obtenha a ponte para sua plataforma" +install_download_intro: "Baixe a ponte inou para seu sistema operacional:" +install_step2: "Passo 2: Configurar" +install_step2_desc: "Adicione à configuração do Claude Desktop" +install_config_intro: "Adicione isso ao seu arquivo de configuração do Claude Desktop:" +install_step3: "Passo 3: Testar" +install_step3_desc: "Verifique a conexão" +install_test_intro: "Reinicie o Claude Desktop e pergunte: 'Mostre meus perfis inou'" +nav_install: "Conectar ao Claude" +nav_home: "Início" + +# Status +pending: "pendente" +rate_limit_exceeded: "Muitas tentativas de cadastro da sua localização. Tente novamente amanhã." + +# Sex display +sex_male: "masculino" +sex_female: "feminino" +sex_na: "outro" + +# Friend invite email +friend_invite_subject: "Dá uma olhada — %s" +friend_invite_p1: "Tenho usado o inou, a forma segura de armazenar dados de saúde e explorá-los com IA. Ele mantém todas as informações de saúde da minha família num só lugar — exames de imagem, resultados de laboratório, prontuários médicos — e achei que também poderia ser útil para você." +friend_invite_p2: "O verdadeiro poder está em usar IA para entender tudo: compreender o que um relatório realmente significa, identificar tendências ao longo do tempo, ou simplesmente fazer perguntas em linguagem simples e obter respostas claras." +friend_invite_btn: "Conhecer o inou" +friend_invite_dear: "Olá %s," +rel_0: "você" +rel_1: "Pai/Mãe" +rel_2: "Filho/a" +rel_3: "Cônjuge" +rel_4: "Irmão/ã" +rel_5: "Tutor" +rel_6: "Cuidador" +rel_7: "Coach" +rel_8: "Médico" +rel_9: "Amigo" +rel_10: "Outro" +rel_99: "Demo" +select_relation: "Selecionar relação..." + +# Categorias +category000: Imagem médica +category001: Documento +category002: Resultado de laboratório +category003: Genoma +category004: Upload +category005: Consulta +category006: Diagnóstico +category007: Resultado de imagem +category008: Resultado de EEG +category009: Sinal vital +category010: Exercício +category011: Medicamento +category012: Suplemento +category013: Nutrição +category014: Fertilidade +category015: Sintoma +category016: Nota +category017: Histórico médico +category018: Histórico familiar +category019: Cirurgia +category020: Hospitalização +category021: Dados de nascimento +category022: Dispositivo médico +category023: Terapia +category024: Avaliação +category025: Prestador de saúde +category026: Pergunta + +# Genome +genome_english_only: "Todas as informações genéticas estão em inglês. Use o Claude para discuti-las em português." +genome_variants: "variantes" +genome_hidden: "ocultas" +genome_show_all_categories: "Mostrar todas as %d categorias" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/ru.yaml b/lang/ru.yaml new file mode 100644 index 0000000..eb32697 --- /dev/null +++ b/lang/ru.yaml @@ -0,0 +1,226 @@ +# Landing +headline_1: "Ваши медицинские данные." +headline_2: "Ваш ИИ." +headline_3: "Ваши ответы." +intro: "Загружайте снимки, анализы и многое другое. Подключите ИИ, чтобы понять, что вы видите." +email: "Электронная почта" +get_started: "Начать" +data_yours: "Ваши данные остаются вашими" +never_training: "Никогда не используются для обучения" +never_training_desc: "Ваши снимки никогда не используются для обучения моделей ИИ." +never_shared: "Никогда не передаются" +never_shared_desc: "Мы никогда не делимся вашими данными." +encrypted: "Зашифрованное хранилище" +encrypted_desc: "Все данные зашифрованы." +delete: "Удалить в любое время" +delete_desc: "Ваши данные, ваш контроль." + +# Verify +check_email: "Проверьте почту" +code_sent_to: "Мы отправили 6-значный код на" +verification_code: "Код подтверждения" +verify: "Подтвердить" +use_different_email: "Использовать другой адрес" +invalid_code: "Неверный или просроченный код. Попробуйте снова." + +# Onboard +create_dossier: "Создайте досье" +create_profile_intro: "Расскажите о себе, чтобы начать." +name: "Имя" +name_placeholder: "Ваше имя" +date_of_birth: "Дата рождения" +sex_at_birth: "Пол при рождении" +female: "Женский" +male: "Мужской" +create_my_dossier: "Создать моё досье" + +# Minor error +must_be_18: "Вам должно быть 18 лет для создания аккаунта" +minor_explanation: "Если вы настраиваете это для другого человека, сначала создайте свой профиль. Это гарантирует, что только вы сможете получить доступ к их медицинским данным." +minor_next_steps: "После создания досье вы сможете добавить других." +use_different_dob: "Использовать другую дату рождения" + +# Minor login block +minor_login_blocked: "Вам должно быть 18 лет для входа" +minor_ask_guardian: "Попросите %s открыть доступ к вашему досье." +minor_ask_guardian_generic: "Попросите родителя или опекуна открыть доступ к вашему досье." + +# Dashboard +dossiers: "Досье" +dossiers_intro: "Управляйте медицинскими данными для себя или других" +you: "вы" +view: "Просмотр" +add_dossier: "Добавить досье" +edit_dossier: "Редактировать досье" +care: "забота" +logout: "Выйти" + +# Profile detail +back_to_dossiers: "Назад к досье" +born: "Родился" +no_access_yet: "Только у вас есть доступ." +people_with_access: "Люди с доступом" +share_access: "Поделиться доступом" +can_edit: "может добавлять данные" +remove: "Удалить" +confirm_revoke: "Отозвать доступ?" + +# Dossier sections +section_imaging: "Снимки" +section_labs: "Анализы" +section_uploads: "Загрузки" +section_vitals: "Показатели" +section_medications: "Лекарства" +section_records: "Записи" +section_journal: "Дневник" + +# Section summaries +imaging_summary: "%d исследований · %d снимков" +no_imaging: "Нет снимков" +no_lab_data: "Нет анализов" +no_files: "Нет файлов" +no_upload_access: "You don't have permission to upload" +files_summary: "%d файлов (%s)" +series_count: "%d серий" +vitals_desc: "Давление, пульс, SpO₂, вес, глюкоза" +medications_desc: "Рецепты и добавки" +records_desc: "Клинические записи и медицинские документы" +journal_desc: "Симптомы, боль и наблюдения" + +# Buttons and actions +open_viewer: "Открыть просмотрщик" +manage: "Управление" +show_all_studies: "Показать все %d исследований..." +coming_soon: "Скоро" + +# Upload page +upload_files: "Загрузить медицинские данные" +upload_files_intro: "Загружайте медицинские снимки, анализы, геномные файлы или другие документы о здоровье." +upload_hint_broad: "DICOM, PDF, CSV, VCF и другие" +uploading: "Загрузка..." +files_uploaded: "файлов загружено" +upload_scans: "Загрузить снимки" +upload_scans_intro: "Загрузите папку с DICOM-файлами вашего исследования." +upload_drop: "Нажмите или перетащите папку сюда" +upload_hint: "Только папки DICOM" + +# Add profile +add_dossier_intro: "Добавьте человека, чьими медицинскими данными хотите управлять." +email_optional: "Email (необязательно)" +email_optional_hint: "Если им 18+, они смогут войти сами" +your_relation: "Ваши отношения с ними" +select_relation: "Выберите..." +i_provide_care: "Я забочусь об этом человеке" + +# Share access +share_access_intro: "Пригласите кого-то для доступа к" +their_relation: "Их отношения с этим человеком" +can_add_data: "Может добавлять данные (добавки, заметки и т.д.)" +send_invitation: "Отправить приглашение" +back_to_dossier: "Назад к досье" + +# Relations + +# Invitation email +invite_email_subject: "%s добавил вас в inou" +invite_email_body: "%s добавил ваше медицинское досье в inou, чтобы вы могли просматривать и управлять своими медицинскими данными." +invite_email_cta: "Войти для просмотра" +continue: "Продолжить" +i_am_their: "Я их..." + +# Simple relation names (for display) +my_role: "моя роль" +role: "role" +section_privacy: "Конфиденциальность" +people_with_access_count: "с доступом" +view_audit_log: "Журнал действий" +export_data: "Download my data" +relation_with: "Отношение с" +audit_log: "Журнал действий" +audit_log_intro: "История активности для" +audit_log_desc: "Отслеживание доступа и изменений в досье" +install_title: "Подключить к Claude" +install_intro: "Настройте мост inou, чтобы Claude мог анализировать ваши медицинские данные" +install_step1: "Шаг 1: Скачать" +install_step1_desc: "Получите мост для вашей платформы" +install_download_intro: "Скачайте мост inou для вашей операционной системы:" +install_step2: "Шаг 2: Настроить" +install_step2_desc: "Добавьте в конфигурацию Claude Desktop" +install_config_intro: "Добавьте это в файл конфигурации Claude Desktop:" +install_step3: "Шаг 3: Проверить" +install_step3_desc: "Проверьте соединение" +install_test_intro: "Перезапустите Claude Desktop и спросите: 'Покажи мои профили inou'" +nav_install: "Подключить к Claude" +nav_home: "Главная" +rate_limit_exceeded: "Слишком много попыток регистрации с вашего местоположения. Пожалуйста, попробуйте завтра." + +sex_male: "мужской" +sex_female: "женский" +sex_na: "другой" + +# Friend invite email +friend_invite_subject: "Посмотри — %s" +friend_invite_p1: "Я использую inou — безопасный способ хранить медицинские данные и анализировать их с помощью ИИ. Там хранится вся медицинская информация моей семьи — снимки, результаты анализов, медицинские записи — и я подумал, что тебе тоже может пригодиться." +friend_invite_p2: "Настоящая сила в том, что можно использовать ИИ, чтобы разобраться во всём: понять, что на самом деле означает заключение, отследить тенденции или просто задать вопросы простым языком и получить понятные ответы." +friend_invite_btn: "Посмотреть inou" +friend_invite_dear: "Привет, %s!" +rel_0: "ты" +rel_1: "Родитель" +rel_2: "Ребёнок" +rel_3: "Супруг" +rel_4: "Брат/Сестра" +rel_5: "Опекун" +rel_6: "Сиделка" +rel_7: "Тренер" +rel_8: "Врач" +rel_9: "Друг" +rel_10: "Другое" +rel_99: "Demo" +select_relation: "Выберите отношение..." + +# Категории +category000: Визуализация +category001: Документ +category002: Анализ +category003: Геном +category004: Загрузка +category005: Консультация +category006: Диагноз +category007: Результат визуализации +category008: Результат ЭЭГ +category009: Показатель здоровья +category010: Физическая активность +category011: Лекарство +category012: Добавка +category013: Питание +category014: Фертильность +category015: Симптом +category016: Заметка +category017: История болезни +category018: Семейный анамнез +category019: Операция +category020: Госпитализация +category021: Данные о рождении +category022: Медицинское устройство +category023: Терапия +category024: Оценка +category025: Медицинский работник +category026: Вопрос + +# Genome +genome_english_only: "Вся генетическая информация на английском языке. Используйте Claude, чтобы обсудить её на русском." +genome_variants: "вариантов" +genome_hidden: "скрыто" +genome_show_all_categories: "Показать все %d категорий" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/sv.yaml b/lang/sv.yaml new file mode 100644 index 0000000..c4be91d --- /dev/null +++ b/lang/sv.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "Din hälsodata." +headline_2: "Din AI." +headline_3: "Dina svar." +intro: "Ladda upp bilddiagnostik, labbresultat och mer. Anslut din AI för att hjälpa dig förstå vad du tittar på." +email: "E-post" +get_started: "Kom igång" +data_yours: "Din data förblir din" +never_training: "Används aldrig för träning" +never_training_desc: "Dina bilder används aldrig för att träna AI-modeller." +never_shared: "Delas aldrig" +never_shared_desc: "Vi delar aldrig din data med någon." +encrypted: "Krypterad lagring" +encrypted_desc: "All data krypterad i vila." +delete: "Radera när som helst" +delete_desc: "Din data, din kontroll." + +# Verify +check_email: "Kolla din e-post" +code_sent_to: "Vi skickade en 6-siffrig kod till" +verification_code: "Verifieringskod" +verify: "Verifiera" +use_different_email: "Använd en annan e-post" +invalid_code: "Ogiltig eller utgången kod. Försök igen." + +# Onboard +create_dossier: "Skapa din dossier" +create_profile_intro: "Berätta om dig själv för att komma igång." +name: "Namn" +name_placeholder: "Ditt namn" +date_of_birth: "Födelsedatum" +sex_at_birth: "Kön vid födseln" +female: "Kvinna" +male: "Man" +create_my_dossier: "Skapa min dossier" + +# Minor error +must_be_18: "Du måste vara 18 för att skapa ett konto" +minor_explanation: "Om du skapar detta för någon annan, börja med din egen profil först. Detta säkerställer att bara du kan komma åt deras hälsodata." +minor_next_steps: "Efter att du skapat din dossier kan du lägga till andra." +use_different_dob: "Använd ett annat födelsedatum" + +# Minor login block +minor_login_blocked: "Du måste vara 18 för att logga in" +minor_ask_guardian: "Be %s om åtkomst till din dossier." +minor_ask_guardian_generic: "Be en förälder eller vårdnadshavare om åtkomst till din dossier." + +# Dashboard +dossiers: "Dossier" +dossiers_intro: "Hantera hälsodata för dig själv eller andra" +you: "du" +view: "Visa" +save: "Spara" +cancel: "Avbryt" +add_dossier: "Lägg till dossier" +edit_dossier: "Redigera dossier" +care: "vård" +logout: "Logga ut" + +# Profile detail +back_to_dossiers: "Tillbaka till dossier" +born: "Född" +no_access_yet: "Bara du har åtkomst." +people_with_access: "Personer med åtkomst" +share_access: "Dela åtkomst" +can_edit: "kan lägga till data" +remove: "Ta bort" +confirm_revoke: "Ta bort åtkomst?" + +# Dossier sections +section_imaging: "Bilddiagnostik" +section_labs: "Labb" +section_uploads: "Uppladdningar" +section_vitals: "Vitalvärden" +section_medications: "Läkemedel" +section_records: "Journaler" +section_journal: "Dagbok" +section_genetics: "Genetik" +section_privacy: "Integritet" + +# Section summaries +imaging_summary: "%d undersökningar · %d snitt" +no_imaging: "Ingen bilddata" +no_lab_data: "Ingen labbdata" +no_genetics: "Ingen genetisk data" +no_files: "Inga filer" +no_upload_access: "You don't have permission to upload" +files_summary: "%d filer (%s)" +series_count: "%d serier" +vitals_desc: "Blodtryck, puls, SpO₂, vikt, blodsocker" +medications_desc: "Recept och kosttillskott" +records_desc: "Kliniska anteckningar och journaler" +journal_desc: "Symtom, smärta och observationer" + +# Buttons and actions +open_viewer: "Öppna visare" +manage: "Hantera" +show_all_studies: "Visa alla %d undersökningar..." +coming_soon: "Kommer snart" + +# Upload page +upload_files: "Ladda upp hälsodata" +upload_files_intro: "Ladda upp medicinsk bilddiagnostik, labbresultat, genomfiler eller hälsorelaterade dokument." +upload_hint_broad: "DICOM, PDF, CSV, VCF med mera" +uploading: "Laddar upp..." +files_uploaded: "filer uppladdade" +upload_scans: "Ladda upp skanningar" +upload_scans_intro: "Ladda upp en mapp med DICOM-filer från din bildundersökning." +upload_drop: "Klicka eller dra en mapp hit" +upload_hint: "Endast DICOM-mappar" + +# Add profile +add_dossier_intro: "Lägg till någon vars hälsodata du vill hantera." +email_optional: "E-post (valfritt)" +email_optional_hint: "Om de är 18 kan de logga in själva" +your_relation: "Din relation till dem" +select_relation: "Välj..." +i_provide_care: "Jag ger vård till denna person" +i_am_their: "Jag är deras..." + +# Share access +share_access_intro: "Bjud in någon att få åtkomst" +their_relation: "Deras relation till denna person" +can_add_data: "Kan lägga till data (kosttillskott, anteckningar, etc.)" +send_invitation: "Skicka inbjudan" +back_to_dossier: "Tillbaka till dossier" + +# Relations +my_role: "min roll" +role: "role" + +# Invitation email +invite_email_subject: "%s lade till dig i inou" +invite_email_body: "%s lade till din hälsodossier i inou så att du kan visa och hantera din medicinska data." +invite_email_cta: "Logga in för att visa" +continue: "Fortsätt" + +# Access management +people_with_access_count: "personer med åtkomst" +view_audit_log: "Visa aktivitetslogg" +export_data: "Download my data" +relation_with: "Relation till" +audit_log: "Aktivitetslogg" +audit_log_intro: "Aktivitetshistorik för" +audit_log_desc: "Spåra vem som har åtkomst till eller ändrat denna dossier" + +# Install / Connect +install_title: "Anslut till Claude" +install_intro: "Konfigurera inou-bryggan för att låta Claude analysera din hälsodata" +install_step1: "Steg 1: Ladda ner" +install_step1_desc: "Hämta bryggan för din plattform" +install_download_intro: "Ladda ner inou-bryggan för ditt operativsystem:" +install_step2: "Steg 2: Konfigurera" +install_step2_desc: "Lägg till i Claude Desktop-konfigurationen" +install_config_intro: "Lägg till detta i din Claude Desktop-konfigurationsfil:" +install_step3: "Steg 3: Testa" +install_step3_desc: "Verifiera anslutningen" +install_test_intro: "Starta om Claude Desktop och fråga: 'Visa mina inou-profiler'" +nav_install: "Anslut till Claude" +nav_home: "Hem" + +# Status +pending: "väntande" +rate_limit_exceeded: "För många registreringsförsök från din plats. Försök igen imorgon." + +# Sex display +sex_male: "man" +sex_female: "kvinna" +sex_na: "annat" + +# Friend invite email +friend_invite_subject: "Kolla in det här — %s" +friend_invite_p1: "Jag använder inou, det säkra sättet att lagra hälsodata och utforska den med AI. Det håller all min familjs hälsoinformation på ett ställe — bildstudier, labbresultat, journaler — och jag tänkte att det kanske kunde vara användbart för dig också." +friend_invite_p2: "Den verkliga kraften ligger i att kunna använda AI för att förstå allt: förstå vad en rapport faktiskt betyder, upptäcka trender över tid, eller bara ställa frågor på vanlig svenska och få tydliga svar." +friend_invite_btn: "Upptäck inou" +friend_invite_dear: "Hej %s," +rel_0: "du" +rel_1: "Förälder" +rel_2: "Barn" +rel_3: "Make/Maka" +rel_4: "Syskon" +rel_5: "Vårdnadshavare" +rel_6: "Vårdgivare" +rel_7: "Coach" +rel_8: "Läkare" +rel_9: "Vän" +rel_10: "Annat" +rel_99: "Demo" +select_relation: "Välj relation..." + +# Kategorier +category000: Bilddiagnostik +category001: Dokument +category002: Labbresultat +category003: Genom +category004: Uppladdning +category005: Konsultation +category006: Diagnos +category007: Bildresultat +category008: EEG-resultat +category009: Vitalvärde +category010: Träning +category011: Läkemedel +category012: Tillskott +category013: Näring +category014: Fertilitet +category015: Symptom +category016: Anteckning +category017: Sjukdomshistorik +category018: Familjehistorik +category019: Kirurgi +category020: Sjukhusvistelse +category021: Födelsedata +category022: Medicinsk utrustning +category023: Terapi +category024: Bedömning +category025: Vårdgivare +category026: Fråga + +# Genome +genome_english_only: "All genetisk information är på engelska. Använd Claude för att diskutera det på svenska." +genome_variants: "varianter" +genome_hidden: "dolda" +genome_show_all_categories: "Visa alla %d kategorier" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lang/zh.yaml b/lang/zh.yaml new file mode 100644 index 0000000..680b577 --- /dev/null +++ b/lang/zh.yaml @@ -0,0 +1,236 @@ +# Landing +headline_1: "你的健康数据。" +headline_2: "你的AI。" +headline_3: "你的答案。" +intro: "上传影像、化验等。连接AI帮助你理解所看到的内容。" +email: "电子邮件" +get_started: "开始使用" +data_yours: "你的数据归你所有" +never_training: "从不用于训练" +never_training_desc: "你的图像从不用于训练AI模型。" +never_shared: "从不共享" +never_shared_desc: "我们从不与任何人共享你的数据。" +encrypted: "加密存储" +encrypted_desc: "所有数据静态加密。" +delete: "随时删除" +delete_desc: "你的数据,你做主。" + +# Verify +check_email: "检查你的邮箱" +code_sent_to: "我们已发送6位验证码到" +verification_code: "验证码" +verify: "验证" +use_different_email: "使用其他邮箱" +invalid_code: "验证码无效或已过期。请重试。" + +# Onboard +create_dossier: "创建你的档案" +create_profile_intro: "告诉我们关于你的信息以开始使用。" +name: "姓名" +name_placeholder: "你的姓名" +date_of_birth: "出生日期" +sex_at_birth: "出生时性别" +female: "女" +male: "男" +create_my_dossier: "创建我的档案" + +# Minor error +must_be_18: "你必须年满18岁才能创建账户" +minor_explanation: "如果你是为他人设置,请先从你自己的个人资料开始。这确保只有你才能访问他们的健康数据。" +minor_next_steps: "创建档案后,你可以添加其他人。" +use_different_dob: "使用其他出生日期" + +# Minor login block +minor_login_blocked: "你必须年满18岁才能登录" +minor_ask_guardian: "请联系%s访问你的档案。" +minor_ask_guardian_generic: "请联系父母或监护人访问你的档案。" + +# Dashboard +dossiers: "档案" +dossiers_intro: "管理你或他人的健康数据" +you: "你" +view: "查看" +save: "保存" +cancel: "取消" +add_dossier: "添加档案" +edit_dossier: "编辑档案" +care: "护理" +logout: "退出登录" + +# Profile detail +back_to_dossiers: "返回档案" +born: "出生" +no_access_yet: "只有你有访问权限。" +people_with_access: "有访问权限的人" +share_access: "共享访问权限" +can_edit: "可以添加数据" +remove: "移除" +confirm_revoke: "移除访问权限?" + +# Dossier sections +section_imaging: "影像" +section_labs: "化验" +section_uploads: "上传" +section_vitals: "生命体征" +section_medications: "药物" +section_records: "记录" +section_journal: "日记" +section_genetics: "遗传" +section_privacy: "隐私" + +# Section summaries +imaging_summary: "%d项检查 · %d张切片" +no_imaging: "无影像数据" +no_lab_data: "无化验数据" +no_genetics: "无遗传数据" +no_files: "无文件" +no_upload_access: "You don't have permission to upload" +files_summary: "%d个文件(%s)" +series_count: "%d个序列" +vitals_desc: "血压、心率、血氧、体重、血糖" +medications_desc: "处方药和补充剂" +records_desc: "临床记录和病历" +journal_desc: "症状、疼痛和观察" + +# Buttons and actions +open_viewer: "打开查看器" +manage: "管理" +show_all_studies: "显示全部%d项检查..." +coming_soon: "即将推出" + +# Upload page +upload_files: "上传健康数据" +upload_files_intro: "上传医学影像、化验结果、基因组文件或任何健康相关文档。" +upload_hint_broad: "DICOM、PDF、CSV、VCF等" +uploading: "上传中..." +files_uploaded: "文件已上传" +upload_scans: "上传扫描" +upload_scans_intro: "上传包含影像检查DICOM文件的文件夹。" +upload_drop: "点击或拖拽文件夹到此处" +upload_hint: "仅限DICOM文件夹" + +# Add profile +add_dossier_intro: "添加你想要管理其健康数据的人。" +email_optional: "电子邮件(可选)" +email_optional_hint: "如果年满18岁,他们可以自己登录" +your_relation: "你与此人的关系" +select_relation: "选择..." +i_provide_care: "我为此人提供护理" +i_am_their: "我是他们的..." + +# Share access +share_access_intro: "邀请他人访问" +their_relation: "他们与此人的关系" +can_add_data: "可以添加数据(补充剂、备注等)" +send_invitation: "发送邀请" +back_to_dossier: "返回档案" + +# Relations +my_role: "我的角色" +role: "role" + +# Invitation email +invite_email_subject: "%s将你添加到inou" +invite_email_body: "%s将你的健康档案添加到inou,这样你可以查看和管理你的医疗数据。" +invite_email_cta: "登录查看" +continue: "继续" + +# Access management +people_with_access_count: "有访问权限的人" +view_audit_log: "查看审计日志" +export_data: "Download my data" +relation_with: "与...的关系" +audit_log: "审计日志" +audit_log_intro: "活动历史" +audit_log_desc: "跟踪谁访问或修改了此档案" + +# Install / Connect +install_title: "连接到Claude" +install_intro: "设置inou桥接器以让Claude分析你的健康数据" +install_step1: "步骤1:下载" +install_step1_desc: "获取适合你平台的桥接器" +install_download_intro: "为你的操作系统下载inou桥接器:" +install_step2: "步骤2:配置" +install_step2_desc: "添加到Claude桌面配置" +install_config_intro: "将此添加到你的Claude桌面配置文件:" +install_step3: "步骤3:测试" +install_step3_desc: "验证连接" +install_test_intro: "重启Claude桌面并询问:'显示我的inou档案'" +nav_install: "连接到Claude" +nav_home: "首页" + +# Status +pending: "待处理" +rate_limit_exceeded: "你所在位置的注册尝试次数过多。请明天再试。" + +# Sex display +sex_male: "男" +sex_female: "女" +sex_na: "其他" + +# Friend invite email +friend_invite_subject: "看看这个 — %s" +friend_invite_p1: "我一直在用inou,一种安全存储健康数据并用AI分析的方式。它把我家人的所有健康信息都放在一个地方——影像检查、化验结果、病历——我想这对你也可能有用。" +friend_invite_p2: "真正的力量在于能用AI理解一切:理解报告实际意味着什么,发现随时间变化的趋势,或者只是用普通话提问并获得清晰的答案。" +friend_invite_btn: "了解inou" +friend_invite_dear: "%s," +rel_0: "你" +rel_1: "父母" +rel_2: "子女" +rel_3: "配偶" +rel_4: "兄弟姐妹" +rel_5: "监护人" +rel_6: "护理者" +rel_7: "教练" +rel_8: "医生" +rel_9: "朋友" +rel_10: "其他" +rel_99: "Demo" +select_relation: "选择关系..." + +# 类别 +category000: 影像 +category001: 文档 +category002: 检验结果 +category003: 基因组 +category004: 上传 +category005: 咨询 +category006: 诊断 +category007: 影像检查结果 +category008: 脑电图结果 +category009: 生命体征 +category010: 运动 +category011: 药物 +category012: 补充剂 +category013: 营养 +category014: 生育 +category015: 症状 +category016: 笔记 +category017: 病史 +category018: 家族史 +category019: 手术 +category020: 住院 +category021: 出生数据 +category022: 医疗设备 +category023: 治疗 +category024: 评估 +category025: 医疗服务提供者 +category026: 问题 + +# Genome +genome_english_only: "所有基因信息均为英文。使用Claude可以用中文讨论。" +genome_variants: "变异" +genome_hidden: "隐藏" +genome_show_all_categories: "显示全部%d个类别" + +# API +api_token: "API Token" +api_token_use: "[EN] Use this token to authenticate API requests:" +api_token_warning: "[EN] Keep this private. Anyone with this token can access your health data." +api_token_none: "[EN] Generate a token to access the API programmatically or connect AI assistants." +api_token_generate: "Generate Token" +api_token_regenerate: "Regenerate Token" +api_token_regenerate_confirm: "[EN] This will invalidate your current token. Any connected apps will need to be updated." +api_authentication: "Authentication" +api_auth_instructions: "[EN] Include your API token in the Authorization header:" +copy: "Copy" diff --git a/lib/._db_schema.go b/lib/._db_schema.go new file mode 100644 index 0000000..416537b Binary files /dev/null and b/lib/._db_schema.go differ diff --git a/lib/access.go b/lib/access.go new file mode 100644 index 0000000..ad13324 --- /dev/null +++ b/lib/access.go @@ -0,0 +1,384 @@ +package lib + +import ( + "fmt" + "sync" + "time" +) + +// ============================================================================ +// RBAC Access Control - Rock-solid permission enforcement +// ============================================================================ +// +// Permission checks happen at the LOWEST LEVEL in v2.go functions. +// There is NO WAY to bypass RBAC - every data access function checks permissions. +// +// Operations: +// r = read - view data +// w = write - create/update data +// d = delete - remove data +// m = manage - grant/revoke access to others +// +// ============================================================================ + +// AccessContext represents who is making the request +type AccessContext struct { + AccessorID string // dossier_id of the requester + IsSystem bool // bypass RBAC (internal operations only) +} + +// SystemContext is used for internal operations that bypass RBAC +var SystemContext = &AccessContext{IsSystem: true} + +// ErrAccessDenied is returned when permission check fails +var ErrAccessDenied = fmt.Errorf("access denied") + +// ErrNoAccessor is returned when AccessorID is empty and IsSystem is false +var ErrNoAccessor = fmt.Errorf("no accessor specified") + +// ============================================================================ +// Permission Cache +// ============================================================================ + +type cacheEntry struct { + ops string // "r", "rw", "rwd", "rwdm" + expiresAt time.Time +} + +type permissionCache struct { + mu sync.RWMutex + cache map[string]map[string]map[string]*cacheEntry // [accessor][dossier][entry_id] -> ops + ttl time.Duration +} + +var permCache = &permissionCache{ + cache: make(map[string]map[string]map[string]*cacheEntry), + ttl: time.Hour, +} + +// get returns cached ops or empty string if not found/expired +func (c *permissionCache) get(accessorID, dossierID, entryID string) string { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache[accessorID] == nil { + return "" + } + if c.cache[accessorID][dossierID] == nil { + return "" + } + entry := c.cache[accessorID][dossierID][entryID] + if entry == nil || time.Now().After(entry.expiresAt) { + return "" + } + return entry.ops +} + +// set stores ops in cache +func (c *permissionCache) set(accessorID, dossierID, entryID, ops string) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cache[accessorID] == nil { + c.cache[accessorID] = make(map[string]map[string]*cacheEntry) + } + if c.cache[accessorID][dossierID] == nil { + c.cache[accessorID][dossierID] = make(map[string]*cacheEntry) + } + c.cache[accessorID][dossierID][entryID] = &cacheEntry{ + ops: ops, + expiresAt: time.Now().Add(c.ttl), + } +} + +// InvalidateCacheForAccessor clears all cached permissions for an accessor +func InvalidateCacheForAccessor(accessorID string) { + permCache.mu.Lock() + defer permCache.mu.Unlock() + delete(permCache.cache, accessorID) +} + +// InvalidateCacheForDossier clears all cached permissions for a dossier +func InvalidateCacheForDossier(dossierID string) { + permCache.mu.Lock() + defer permCache.mu.Unlock() + for accessorID := range permCache.cache { + delete(permCache.cache[accessorID], dossierID) + } +} + +// InvalidateCacheAll clears entire cache +func InvalidateCacheAll() { + permCache.mu.Lock() + defer permCache.mu.Unlock() + permCache.cache = make(map[string]map[string]map[string]*cacheEntry) +} + +// ============================================================================ +// Core Permission Check (used by v2.go functions) +// ============================================================================ + +// checkAccess is the internal permission check called by v2.go data functions. +// Returns nil if allowed, ErrAccessDenied if not. +// +// Algorithm: +// 1. ctx == nil → allow (backward compatibility, internal operations) +// 2. ctx.IsSystem → allow +// 3. Accessor == dossier owner → allow (full access to own data) +// 4. Check grants for accessor on this dossier: +// a. Entry-specific grant (entry_id matches) +// b. Walk up parent_id chain checking each level +// c. Root grant (entry_id = "") +// 5. No matching grant → deny +func checkAccess(ctx *AccessContext, dossierID, entryID string, op rune) error { + // 1. nil context allows (for internal operations that pass nil) + if ctx == nil { + return nil + } + + // 2. System context bypasses all checks + if ctx.IsSystem { + return nil + } + + // Must have accessor for non-system context + if ctx.AccessorID == "" { + return ErrNoAccessor + } + + // 3. Owner has full access to own data + if ctx.AccessorID == dossierID { + return nil + } + + // 4. Check grants + ops := getEffectiveOps(ctx.AccessorID, dossierID, entryID) + if hasOp(ops, op) { + return nil + } + + // 5. Deny + return ErrAccessDenied +} + +// CheckAccess is the exported version for use by API/Portal code. +// Same algorithm as checkAccess but requires non-nil context. +func CheckAccess(ctx *AccessContext, dossierID, entryID string, op rune) error { + if ctx == nil { + return ErrNoAccessor + } + return checkAccess(ctx, dossierID, entryID, op) +} + +// getEffectiveOps returns the ops string for accessor on dossier/entry +// Uses cache, falls back to database lookup +func getEffectiveOps(accessorID, dossierID, entryID string) string { + // Check cache first + if ops := permCache.get(accessorID, dossierID, entryID); ops != "" { + return ops + } + + // Load grants from database (bypasses RBAC - internal function) + grants, err := accessGrantListRaw(&PermissionFilter{ + DossierID: dossierID, + GranteeID: accessorID, + }) + if err != nil || len(grants) == 0 { + // Cache negative result + permCache.set(accessorID, dossierID, entryID, "") + return "" + } + + // Find most specific matching grant + ops := findMatchingOps(grants, entryID) + permCache.set(accessorID, dossierID, entryID, ops) + return ops +} + +// findMatchingOps finds the most specific grant that applies to entryID +// Priority: entry-specific > parent chain > category > root +func findMatchingOps(grants []*Access, entryID string) string { + // Build entry->ops map for quick lookup + grantMap := make(map[string]string) // entry_id -> ops (empty key = root) + for _, g := range grants { + existing := grantMap[g.EntryID] + // Merge ops (keep most permissive) + grantMap[g.EntryID] = mergeOps(existing, g.Ops) + } + + // 1. Check entry-specific grant + if entryID != "" { + if ops, ok := grantMap[entryID]; ok { + return ops + } + + // 2. Walk up parent chain (using raw function to avoid RBAC recursion) + // Also track entry category for category grant check + var entryCategory int + currentID := entryID + for i := 0; i < 100; i++ { // max depth to prevent infinite loops + entry, err := entryGetRaw(currentID) + if err != nil || entry == nil { + break + } + if i == 0 { + entryCategory = entry.Category + } + if entry.ParentID == "" { + break + } + if ops, ok := grantMap[entry.ParentID]; ok { + return ops + } + currentID = entry.ParentID + } + + // 3. Check category grant (cat:{category_id}) + if entryCategory > 0 { + catKey := fmt.Sprintf("cat:%d", entryCategory) + if ops, ok := grantMap[catKey]; ok { + return ops + } + } + } + + // 4. Check root grant + if ops, ok := grantMap[""]; ok { + return ops + } + + return "" +} + +// mergeOps combines two ops strings, keeping the most permissive +func mergeOps(a, b string) string { + ops := make(map[rune]bool) + for _, c := range a { + ops[c] = true + } + for _, c := range b { + ops[c] = true + } + result := "" + for _, c := range "rwdm" { + if ops[c] { + result += string(c) + } + } + return result +} + +// hasOp checks if ops string contains the requested operation +func hasOp(ops string, op rune) bool { + for _, c := range ops { + if c == op { + return true + } + } + return false +} + +// accessGrantListRaw loads grants without RBAC check (for internal use by permission system) +func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) { + q := "SELECT * FROM access WHERE 1=1" + args := []any{} + + if f != nil { + if f.DossierID != "" { + q += " AND dossier_id = ?" + args = append(args, f.DossierID) + } + if f.GranteeID != "" { + q += " AND grantee_id = ?" + args = append(args, f.GranteeID) + } + if f.EntryID != "" { + q += " AND entry_id = ?" + args = append(args, f.EntryID) + } + if f.Role != "" { + q += " AND role = ?" + args = append(args, CryptoEncrypt(f.Role)) + } + } + + q += " ORDER BY created_at DESC" + + var result []*Access + err := Query(q, args, &result) + return result, err +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +// CanAccessDossier returns true if accessor can read dossier (for quick checks) +func CanAccessDossier(ctx *AccessContext, dossierID string) bool { + return CheckAccess(ctx, dossierID, "", 'r') == nil +} + +// CanManageDossier returns true if accessor can manage permissions for dossier +func CanManageDossier(ctx *AccessContext, dossierID string) bool { + return CheckAccess(ctx, dossierID, "", 'm') == nil +} + +// GetAccessorOps returns the operations accessor can perform on dossier/entry +// Returns empty string if no access +func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string { + if ctx == nil || ctx.AccessorID == "" { + if ctx != nil && ctx.IsSystem { + return "rwdm" + } + return "" + } + + // Owner has full access + if ctx.AccessorID == dossierID { + return "rwdm" + } + + return getEffectiveOps(ctx.AccessorID, dossierID, entryID) +} + +// DossierListAccessible returns all dossiers accessible by ctx.AccessorID +func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) { + if ctx == nil || ctx.AccessorID == "" { + if ctx != nil && ctx.IsSystem { + // System context: return all + return DossierList(nil, nil) + } + return nil, ErrNoAccessor + } + + // Get accessor's own dossier + own, err := dossierGetRaw(ctx.AccessorID) + if err != nil { + // Invalid accessor (doesn't exist) - treat as unauthorized + return nil, ErrAccessDenied + } + + result := []*Dossier{own} + + // Get all grants where accessor is grantee + grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID}) + if err != nil { + return result, nil // Return just own dossier on error + } + + // Collect unique dossier IDs with read permission + seen := map[string]bool{ctx.AccessorID: true} + for _, g := range grants { + if g.DossierID == "" || seen[g.DossierID] { + continue + } + if g.CanRead() { + seen[g.DossierID] = true + if d, err := dossierGetRaw(g.DossierID); err == nil { + result = append(result, d) + } + } + } + + return result, nil +} diff --git a/lib/config.go b/lib/config.go new file mode 100644 index 0000000..b2d4014 --- /dev/null +++ b/lib/config.go @@ -0,0 +1,55 @@ +package lib + +import ( + "log" + "os" + "strings" +) + +// Production paths - single source of truth +const ( + DBPathDefault = "/tank/inou/data/inou.db" + KeyPathDefault = "/tank/inou/master.key" + configFile = "/tank/inou/anthropic.env" +) + +// Init initializes all lib subsystems (crypto, database) with production defaults +func Init() error { + if err := CryptoInit(KeyPathDefault); err != nil { + return err + } + return DBInit(DBPathDefault) +} + +var ( + GeminiKey string = "AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA" + AnthropicKey string = "" +) + +func ConfigInit() { + data, err := os.ReadFile(configFile) + if err != nil { + log.Printf("Warning: %s not found: %v", configFile, err) + return + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "GEMINI_API_KEY": + GeminiKey = value + case "ANTHROPIC_API_KEY": + AnthropicKey = value + } + } +} diff --git a/lib/crypto.go b/lib/crypto.go new file mode 100644 index 0000000..57b5ee0 --- /dev/null +++ b/lib/crypto.go @@ -0,0 +1,183 @@ +package lib + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "time" +) + +// STUBBED: fips140 removed for local testing + +var masterKey []byte + +// CryptoInit loads the master key from file +func CryptoInit(keyPath string) error { + var err error + masterKey, err = os.ReadFile(keyPath) + if err != nil { + return err + } + if len(masterKey) != 32 { + return fmt.Errorf("master key must be 32 bytes, got %d", len(masterKey)) + } + return nil +} + +// CryptoFIPSEnabled returns true if FIPS 140-3 mode is active +// STUBBED for local testing +func CryptoFIPSEnabled() bool { + return false +} + +// CryptoStatus returns a string describing crypto status +// STUBBED for local testing +func CryptoStatus() string { + return "FIPS 140-3 STUBBED (local testing)" +} + +// deriveNonce derives a deterministic nonce from data using AES +func deriveNonce(data []byte, nonceSize int) []byte { + block, _ := aes.NewCipher(masterKey) + nonce := make([]byte, nonceSize) + for i := 0; i < len(data); i += 16 { + chunk := make([]byte, 16) + end := i + 16 + if end > len(data) { + end = len(data) + } + copy(chunk, data[i:end]) + encrypted := make([]byte, 16) + block.Encrypt(encrypted, chunk) + for j := 0; j < nonceSize && j < 16; j++ { + nonce[j] ^= encrypted[j] + } + } + return nonce +} + +// CryptoEncryptBytes encrypts binary data with AES-GCM (deterministic nonce) +func CryptoEncryptBytes(plaintext []byte) []byte { + if len(plaintext) == 0 { + return nil + } + block, _ := aes.NewCipher(masterKey) + gcm, _ := cipher.NewGCM(block) + nonce := deriveNonce(plaintext, gcm.NonceSize()) + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + return append(nonce, ciphertext...) +} + +// CryptoDecryptBytes decrypts binary data with AES-GCM +func CryptoDecryptBytes(ciphertext []byte) ([]byte, error) { + if len(ciphertext) == 0 { + return nil, nil + } + block, err := aes.NewCipher(masterKey) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(ciphertext) < gcm.NonceSize() { + return nil, fmt.Errorf("ciphertext too short") + } + nonce := ciphertext[:gcm.NonceSize()] + return gcm.Open(nil, nonce, ciphertext[gcm.NonceSize():], nil) +} + +// CryptoEncrypt encrypts a string, returns base64 +func CryptoEncrypt(plaintext string) string { + if plaintext == "" { + return "" + } + encrypted := CryptoEncryptBytes([]byte(plaintext)) + return base64.StdEncoding.EncodeToString(encrypted) +} + +// CryptoDecrypt decrypts a base64 string +func CryptoDecrypt(ciphertext string) string { + if ciphertext == "" { + return "" + } + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "" + } + result, err := CryptoDecryptBytes(data) + if err != nil { + return "" + } + return string(result) +} + +// cryptoEncryptSIV - internal, used by DB wrappers +func cryptoEncryptSIV(plaintext string) string { + return CryptoEncrypt(plaintext) +} + +// cryptoDecryptSIV - internal, used by DB wrappers +func cryptoDecryptSIV(ciphertext string) string { + return CryptoDecrypt(ciphertext) +} + +// NewID generates a cryptographically random 63-bit positive ID as hex string +func NewID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + panic(err) + } + b[0] &= 0x7F // Clear high bit to ensure positive int64 + return fmt.Sprintf("%016x", + int64(b[0])<<56 | int64(b[1])<<48 | int64(b[2])<<40 | int64(b[3])<<32 | + int64(b[4])<<24 | int64(b[5])<<16 | int64(b[6])<<8 | int64(b[7])) +} + +// Token holds the authenticated dossier and expiration +type Token struct { + DossierID string `json:"d"` + Exp int64 `json:"exp"` +} + +// TokenCreate creates an encrypted token for a dossier +// duration is how long until expiration (e.g., 4*time.Hour) +func TokenCreate(dossierID string, duration time.Duration) string { + t := Token{ + DossierID: dossierID, + Exp: time.Now().Unix() + int64(duration.Seconds()), + } + data, _ := json.Marshal(t) + encrypted := CryptoEncryptBytes(data) + return base64.URLEncoding.EncodeToString(encrypted) +} + +// TokenParse decrypts and validates a token +// Returns the token if valid, or error if expired/invalid +func TokenParse(tokenStr string) (*Token, error) { + data, err := base64.URLEncoding.DecodeString(tokenStr) + if err != nil { + return nil, fmt.Errorf("invalid token encoding") + } + + decrypted, err := CryptoDecryptBytes(data) + if err != nil { + return nil, fmt.Errorf("invalid token") + } + + var t Token + if err := json.Unmarshal(decrypted, &t); err != nil { + return nil, fmt.Errorf("invalid token format") + } + + if time.Now().Unix() > t.Exp { + return nil, fmt.Errorf("token expired") + } + + return &t, nil +} diff --git a/lib/data.go b/lib/data.go new file mode 100644 index 0000000..45537a3 --- /dev/null +++ b/lib/data.go @@ -0,0 +1,342 @@ +package lib + +import ( + "database/sql" + "fmt" + "time" +) + +// ============================================================================ +// DOSSIER HELPERS (auth code management) +// ============================================================================ + +// DossierSetAuthCode updates auth code and expiry (internal/auth operation) +func DossierSetAuthCode(dossierID string, code int, expiresAt int64) error { + d, err := DossierGet(nil, dossierID) // nil ctx = internal operation + if err != nil { + return err + } + d.AuthCode = code + d.AuthCodeExpiresAt = expiresAt + return DossierWrite(nil, d) +} + +// DossierClearAuthCode clears auth code and sets last login (internal/auth operation) +func DossierClearAuthCode(dossierID string) error { + d, err := DossierGet(nil, dossierID) // nil ctx = internal operation + if err != nil { + return err + } + d.AuthCode = 0 + d.AuthCodeExpiresAt = 0 + d.LastLogin = time.Now().Unix() + return DossierWrite(nil, d) +} + +// ============================================================================ +// DOSSIER ACCESS +// ============================================================================ + +// AccessAdd inserts a new access record +func AccessAdd(a *DossierAccess) error { + if a.CreatedAt == 0 { + a.CreatedAt = time.Now().Unix() + } + return AccessWrite(a) +} + +// AccessDelete removes an access record +func AccessDelete(accessorID, targetID string) error { + return AccessRemove(accessorID, targetID) +} + +// AccessModify updates an access record +func AccessModify(a *DossierAccess) error { + // Lookup access_id if not provided + if a.AccessID == "" { + existing, err := AccessGet(a.AccessorDossierID, a.TargetDossierID) + if err != nil { + return err + } + a.AccessID = existing.AccessID + } + return AccessWrite(a) +} + +// AccessListByAccessor lists all dossiers a user can access +func AccessListByAccessor(accessorID string) ([]*DossierAccess, error) { + return AccessList(&AccessFilter{AccessorID: accessorID}) +} + +// AccessListByTarget lists all users who can access a dossier +func AccessListByTarget(targetID string) ([]*DossierAccess, error) { + return AccessList(&AccessFilter{TargetID: targetID}) +} + +// AccessUpdateTimestamp updates the accessed_at timestamp +func AccessUpdateTimestamp(accessorID, targetID string) error { + access, err := AccessGet(accessorID, targetID) + if err != nil { + return err + } + access.AccessedAt = time.Now().Unix() + return AccessWrite(access) +} + +// ============================================================================ +// ENTRY +// ============================================================================ + +// EntryAdd inserts a new entry. Generates EntryID if empty. (internal operation) +func EntryAdd(e *Entry) error { + return EntryWrite(nil, e) // nil ctx = internal operation +} + +// EntryDelete removes a single entry (internal operation) +func EntryDelete(entryID string) error { + return EntryRemove(nil, entryID) // nil ctx = internal operation +} + +// EntryDeleteTree removes an entry and all its children +func EntryDeleteTree(dossierID, entryID string) error { + // Delete children first + var children []*Entry + if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ? AND parent_id = ?", []any{dossierID, entryID}, &children); err != nil { + return err + } + for _, c := range children { + if err := Delete("entries", "entry_id", c.EntryID); err != nil { + return err + } + } + return Delete("entries", "entry_id", entryID) +} + +// EntryModify updates an entry (internal operation) +func EntryModify(e *Entry) error { + return EntryWrite(nil, e) // nil ctx = internal operation +} + +// EntryQuery finds entries by dossier and optional category/type +// Use category=-1 to skip category filter, typ="" to skip type filter +func EntryQuery(dossierID string, category int, typ string) ([]*Entry, error) { + q := "SELECT * FROM entries WHERE dossier_id = ?" + args := []any{dossierID} + if category >= 0 { + q += " AND category = ?" + args = append(args, category) + } + if typ != "" { + q += " AND type = ?" + args = append(args, CryptoEncrypt(typ)) + } + q += " ORDER BY timestamp DESC" + var result []*Entry + return result, Query(q, args, &result) +} + +// EntryQueryByDate retrieves entries within a timestamp range +func EntryQueryByDate(dossierID string, from, to int64) ([]*Entry, error) { + var result []*Entry + return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND timestamp >= ? AND timestamp < ? ORDER BY timestamp DESC", + []any{dossierID, from, to}, &result) +} + +// EntryChildren retrieves child entries ordered by ordinal +func EntryChildren(dossierID, parentID string) ([]*Entry, error) { + var result []*Entry + return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? ORDER BY ordinal", + []any{dossierID, parentID}, &result) +} + +// EntryChildrenByCategory retrieves child entries filtered by category, ordered by ordinal +func EntryChildrenByCategory(dossierID, parentID string, category int) ([]*Entry, error) { + var result []*Entry + return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? AND category = ? ORDER BY ordinal", + []any{dossierID, parentID, category}, &result) +} + +// EntryChildrenByType retrieves child entries filtered by type string, ordered by ordinal +func EntryChildrenByType(dossierID, parentID string, typ string) ([]*Entry, error) { + var result []*Entry + return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? AND type = ? ORDER BY ordinal", + []any{dossierID, parentID, CryptoEncrypt(typ)}, &result) +} + +// EntryRootByType finds the root entry (parent_id = 0 or NULL) for a given type +func EntryRootByType(dossierID string, typ string) (*Entry, error) { + var result []*Entry + err := Query("SELECT * FROM entries WHERE dossier_id = ? AND type = ? AND (parent_id IS NULL OR parent_id = '' OR parent_id = '0') LIMIT 1", + []any{dossierID, CryptoEncrypt(typ)}, &result) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("not found") + } + return result[0], nil +} + +// EntryRootsByType finds all root entries (parent_id = '' or NULL) for a given type +func EntryRootsByType(dossierID string, typ string) ([]*Entry, error) { + var result []*Entry + return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND type = ? AND (parent_id IS NULL OR parent_id = '' OR parent_id = '0') ORDER BY timestamp DESC", + []any{dossierID, CryptoEncrypt(typ)}, &result) +} + +// EntryRootByCategory finds the root entry (parent_id IS NULL) for a category +func EntryRootByCategory(dossierID string, category int) (*Entry, error) { + var result []*Entry + err := Query("SELECT * FROM entries WHERE dossier_id = ? AND category = ? AND (parent_id IS NULL OR parent_id = '') LIMIT 1", + []any{dossierID, category}, &result) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("not found") + } + return result[0], nil +} + +// EntryTypes returns distinct types for a dossier+category +func EntryTypes(dossierID string, category int) ([]string, error) { + var entries []*Entry + if err := Query("SELECT DISTINCT type FROM entries WHERE dossier_id = ? AND category = ?", + []any{dossierID, category}, &entries); err != nil { + return nil, err + } + var types []string + for _, e := range entries { + if e.Type != "" { + types = append(types, e.Type) + } + } + return types, nil +} + +// ============================================================================ +// AUDIT +// ============================================================================ + +// AuditAdd inserts an audit entry. Generates AuditID if empty. +func AuditAdd(a *AuditEntry) error { + if a.Timestamp == 0 { + a.Timestamp = time.Now().Unix() + } + return AuditWrite(a) +} + +// AuditLog is a convenience function for quick audit logging +func AuditLog(actor1ID string, action string, targetID string, details string) { + AuditAdd(&AuditEntry{ + Actor1ID: actor1ID, + Action: action, + TargetID: targetID, + Details: details, + }) +} + +// AuditLogFull logs an audit entry with all fields +func AuditLogFull(actor1ID, actor2ID, targetID string, action, details string, relationID int) { + AuditAdd(&AuditEntry{ + Actor1ID: actor1ID, + Actor2ID: actor2ID, + TargetID: targetID, + Action: action, + Details: details, + RelationID: relationID, + }) +} + +// AuditQueryByDossier retrieves audit entries by actor +func AuditQueryByActor(actor1ID string, from, to int64) ([]*AuditEntry, error) { + return AuditList(&AuditFilter{ActorID: actor1ID, FromDate: from, ToDate: to}) +} + +// AuditQueryByTarget retrieves audit entries by target dossier +func AuditQueryByTarget(targetID string, from, to int64) ([]*AuditEntry, error) { + return AuditList(&AuditFilter{TargetID: targetID, FromDate: from, ToDate: to}) +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +func nilIfZero(v int64) any { + if v == 0 { + return nil + } + return v +} + +func nilIfEmpty(s string) any { + if s == "" || s == "0000000000000000" { + return nil + } + return s +} + +func nullStr(ns sql.NullString) string { + if ns.Valid { + return ns.String + } + return "" +} + +func nullInt(ni sql.NullInt64) int64 { + if ni.Valid { + return ni.Int64 + } + return 0 +} + +func nullIntToHex(ni sql.NullInt64) string { + if ni.Valid { + return fmt.Sprintf("%016x", ni.Int64) + } + return "" +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// EntryDeleteByCategory removes all entries with a given category for a dossier +func EntryDeleteByCategory(dossierID string, category int) error { + // Query all entries with this category, then delete each + var entries []*Entry + if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ? AND category = ?", + []any{dossierID, category}, &entries); err != nil { + return err + } + for _, e := range entries { + if err := Delete("entries", "entry_id", e.EntryID); err != nil { + return err + } + } + return nil +} + +// OpenReadOnly opens a SQLite database in read-only mode +func OpenReadOnly(path string) (*sql.DB, error) { + return sql.Open("sqlite3", path+"?mode=ro") +} + +// EntryAddBatch inserts multiple entries (internal operation) +func EntryAddBatch(entries []*Entry) error { + return EntryWrite(nil, entries...) // nil ctx = internal operation +} + +// DossierSetSessionToken sets the mobile session token (internal/auth operation) +func DossierSetSessionToken(dossierID string, token string) error { + d, err := DossierGet(nil, dossierID) // nil ctx = internal operation + if err != nil { + return err + } + d.SessionToken = token + return DossierWrite(nil, d) +} + diff --git a/lib/db_auth.go b/lib/db_auth.go new file mode 100644 index 0000000..5b5ba56 --- /dev/null +++ b/lib/db_auth.go @@ -0,0 +1,179 @@ +package lib + +// ============================================================================ +// Auth Database (auth.db) - OAuth tokens and sessions +// ============================================================================ +// Separate from medical data (inou.db). Volatile/ephemeral data. +// Schema documented in docs/schema-auth.sql +// ============================================================================ + +import ( + "database/sql" + "fmt" + "reflect" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +var authDB *sql.DB + +// AuthDBInit opens the auth database connection +func AuthDBInit(dbPath string) error { + var err error + authDB, err = sql.Open("sqlite3", dbPath) + return err +} + +// AuthDBClose closes the auth database connection +func AuthDBClose() { + if authDB != nil { + authDB.Close() + } +} + +// authSave inserts or updates a record in auth.db (simplified, no encryption) +func authSave(table string, v interface{}) error { + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + typ := val.Type() + + var cols []string + var placeholders []string + var vals []interface{} + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("db") + if tag == "" || tag == "-" { + continue + } + parts := strings.Split(tag, ",") + colName := parts[0] + cols = append(cols, colName) + placeholders = append(placeholders, "?") + vals = append(vals, val.Field(i).Interface()) + } + + query := fmt.Sprintf( + "INSERT OR REPLACE INTO %s (%s) VALUES (%s)", + table, + strings.Join(cols, ", "), + strings.Join(placeholders, ", "), + ) + + _, err := authDB.Exec(query, vals...) + return err +} + +// authLoad retrieves a single record by primary key from auth.db +func authLoad(table string, pk interface{}, dest interface{}) error { + val := reflect.ValueOf(dest) + if val.Kind() != reflect.Ptr { + return fmt.Errorf("dest must be a pointer") + } + val = val.Elem() + typ := val.Type() + + var pkCol string + var cols []string + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("db") + if tag == "" || tag == "-" { + continue + } + parts := strings.Split(tag, ",") + colName := parts[0] + isPK := len(parts) > 1 && parts[1] == "pk" + if isPK { + pkCol = colName + } + cols = append(cols, colName) + } + + query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?", strings.Join(cols, ", "), table, pkCol) + row := authDB.QueryRow(query, pk) + + // Build scan destinations + ptrs := make([]interface{}, len(cols)) + colIdx := 0 + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("db") + if tag == "" || tag == "-" { + continue + } + ptrs[colIdx] = val.Field(i).Addr().Interface() + colIdx++ + } + + return row.Scan(ptrs...) +} + +// authQuery executes a SELECT and scans into a slice (for auth.db) +func authQuery(query string, args []interface{}, dest interface{}) error { + rows, err := authDB.Query(query, args...) + if err != nil { + return err + } + defer rows.Close() + + sliceVal := reflect.ValueOf(dest) + if sliceVal.Kind() != reflect.Ptr || sliceVal.Elem().Kind() != reflect.Slice { + return fmt.Errorf("dest must be pointer to slice") + } + sliceVal = sliceVal.Elem() + elemType := sliceVal.Type().Elem() + isPtr := elemType.Kind() == reflect.Ptr + if isPtr { + elemType = elemType.Elem() + } + + cols, _ := rows.Columns() + + for rows.Next() { + elem := reflect.New(elemType).Elem() + ptrs := make([]interface{}, len(cols)) + + for i, col := range cols { + for j := 0; j < elemType.NumField(); j++ { + field := elemType.Field(j) + tag := field.Tag.Get("db") + if tag == "" { + continue + } + parts := strings.Split(tag, ",") + if parts[0] == col { + ptrs[i] = elem.Field(j).Addr().Interface() + break + } + } + if ptrs[i] == nil { + var dummy interface{} + ptrs[i] = &dummy + } + } + + if err := rows.Scan(ptrs...); err != nil { + return err + } + + if isPtr { + sliceVal.Set(reflect.Append(sliceVal, elem.Addr())) + } else { + sliceVal.Set(reflect.Append(sliceVal, elem)) + } + } + + return rows.Err() +} + +// authDelete removes a record by primary key from auth.db +func authDelete(table, pkCol string, pkVal interface{}) error { + query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table, pkCol) + _, err := authDB.Exec(query, pkVal) + return err +} diff --git a/lib/db_queries.go b/lib/db_queries.go new file mode 100644 index 0000000..f465168 --- /dev/null +++ b/lib/db_queries.go @@ -0,0 +1,941 @@ +package lib + +// ============================================================================ +// ⛔ CRITICAL: DO NOT MODIFY THIS FILE WITHOUT JOHAN'S EXPRESS CONSENT +// ============================================================================ +// This is the ONLY file allowed to access the database directly. +// All other code must use these functions: Save, Load, Query, Delete, Count +// +// Run `make check-db` to verify no direct DB access exists elsewhere. +// ============================================================================ + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "strings" + "time" +) + +// Store provides a generic data layer with automatic encryption. +// String and []byte fields are encrypted automatically. +// Int/int64/bool fields pass through unchanged. +// +// Struct tags: +// db:"column_name" - maps field to column (default: lowercase field name) +// db:"column_name,pk" - marks as primary key +// db:"-" - skip this field +// +// Example: +// type Entry struct { +// EntryID int64 `db:"entry_id,pk"` +// Type string `db:"type"` // auto-encrypted +// Status int `db:"status"` // not encrypted +// } + +// fieldInfo holds metadata about a struct field +type fieldInfo struct { + Name string // struct field name + Column string // db column name + Type reflect.Type // Go type + IsPK bool // is primary key + Skip bool // skip this field + Index int // field index in struct +} + +// tableInfo holds metadata about a struct/table mapping +type tableInfo struct { + Name string + Fields []fieldInfo + PK *fieldInfo +} + +// getTableInfo extracts table metadata from a struct using reflection +func getTableInfo(table string, v any) (*tableInfo, error) { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("expected struct, got %s", t.Kind()) + } + + info := &tableInfo{Name: table} + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // Parse db tag + tag := field.Tag.Get("db") + if tag == "-" { + continue + } + + fi := fieldInfo{ + Name: field.Name, + Type: field.Type, + Index: i, + } + + // Default: lowercase field name + fi.Column = strings.ToLower(field.Name) + + // Parse tag options (override column name if specified) + if tag != "" { + parts := strings.Split(tag, ",") + if parts[0] != "" { + fi.Column = parts[0] + } + for _, opt := range parts[1:] { + if opt == "pk" { + fi.IsPK = true + } + } + } + + info.Fields = append(info.Fields, fi) + if fi.IsPK { + info.PK = &info.Fields[len(info.Fields)-1] + } + } + + // PK required for table operations, optional for QuerySQL (table="") + if table != "" && info.PK == nil { + return nil, fmt.Errorf("no primary key defined for table %s (use `db:\"col,pk\"`)", table) + } + + return info, nil +} + +// goTypeToSQLite maps Go types to SQLite column types +func goTypeToSQLite(t reflect.Type) string { + switch t.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return "INTEGER" + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "INTEGER" + case reflect.Float32, reflect.Float64: + return "REAL" + case reflect.Bool: + return "INTEGER" + case reflect.String: + return "TEXT" + case reflect.Slice: + if t.Elem().Kind() == reflect.Uint8 { + return "BLOB" + } + return "TEXT" // JSON-encode other slices + default: + return "TEXT" + } +} + +// Verify checks that the database schema matches the struct definition. +// Returns nil if OK, or an error with suggested ALTER statements. +func Verify(table string, v any) error { + info, err := getTableInfo(table, v) + if err != nil { + return err + } + + // Get current columns from DB + rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return fmt.Errorf("failed to query table info: %w", err) + } + defer rows.Close() + + dbColumns := make(map[string]string) // column -> type + for rows.Next() { + var cid int + var name, colType string + var notNull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dflt, &pk); err != nil { + return err + } + dbColumns[name] = colType + } + + // Check if table exists + if len(dbColumns) == 0 { + // Generate CREATE TABLE + var cols []string + for _, f := range info.Fields { + colDef := fmt.Sprintf("%s %s", f.Column, goTypeToSQLite(f.Type)) + if f.IsPK { + colDef += " PRIMARY KEY" + } + cols = append(cols, colDef) + } + return fmt.Errorf(`schema mismatch: table '%s' does not exist + +Suggested fix: + CREATE TABLE %s ( + %s + ); +`, table, table, strings.Join(cols, ",\n ")) + } + + // Check for missing columns + var missing []fieldInfo + for _, f := range info.Fields { + if _, exists := dbColumns[f.Column]; !exists { + missing = append(missing, f) + } + } + + if len(missing) > 0 { + var alters []string + var details []string + for _, f := range missing { + sqlType := goTypeToSQLite(f.Type) + details = append(details, fmt.Sprintf(" - %s (%s)", f.Column, sqlType)) + alters = append(alters, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s;", table, f.Column, sqlType)) + } + return fmt.Errorf(`schema mismatch for table '%s' + +Missing columns: +%s + +Suggested fix: + %s + +Run these manually, then restart. +`, table, strings.Join(details, "\n"), strings.Join(alters, "\n ")) + } + + return nil +} + +// VerifyAll checks multiple table/struct pairs +func VerifyAll(pairs ...any) error { + if len(pairs)%2 != 0 { + return fmt.Errorf("VerifyAll requires pairs of (table string, struct)") + } + for i := 0; i < len(pairs); i += 2 { + table, ok := pairs[i].(string) + if !ok { + return fmt.Errorf("expected string for table name at position %d", i) + } + if err := Verify(table, pairs[i+1]); err != nil { + return err + } + } + return nil +} + +// Save upserts struct(s) to the database. +// Accepts a single struct or a slice of structs. +// String and []byte fields are encrypted automatically. +// Slices are wrapped in a transaction for atomicity. +func Save(table string, v any) error { + start := time.Now() + defer func() { logSlowQuery("INSERT OR REPLACE INTO "+table, time.Since(start)) }() + + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + // Slice: bulk upsert with transaction + if val.Kind() == reflect.Slice { + if val.Len() == 0 { + return nil + } + + // Get table info from first element + elemType := val.Type().Elem() + if elemType.Kind() == reflect.Ptr { + elemType = elemType.Elem() + } + sample := reflect.New(elemType).Interface() + info, err := getTableInfo(table, sample) + if err != nil { + return err + } + + // Build query once + var columns []string + var placeholders []string + for _, f := range info.Fields { + columns = append(columns, f.Column) + placeholders = append(placeholders, "?") + } + query := fmt.Sprintf( + "INSERT OR REPLACE INTO %s (%s) VALUES (%s)", + table, + strings.Join(columns, ", "), + strings.Join(placeholders, ", "), + ) + + // Transaction with prepared statement + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(query) + if err != nil { + return err + } + defer stmt.Close() + + for i := 0; i < val.Len(); i++ { + elem := val.Index(i) + if elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + + values := make([]any, len(info.Fields)) + for j, f := range info.Fields { + values[j] = encryptField(elem.Field(f.Index), f.Column) + } + + if _, err := stmt.Exec(values...); err != nil { + return err + } + } + + return tx.Commit() + } + + // Single struct + info, err := getTableInfo(table, v) + if err != nil { + return err + } + + var columns []string + var placeholders []string + var values []any + + for _, f := range info.Fields { + columns = append(columns, f.Column) + placeholders = append(placeholders, "?") + values = append(values, encryptField(val.Field(f.Index), f.Column)) + } + + query := fmt.Sprintf( + "INSERT OR REPLACE INTO %s (%s) VALUES (%s)", + table, + strings.Join(columns, ", "), + strings.Join(placeholders, ", "), + ) + + _, err = db.Exec(query, values...) + return err +} + +// Load retrieves a record by primary key and populates the struct. +// String and []byte fields are decrypted automatically. +func Load(table string, id string, v any) error { + start := time.Now() + defer func() { logSlowQuery("SELECT FROM "+table+" WHERE pk=?", time.Since(start), id) }() + + info, err := getTableInfo(table, v) + if err != nil { + return err + } + + val := reflect.ValueOf(v) + if val.Kind() != reflect.Ptr { + return fmt.Errorf("Load requires a pointer to struct") + } + val = val.Elem() + + // Build SELECT + var columns []string + for _, f := range info.Fields { + columns = append(columns, f.Column) + } + + query := fmt.Sprintf( + "SELECT %s FROM %s WHERE %s = ?", + strings.Join(columns, ", "), + table, + info.PK.Column, + ) + + row := db.QueryRow(query, id) + + // Create scan destinations + scanDest := make([]any, len(info.Fields)) + for i, f := range info.Fields { + scanDest[i] = createScanDest(f.Type) + } + + if err := row.Scan(scanDest...); err != nil { + return err + } + + // Decrypt and set fields + for i, f := range info.Fields { + decryptAndSet(val.Field(f.Index), scanDest[i], f.Type, f.Column) + } + + return nil +} + +// Query runs a SQL query and populates the slice. +// Column names in result must match struct db tags. +// String and []byte fields are decrypted automatically. +func Query(query string, args []any, slicePtr any) error { + start := time.Now() + defer func() { logSlowQuery(query, time.Since(start), args...) }() + + sliceVal := reflect.ValueOf(slicePtr) + if sliceVal.Kind() != reflect.Ptr || sliceVal.Elem().Kind() != reflect.Slice { + return fmt.Errorf("Query requires a pointer to slice") + } + + sliceType := sliceVal.Elem().Type() + elemType := sliceType.Elem() + if elemType.Kind() == reflect.Ptr { + elemType = elemType.Elem() + } + + // Get struct field info + sample := reflect.New(elemType).Interface() + info, err := getTableInfo("", sample) + if err != nil { + return err + } + + // Build column->field mapping + colToField := make(map[string]*fieldInfo) + for i := range info.Fields { + colToField[info.Fields[i].Column] = &info.Fields[i] + } + + rows, err := db.Query(query, args...) + if err != nil { + return err + } + defer rows.Close() + + // Get column names from result + cols, err := rows.Columns() + if err != nil { + return err + } + + result := reflect.MakeSlice(sliceType, 0, 0) + + for rows.Next() { + elem := reflect.New(elemType).Elem() + + // Create scan destinations for each column + scanDest := make([]any, len(cols)) + fieldMap := make([]*fieldInfo, len(cols)) + + for i, col := range cols { + if fi, ok := colToField[col]; ok { + scanDest[i] = createScanDest(fi.Type) + fieldMap[i] = fi + } else { + // Unknown column - scan into throwaway + scanDest[i] = new(any) + } + } + + if err := rows.Scan(scanDest...); err != nil { + return err + } + + // Decrypt and set matched fields + for i, fi := range fieldMap { + if fi != nil { + decryptAndSet(elem.Field(fi.Index), scanDest[i], fi.Type, fi.Column) + } + } + + if sliceType.Elem().Kind() == reflect.Ptr { + result = reflect.Append(result, elem.Addr()) + } else { + result = reflect.Append(result, elem) + } + } + + sliceVal.Elem().Set(result) + return nil +} + +// Count runs a SELECT COUNT(*) query and returns the result. +// Example: Count("SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?", dossierID, category) +func Count(query string, args ...any) (int, error) { + var count int + err := db.QueryRow(query, args...).Scan(&count) + return count, err +} + +// Delete removes a record by primary key. +// pkCol is the primary key column name, id is 16-char hex string. +func Delete(table, pkCol, id string) error { + query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table, pkCol) + _, err := db.Exec(query, id) + return err +} + +// DeleteTree removes a record and all its descendants. +// Traverses the parent-child hierarchy recursively, deletes children first. +// Works with any SQL database (no CTEs or CASCADE needed). +func DeleteTree(table, pkCol, parentCol, id string) error { + // Collect all IDs (parent + descendants) + var ids []string + var collect func(string) error + collect = func(pid string) error { + ids = append(ids, pid) + rows, err := db.Query( + fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?", pkCol, table, parentCol), + pid, + ) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var childID string + if err := rows.Scan(&childID); err != nil { + return err + } + if err := collect(childID); err != nil { + return err + } + } + return nil + } + + if err := collect(id); err != nil { + return err + } + + // Delete in reverse order (children first) + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for i := len(ids) - 1; i >= 0; i-- { + if _, err := tx.Exec( + fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table, pkCol), + ids[i], + ); err != nil { + return err + } + } + + return tx.Commit() +} + +// encryptField encrypts string and []byte fields, passes others through +// ID columns (ending in _id) are NOT encrypted +func encryptField(v reflect.Value, column string) any { + switch v.Kind() { + case reflect.String: + s := v.String() + if s == "" { + return "" + } + // Don't encrypt ID columns + if strings.HasSuffix(column, "_id") { + return s + } + return CryptoEncrypt(s) + + case reflect.Slice: + if v.Type().Elem().Kind() == reflect.Uint8 { + // []byte + b := v.Bytes() + if len(b) == 0 { + return []byte{} + } + return CryptoEncryptBytes(b) + } + // Other slices: JSON encode then encrypt + return v.Interface() + + default: + return v.Interface() + } +} + +// createScanDest creates an appropriate scan destination for a Go type +func createScanDest(t reflect.Type) any { + switch t.Kind() { + case reflect.Int, reflect.Int64: + return new(sql.NullInt64) + case reflect.String: + return new(sql.NullString) + case reflect.Slice: + if t.Elem().Kind() == reflect.Uint8 { + return new([]byte) + } + return new(sql.NullString) + case reflect.Bool: + return new(sql.NullInt64) + default: + return new(sql.NullString) + } +} + +// decryptAndSet decrypts the scanned value and sets the struct field +// ID columns (ending in _id) are NOT decrypted +func decryptAndSet(field reflect.Value, scanned any, t reflect.Type, column string) { + switch t.Kind() { + case reflect.Int, reflect.Int64: + if ns, ok := scanned.(*sql.NullInt64); ok && ns.Valid { + field.SetInt(ns.Int64) + } + + case reflect.String: + if ns, ok := scanned.(*sql.NullString); ok && ns.Valid { + // Don't decrypt ID columns or known plain-text columns + plainCols := map[string]bool{"language": true, "timezone": true, "weight_unit": true, "height_unit": true} + if strings.HasSuffix(column, "_id") || plainCols[column] { + field.SetString(ns.String) + } else { + field.SetString(CryptoDecrypt(ns.String)) + } + } + + case reflect.Slice: + if t.Elem().Kind() == reflect.Uint8 { + // []byte - decrypt + if b, ok := scanned.(*[]byte); ok && b != nil && len(*b) > 0 { + decrypted, err := CryptoDecryptBytes(*b) + if err == nil { + field.SetBytes(decrypted) + } + } + } + + case reflect.Bool: + if ns, ok := scanned.(*sql.NullInt64); ok && ns.Valid { + field.SetBool(ns.Int64 != 0) + } + } +} + +// ============================================================================= +// OAuth 2.0 Store Functions +// ============================================================================= + +// OAuthClient represents a registered OAuth client (Claude, Flutter, etc.) +type OAuthClient struct { + ClientID string `db:"client_id,pk"` + ClientSecret string `db:"client_secret"` // hashed + Name string `db:"name"` + RedirectURIs []string `db:"-"` // parsed from JSON + RedirectJSON string `db:"redirect_uris"` + CreatedAt int64 `db:"created_at"` +} + +// OAuthCode represents a temporary authorization code +type OAuthCode struct { + Code string `db:"code,pk"` + ClientID string `db:"client_id"` + DossierID string `db:"dossier_id"` + RedirectURI string `db:"redirect_uri"` + CodeChallenge string `db:"code_challenge"` + CodeChallengeMethod string `db:"code_challenge_method"` + ExpiresAt int64 `db:"expires_at"` + Used int `db:"used"` +} + +// OAuthRefreshToken represents a long-lived refresh token +type OAuthRefreshToken struct { + TokenID string `db:"token_id,pk"` + ClientID string `db:"client_id"` + DossierID string `db:"dossier_id"` + ExpiresAt int64 `db:"expires_at"` + Revoked int `db:"revoked"` + CreatedAt int64 `db:"created_at"` +} + +// generateID creates a random hex ID of specified byte length +func generateID(bytes int) string { + b := make([]byte, bytes) + rand.Read(b) + return hex.EncodeToString(b) +} + +// hashSecret hashes a client secret for storage +func hashSecret(secret string) string { + h := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(h[:]) +} + +// OAuthClientCreate creates a new OAuth client and returns the plain secret +func OAuthClientCreate(name string, redirectURIs []string) (*OAuthClient, string, error) { + clientID := generateID(16) + plainSecret := generateID(32) + + urisJSON, _ := json.Marshal(redirectURIs) + + client := &OAuthClient{ + ClientID: clientID, + ClientSecret: hashSecret(plainSecret), + Name: name, + RedirectURIs: redirectURIs, + RedirectJSON: string(urisJSON), + CreatedAt: time.Now().Unix(), + } + + if err := authSave("oauth_clients", client); err != nil { + return nil, "", err + } + + return client, plainSecret, nil +} + +// OAuthClientCreatePublic creates a public OAuth client with a fixed client_id (no secret) +// Used for first-party clients like the MCP bridge +func OAuthClientCreatePublic(clientID, name string) error { + urisJSON, _ := json.Marshal([]string{}) + + client := &OAuthClient{ + ClientID: clientID, + ClientSecret: "", // Public client - no secret + Name: name, + RedirectURIs: []string{}, + RedirectJSON: string(urisJSON), + CreatedAt: time.Now().Unix(), + } + + return authSave("oauth_clients", client) +} + +// OAuthClientGet retrieves a client by ID +func OAuthClientGet(clientID string) (*OAuthClient, error) { + var client OAuthClient + if err := authLoad("oauth_clients", clientID, &client); err != nil { + return nil, err + } + // Parse redirect URIs + json.Unmarshal([]byte(client.RedirectJSON), &client.RedirectURIs) + return &client, nil +} + +// OAuthClientVerifySecret checks if the provided secret matches +func OAuthClientVerifySecret(client *OAuthClient, secret string) bool { + return client.ClientSecret == hashSecret(secret) +} + +// OAuthClientValidRedirectURI checks if URI is registered for client +func OAuthClientValidRedirectURI(client *OAuthClient, uri string) bool { + for _, u := range client.RedirectURIs { + if u == uri { + return true + } + } + return false +} + +// OAuthCodeCreate creates a new authorization code (valid for 10 minutes) +func OAuthCodeCreate(clientID, dossierID, redirectURI, codeChallenge, codeChallengeMethod string) (*OAuthCode, error) { + code := &OAuthCode{ + Code: generateID(32), + ClientID: clientID, + DossierID: dossierID, + RedirectURI: redirectURI, + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + ExpiresAt: time.Now().Unix() + 600, // 10 minutes + Used: 0, + } + + if err := authSave("oauth_codes", code); err != nil { + return nil, err + } + + return code, nil +} + +// OAuthCodeGet retrieves and validates a code (checks expiry, not used) +func OAuthCodeGet(code string) (*OAuthCode, error) { + var c OAuthCode + if err := authLoad("oauth_codes", code, &c); err != nil { + return nil, err + } + + if c.Used != 0 { + return nil, fmt.Errorf("code already used") + } + + if time.Now().Unix() > c.ExpiresAt { + return nil, fmt.Errorf("code expired") + } + + return &c, nil +} + +// OAuthCodeUse marks a code as used (single-use) +func OAuthCodeUse(code string) error { + var c OAuthCode + if err := authLoad("oauth_codes", code, &c); err != nil { + return err + } + c.Used = 1 + return authSave("oauth_codes", &c) +} + +// OAuthCodeVerifyPKCE verifies the PKCE code_verifier against stored challenge +func OAuthCodeVerifyPKCE(c *OAuthCode, codeVerifier string) bool { + if c.CodeChallenge == "" { + return true // No PKCE required + } + + if c.CodeChallengeMethod != "S256" { + return false // Only support S256 + } + + // S256: BASE64URL(SHA256(code_verifier)) == code_challenge + h := sha256.Sum256([]byte(codeVerifier)) + computed := base64.RawURLEncoding.EncodeToString(h[:]) + + return computed == c.CodeChallenge +} + +// OAuthRefreshTokenCreate creates a new refresh token (valid for 30 days) +func OAuthRefreshTokenCreate(clientID, dossierID string) (*OAuthRefreshToken, error) { + token := &OAuthRefreshToken{ + TokenID: generateID(32), + ClientID: clientID, + DossierID: dossierID, + ExpiresAt: time.Now().Unix() + 30*24*60*60, // 30 days + Revoked: 0, + CreatedAt: time.Now().Unix(), + } + + if err := authSave("oauth_refresh_tokens", token); err != nil { + return nil, err + } + + return token, nil +} + +// OAuthRefreshTokenGet retrieves and validates a refresh token +func OAuthRefreshTokenGet(tokenID string) (*OAuthRefreshToken, error) { + var t OAuthRefreshToken + if err := authLoad("oauth_refresh_tokens", tokenID, &t); err != nil { + return nil, err + } + + if t.Revoked != 0 { + return nil, fmt.Errorf("token revoked") + } + + if time.Now().Unix() > t.ExpiresAt { + return nil, fmt.Errorf("token expired") + } + + return &t, nil +} + +// OAuthRefreshTokenRevoke revokes a refresh token +func OAuthRefreshTokenRevoke(tokenID string) error { + var t OAuthRefreshToken + if err := authLoad("oauth_refresh_tokens", tokenID, &t); err != nil { + return err + } + t.Revoked = 1 + return authSave("oauth_refresh_tokens", &t) +} + +// OAuthRefreshTokenRotate revokes old token and creates new one +func OAuthRefreshTokenRotate(oldTokenID string) (*OAuthRefreshToken, error) { + old, err := OAuthRefreshTokenGet(oldTokenID) + if err != nil { + return nil, err + } + + // Revoke old + if err := OAuthRefreshTokenRevoke(oldTokenID); err != nil { + return nil, err + } + + // Create new + return OAuthRefreshTokenCreate(old.ClientID, old.DossierID) +} + +// OAuthRefreshTokenRevokeAll revokes all refresh tokens for a dossier +func OAuthRefreshTokenRevokeAll(dossierID string) error { + var tokens []*OAuthRefreshToken + if err := authQuery("SELECT * FROM oauth_refresh_tokens WHERE dossier_id = ? AND revoked = 0", []any{dossierID}, &tokens); err != nil { + return err + } + for _, t := range tokens { + t.Revoked = 1 + if err := authSave("oauth_refresh_tokens", t); err != nil { + return err + } + } + return nil +} + +// OAuthRefreshTokenGetForClient gets an existing valid refresh token for a client/dossier pair +func OAuthRefreshTokenGetForClient(clientID, dossierID string) (*OAuthRefreshToken, error) { + var tokens []*OAuthRefreshToken + now := time.Now().Unix() + if err := authQuery("SELECT * FROM oauth_refresh_tokens WHERE client_id = ? AND dossier_id = ? AND revoked = 0 AND expires_at > ? ORDER BY created_at DESC LIMIT 1", []any{clientID, dossierID, now}, &tokens); err != nil { + return nil, err + } + if len(tokens) == 0 { + return nil, fmt.Errorf("no token found") + } + return tokens[0], nil +} + +// OAuthRefreshTokenGetOrCreate gets or creates a refresh token for a client/dossier pair +func OAuthRefreshTokenGetOrCreate(clientID, dossierID string) (*OAuthRefreshToken, error) { + token, err := OAuthRefreshTokenGetForClient(clientID, dossierID) + if err == nil { + return token, nil + } + return OAuthRefreshTokenCreate(clientID, dossierID) +} + +// OAuthRefreshTokenRegenerate revokes all existing tokens for a client/dossier and creates new one +func OAuthRefreshTokenRegenerate(clientID, dossierID string) (*OAuthRefreshToken, error) { + var tokens []*OAuthRefreshToken + if err := authQuery("SELECT * FROM oauth_refresh_tokens WHERE client_id = ? AND dossier_id = ? AND revoked = 0", []any{clientID, dossierID}, &tokens); err != nil { + return nil, err + } + for _, t := range tokens { + t.Revoked = 1 + authSave("oauth_refresh_tokens", t) + } + return OAuthRefreshTokenCreate(clientID, dossierID) +} + +// OAuthCleanup removes expired codes and tokens (call periodically) +func OAuthCleanup() error { + now := time.Now().Unix() + + // Delete expired or used codes (keep used codes 1 hour for debugging) + var codes []*OAuthCode + if err := authQuery("SELECT * FROM oauth_codes WHERE expires_at < ? OR used = 1", []any{now - 3600}, &codes); err == nil { + for _, c := range codes { + authDelete("oauth_codes", "code", c.Code) + } + } + + // Delete expired revoked tokens (keep revoked 1 day) + var tokens []*OAuthRefreshToken + if err := authQuery("SELECT * FROM oauth_refresh_tokens WHERE expires_at < ? AND revoked = 1", []any{now - 86400}, &tokens); err == nil { + for _, t := range tokens { + authDelete("oauth_refresh_tokens", "token_id", t.TokenID) + } + } + + return nil +} + diff --git a/lib/db_schema.go b/lib/db_schema.go new file mode 100644 index 0000000..77c1db7 --- /dev/null +++ b/lib/db_schema.go @@ -0,0 +1,64 @@ +package lib + +// ============================================================================ +// Database Connection Management +// ============================================================================ +// Connection lifecycle only. Schema is documented in docs/schema.sql. +// All operations go through db_queries.go. +// ============================================================================ + +import ( + "database/sql" + "log" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +var db *sql.DB + +// Slow query thresholds +var ( + SlowQueryThreshold = 100 * time.Millisecond // Log at info level + WarnQueryThreshold = 500 * time.Millisecond // Log at warn level + SlowQueryEnabled = true +) + +// logSlowQuery logs queries that exceed the threshold +func logSlowQuery(query string, duration time.Duration, args ...any) { + if !SlowQueryEnabled { + return + } + if duration < SlowQueryThreshold { + return + } + + // Truncate query for logging + q := strings.TrimSpace(query) + if len(q) > 200 { + q = q[:200] + "..." + } + // Replace newlines/tabs with spaces + q = strings.Join(strings.Fields(q), " ") + + if duration >= WarnQueryThreshold { + log.Printf("[SLOW QUERY WARN] %v | %s | args=%d", duration, q, len(args)) + } else { + log.Printf("[SLOW QUERY] %v | %s | args=%d", duration, q, len(args)) + } +} + +// DBInit opens database connection +func DBInit(dbPath string) error { + var err error + db, err = sql.Open("sqlite3", dbPath) + return err +} + +// DBClose closes database connection +func DBClose() { + if db != nil { + db.Close() + } +} diff --git a/lib/email.go b/lib/email.go new file mode 100644 index 0000000..4cc7189 --- /dev/null +++ b/lib/email.go @@ -0,0 +1,81 @@ +package lib + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "os" + "strings" +) + +var ( + smtpHost, smtpPort, smtpUser, smtpToken, smtpFrom string +) + +func EmailInit(envPath string) error { + data, err := os.ReadFile(envPath) + if err != nil { return err } + for _, line := range strings.Split(string(data), "\n") { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { continue } + switch parts[0] { + case "SMTP_HOST": smtpHost = parts[1] + case "SMTP_PORT": smtpPort = parts[1] + case "SMTP_USER": smtpUser = parts[1] + case "SMTP_TOKEN": smtpToken = parts[1] + case "SMTP_FROM_NAME": smtpFrom = parts[1] + } + } + return nil +} + +func SendEmail(to, fromName, subject, content string) error { + if smtpHost == "" { return nil } + + displayFrom := smtpFrom + if fromName != "" { + displayFrom = fromName + " via inou" + } + + html := wrapEmail(content) + + msg := "From: " + displayFrom + " <" + smtpUser + ">\r\n" + + "To: " + to + "\r\n" + + "Subject: " + subject + "\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/html; charset=utf-8\r\n\r\n" + html + + conn, err := net.Dial("tcp", smtpHost+":"+smtpPort) + if err != nil { return err } + defer conn.Close() + + client, err := smtp.NewClient(conn, smtpHost) + if err != nil { return err } + defer client.Close() + + if err = client.StartTLS(&tls.Config{ServerName: smtpHost}); err != nil { return err } + if err = client.Auth(smtp.PlainAuth("", smtpUser, smtpToken, smtpHost)); err != nil { return err } + if err = client.Mail(smtpUser); err != nil { return err } + if err = client.Rcpt(to); err != nil { return err } + + w, err := client.Data() + if err != nil { return err } + _, err = w.Write([]byte(msg)) + if err != nil { return err } + return w.Close() +} + +func wrapEmail(content string) string { + return fmt.Sprintf(` + + + +
+
+%s +
+
+ +`, content) +} diff --git a/lib/errors.go b/lib/errors.go new file mode 100644 index 0000000..8a66506 --- /dev/null +++ b/lib/errors.go @@ -0,0 +1,48 @@ +package lib + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "runtime/debug" + "time" +) + +const errorsDir = "/tank/inou/errors" + +type Incident struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + Context string `json:"context"` + Error string `json:"error"` + Details map[string]interface{} `json:"details"` + Stack string `json:"stack"` +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +func SendErrorForAnalysis(context string, err error, details map[string]interface{}) { + id := fmt.Sprintf("%s-%s", time.Now().Format("20060102-150405"), randomHex(4)) + + incident := Incident{ + ID: id, + Timestamp: time.Now(), + Context: context, + Error: err.Error(), + Details: details, + Stack: string(debug.Stack()), + } + + os.MkdirAll(errorsDir, 0755) + path := fmt.Sprintf("%s/%s.json", errorsDir, id) + data, _ := json.MarshalIndent(incident, "", " ") + os.WriteFile(path, data, 0644) + + SendSignal(fmt.Sprintf("[inou] %s: %v\n%s", context, err, path)) +} diff --git a/lib/files.go b/lib/files.go new file mode 100644 index 0000000..ab114d4 --- /dev/null +++ b/lib/files.go @@ -0,0 +1,36 @@ +package lib + +import ( + "os" +) + +func EncryptFile(content []byte, destPath string) error { + encrypted := CryptoEncryptBytes(content) + if err := os.WriteFile(destPath, encrypted, 0644); err != nil { + SendErrorForAnalysis("EncryptFile", err, map[string]interface{}{ + "path": destPath, + "size": len(content), + }) + return err + } + return nil +} + +func DecryptFile(srcPath string) ([]byte, error) { + encrypted, err := os.ReadFile(srcPath) + if err != nil { + SendErrorForAnalysis("DecryptFile.Read", err, map[string]interface{}{ + "path": srcPath, + }) + return nil, err + } + decrypted, err := CryptoDecryptBytes(encrypted) + if err != nil { + SendErrorForAnalysis("DecryptFile.Decrypt", err, map[string]interface{}{ + "path": srcPath, + "size": len(encrypted), + }) + return nil, err + } + return decrypted, nil +} diff --git a/lib/llm.go b/lib/llm.go new file mode 100644 index 0000000..79eb3d2 --- /dev/null +++ b/lib/llm.go @@ -0,0 +1,150 @@ +package lib + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +var promptsDir string + +// InitPrompts sets the directory where prompt files are located. +// This must be called by the main application at startup. +func InitPrompts(path string) { + promptsDir = path +} + +// PromptsDir returns the configured prompts directory. +// This is used by local prompt loading functions in consumer packages. +func PromptsDir() string { + return promptsDir +} + +// GeminiPart represents a single part in the Gemini content, can be text or inline_data. +type GeminiPart struct { + Text string `json:"text,omitempty"` + InlineData *GeminiInlineData `json:"inline_data,omitempty"` +} + +// GeminiInlineData represents inline data for multimodal input. +type GeminiInlineData struct { + MimeType string `json:"mime_type"` + Data string `json:"data"` +} + +// GeminiConfig allows overriding default generation parameters. +type GeminiConfig struct { + Temperature *float64 `json:"temperature,omitempty"` + MaxOutputTokens *int `json:"maxOutputTokens,omitempty"` + ResponseMimeType *string `json:"responseMimeType,omitempty"` + Model *string `json:"model,omitempty"` // Allows specifying a different model +} + +// CallGemini sends a text-only request to the Gemini API with default configuration. +// It returns the raw text response from the model. +func CallGemini(prompt string) (string, error) { + parts := []GeminiPart{ + {Text: prompt}, + } + return CallGeminiMultimodal(parts, nil) +} + +// CallGeminiMultimodal sends a request to the Gemini API with flexible content and configuration. +// It returns the raw text response from the model. +func CallGeminiMultimodal(parts []GeminiPart, config *GeminiConfig) (string, error) { + if GeminiKey == "" { + return "", fmt.Errorf("Gemini API key not configured") + } + + // Default configuration + defaultTemperature := 0.1 + defaultMaxOutputTokens := 2048 + defaultResponseMimeType := "application/json" + defaultModel := "gemini-2.0-flash" + + if config == nil { + config = &GeminiConfig{} + } + + // Apply defaults if not overridden + if config.Temperature == nil { + config.Temperature = &defaultTemperature + } + if config.MaxOutputTokens == nil { + config.MaxOutputTokens = &defaultMaxOutputTokens + } + if config.ResponseMimeType == nil { + config.ResponseMimeType = &defaultResponseMimeType + } + if config.Model == nil { + config.Model = &defaultModel + } + + reqBody := map[string]interface{}{ + "contents": []map[string]interface{}{ + { + "parts": parts, + }, + }, + "systemInstruction": map[string]interface{}{ + "parts": []map[string]string{ + {"text": "You are a JSON-only API. Output raw JSON with no markdown, no code fences, no explanations. Start directly with { and end with }."}, + }, + }, + "generationConfig": map[string]interface{}{ + "temperature": *config.Temperature, + "maxOutputTokens": *config.MaxOutputTokens, + "responseMimeType": *config.ResponseMimeType, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", *config.Model, GeminiKey) + + resp, err := http.Post(url, "application/json", bytes.NewReader(jsonBody)) + if err != nil { + return "", fmt.Errorf("API request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("Gemini API error %d: %s", resp.StatusCode, string(body)) + } + + var geminiResp struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(body, &geminiResp); err != nil { + return "", fmt.Errorf("failed to parse Gemini response: %v", err) + } + + if len(geminiResp.Candidates) == 0 || len(geminiResp.Candidates[0].Content.Parts) == 0 { + return "", fmt.Errorf("empty response from Gemini") + } + + finalText := strings.TrimSpace(geminiResp.Candidates[0].Content.Parts[0].Text) + // The model sometimes still wraps the output in markdown, so we clean it. + finalText = strings.TrimPrefix(finalText, "```json") + finalText = strings.TrimPrefix(finalText, "```") + finalText = strings.TrimSuffix(finalText, "```") + + return finalText, nil +} diff --git a/lib/migrate_category.go b/lib/migrate_category.go new file mode 100644 index 0000000..44ef30c --- /dev/null +++ b/lib/migrate_category.go @@ -0,0 +1,80 @@ +package lib + +import ( + "log" + "regexp" +) + +// MigrateDOB encrypts plain-text date_of_birth values (YYYY-MM-DD format) +func MigrateDOB() error { + rows, err := db.Query(`SELECT dossier_id, date_of_birth FROM dossiers WHERE date_of_birth != ''`) + if err != nil { + return err + } + defer rows.Close() + + datePattern := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + updated := 0 + + for rows.Next() { + var dossierID, dob string + if err := rows.Scan(&dossierID, &dob); err != nil { + continue + } + + // If it looks like a plain date, encrypt it + if datePattern.MatchString(dob) { + encrypted := CryptoEncrypt(dob) + _, err := db.Exec(`UPDATE dossiers SET date_of_birth = ? WHERE dossier_id = ?`, encrypted, dossierID) + if err != nil { + log.Printf("update error for dossier %s: %v", dossierID, err) + continue + } + log.Printf("Encrypted DOB for dossier %s: %s", dossierID, dob) + updated++ + } + } + + log.Printf("DOB migration complete: %d encrypted", updated) + return nil +} + +// MigrateCategory populates category from encrypted category strings. +// Run once, then drop the old category column. +func MigrateCategory() error { + rows, err := db.Query(`SELECT entry_id, category FROM entries WHERE category IS NULL`) + if err != nil { + return err + } + defer rows.Close() + + updated := 0 + unknown := 0 + + for rows.Next() { + var entryID int64 + var encCategory string + if err := rows.Scan(&entryID, &encCategory); err != nil { + log.Printf("scan error: %v", err) + continue + } + + catStr := CryptoDecrypt(encCategory) + catInt, ok := CategoryFromString[catStr] + if !ok { + log.Printf("unknown category %q for entry %d", catStr, entryID) + unknown++ + continue + } + + _, err := db.Exec(`UPDATE entries SET category = ? WHERE entry_id = ?`, catInt, entryID) + if err != nil { + log.Printf("update error for entry %d: %v", entryID, err) + continue + } + updated++ + } + + log.Printf("Migration complete: %d updated, %d unknown", updated, unknown) + return nil +} diff --git a/lib/prompt.go b/lib/prompt.go new file mode 100644 index 0000000..b497f3b --- /dev/null +++ b/lib/prompt.go @@ -0,0 +1,183 @@ +package lib + +import ( + "encoding/json" + "fmt" + "log" + "time" +) + +// PromptAdd inserts a new prompt. Generates PromptID if empty. +func PromptAdd(p *Prompt) error { + if p.PromptID == "" { + p.PromptID = NewID() + } + now := time.Now().Unix() + if p.CreatedAt == 0 { + p.CreatedAt = now + } + p.UpdatedAt = now + if p.Active == false && p.Dismissed == false { + p.Active = true // default to active + } + return Save("prompts", p) +} + +// PromptModify updates an existing prompt +func PromptModify(p *Prompt) error { + p.UpdatedAt = time.Now().Unix() + return Save("prompts", p) +} + +// PromptDelete removes a prompt +func PromptDelete(promptID string) error { + return Delete("prompts", "prompt_id", promptID) +} + +// PromptGet retrieves a single prompt by ID +func PromptGet(promptID string) (*Prompt, error) { + p := &Prompt{} + return p, Load("prompts", promptID, p) +} + +// PromptQueryActive retrieves active prompts due for a dossier +func PromptQueryActive(dossierID string) ([]*Prompt, error) { + now := time.Now().Unix() + var result []*Prompt + err := Query(`SELECT * FROM prompts + WHERE dossier_id = ? AND active = 1 AND dismissed = 0 + AND (expires_at = 0 OR expires_at > ?) + ORDER BY + CASE WHEN next_ask <= ? OR next_ask IS NULL OR input_type = 'freeform' THEN 0 ELSE 1 END, + next_ask, time_of_day`, []any{dossierID, now, now}, &result) + return result, err +} + +// PromptQueryAll retrieves all prompts for a dossier (including inactive) +func PromptQueryAll(dossierID string) ([]*Prompt, error) { + var result []*Prompt + err := Query(`SELECT * FROM prompts WHERE dossier_id = ? ORDER BY active DESC, time_of_day, created_at`, + []any{dossierID}, &result) + return result, err +} + +// PromptRespond records a response and advances next_ask +func PromptRespond(promptID string, response, responseRaw string) error { + now := time.Now().Unix() + + // Get current prompt to calculate next_ask + p, err := PromptGet(promptID) + if err != nil { + return err + } + + p.LastResponse = response + p.LastResponseRaw = responseRaw + p.LastResponseAt = now + p.NextAsk = calculateNextAsk(p.Frequency, p.TimeOfDay, now) + p.UpdatedAt = now + + if err := Save("prompts", p); err != nil { + return err + } + + // Create entry for certain prompt types + if err := promptCreateEntry(p, response, now); err != nil { + // Log but don't fail the response + log.Printf("Failed to create entry for prompt %s: %v", promptID, err) + } + + return nil +} + +// promptCreateEntry creates an entry from a prompt response +// Uses the prompt's category/type directly - no hardcoded mappings +func promptCreateEntry(p *Prompt, response string, timestamp int64) error { + // Skip freeform/note types for now + if p.InputType == "freeform" { + return nil + } + + // Entry inherits category/type from prompt + e := &Entry{ + DossierID: p.DossierID, + Category: CategoryFromString[p.Category], // Prompt still uses string, convert here + Type: p.Type, + Value: responseToValue(response), + Timestamp: timestamp, + Data: fmt.Sprintf(`{"response":%s,"source":"prompt","prompt_id":"%s"}`, response, p.PromptID), + } + return EntryAdd(e) +} + +// responseToValue converts JSON response to a human-readable value string +func responseToValue(response string) string { + var resp map[string]interface{} + if err := json.Unmarshal([]byte(response), &resp); err != nil { + return response // fallback to raw + } + + // Single value + if v, ok := resp["value"]; ok { + return fmt.Sprintf("%v", v) + } + + // Blood pressure style: systolic/diastolic + if sys, ok := resp["systolic"]; ok { + if dia, ok := resp["diastolic"]; ok { + return fmt.Sprintf("%v/%v", sys, dia) + } + } + + // Fallback: join all values + var parts []string + for _, v := range resp { + parts = append(parts, fmt.Sprintf("%v", v)) + } + if len(parts) > 0 { + return fmt.Sprintf("%v", parts[0]) // just first for now + } + return response +} + +// PromptDismiss marks a prompt as dismissed +func PromptDismiss(promptID string) error { + p, err := PromptGet(promptID) + if err != nil { + return err + } + p.Dismissed = true + p.UpdatedAt = time.Now().Unix() + return Save("prompts", p) +} + +// PromptSkip advances next_ask to tomorrow without recording a response +func PromptSkip(promptID string) error { + p, err := PromptGet(promptID) + if err != nil { + return err + } + now := time.Now().Unix() + p.NextAsk = now + 24*60*60 + p.UpdatedAt = now + return Save("prompts", p) +} + +// calculateNextAsk determines when to ask again based on frequency +func calculateNextAsk(frequency, timeOfDay string, now int64) int64 { + switch frequency { + case "once": + return 0 // never ask again (will be filtered by expires_at or dismissed) + case "daily": + return now + 24*60*60 + case "twice_daily": + return now + 12*60*60 + case "weekly": + return now + 7*24*60*60 + case "until_resolved": + return now + 24*60*60 // ask daily until dismissed + default: + // Handle "weekly:mon,wed,fri" or other patterns later + return now + 24*60*60 + } +} diff --git a/lib/roles.go b/lib/roles.go new file mode 100644 index 0000000..850b960 --- /dev/null +++ b/lib/roles.go @@ -0,0 +1,245 @@ +package lib + +// ============================================================================ +// System Role Definitions +// ============================================================================ +// Predefined roles that can be applied to grantees. Each role defines +// a set of operations (r/w/d/m) at root level and optionally at specific +// categories. +// ============================================================================ + +// RoleGrant defines a single permission grant within a role +type RoleGrant struct { + Category int // 0 = root (all categories), >0 = specific category + Ops string // "r", "rw", "rwd", "rwdm" +} + +// RoleTemplate defines a predefined role with its grants +type RoleTemplate struct { + Name string // Role identifier (e.g., "Family", "Doctor") + Description string // Human-readable description + Grants []RoleGrant // Permission grants +} + +// SystemRoles defines all available role templates +var SystemRoles = []RoleTemplate{ + { + Name: "Family", + Description: "Full access for family members", + Grants: []RoleGrant{ + {Category: 0, Ops: "rwdm"}, // Full access to everything + }, + }, + { + Name: "Doctor", + Description: "Read/write access for healthcare providers", + Grants: []RoleGrant{ + {Category: 0, Ops: "rw"}, // Read/write to everything + }, + }, + { + Name: "Caregiver", + Description: "Read/write access for caregivers", + Grants: []RoleGrant{ + {Category: 0, Ops: "rw"}, // Read/write to everything + }, + }, + { + Name: "Trainer", + Description: "Read-only with write access to exercise and nutrition", + Grants: []RoleGrant{ + {Category: 0, Ops: "r"}, // Read everything + {Category: CategoryExercise, Ops: "rw"}, // Write exercise + {Category: CategoryNutrition, Ops: "rw"}, // Write nutrition + }, + }, + { + Name: "Friend", + Description: "Read-only access", + Grants: []RoleGrant{ + {Category: 0, Ops: "r"}, // Read everything + }, + }, + { + Name: "Researcher", + Description: "Read-only access for research purposes", + Grants: []RoleGrant{ + {Category: 0, Ops: "r"}, // Read everything + }, + }, +} + +// GetSystemRoles returns all system role templates +func GetSystemRoles() []RoleTemplate { + return SystemRoles +} + +// GetSystemRole returns a specific role template by name +func GetSystemRole(name string) *RoleTemplate { + for _, r := range SystemRoles { + if r.Name == name { + return &r + } + } + return nil +} + +// ApplyRoleTemplate creates access grants for a grantee based on a role template. +// For category-specific grants, creates a root entry for that category in the +// dossier to serve as the grant target. +func ApplyRoleTemplate(dossierID, granteeID, roleName string) error { + role := GetSystemRole(roleName) + if role == nil { + // Unknown role - create basic read-only grant + return AccessGrantWrite(&Access{ + DossierID: dossierID, + GranteeID: granteeID, + EntryID: "", // root + Role: roleName, + Ops: "r", + }) + } + + var grants []*Access + for _, g := range role.Grants { + grant := &Access{ + DossierID: dossierID, + GranteeID: granteeID, + Role: roleName, + Ops: g.Ops, + } + + if g.Category == 0 { + // Root grant + grant.EntryID = "" + } else { + // Category-specific grant + // Find or create category root entry + catRootID, err := findOrCreateCategoryRoot(dossierID, g.Category) + if err != nil { + return err + } + grant.EntryID = catRootID + } + + grants = append(grants, grant) + } + + err := AccessGrantWrite(grants...) + if err != nil { + return err + } + + // Invalidate cache for grantee + InvalidateCacheForAccessor(granteeID) + return nil +} + +// findOrCreateCategoryRoot finds or creates a root entry for category-level grants +// This is a virtual entry that serves as parent for all entries of that category +func findOrCreateCategoryRoot(dossierID string, category int) (string, error) { + // Look for existing category root entry (type = "category_root") + entries, err := EntryList(nil, "", category, &EntryFilter{ + DossierID: dossierID, + Type: "category_root", + Limit: 1, + }) + if err == nil && len(entries) > 0 { + return entries[0].EntryID, nil + } + + // Create virtual category root entry + entry := &Entry{ + DossierID: dossierID, + Category: category, + Type: "category_root", + Value: CategoryName(category), + } + if err := EntryWrite(nil, entry); err != nil { + return "", err + } + return entry.EntryID, nil +} + +// RevokeRole removes all grants with the specified role for a grantee on a dossier +func RevokeRole(dossierID, granteeID, roleName string) error { + grants, err := AccessGrantList(&PermissionFilter{ + DossierID: dossierID, + GranteeID: granteeID, + Role: roleName, + }) + if err != nil { + return err + } + + var ids []string + for _, g := range grants { + ids = append(ids, g.AccessID) + } + + if len(ids) > 0 { + if err := AccessGrantRemove(ids...); err != nil { + return err + } + } + + // Invalidate cache for grantee + InvalidateCacheForAccessor(granteeID) + return nil +} + +// GetGranteeRole returns the primary role name for a grantee on a dossier +// If multiple roles exist, returns the first one found +func GetGranteeRole(dossierID, granteeID string) string { + grants, err := AccessGrantList(&PermissionFilter{ + DossierID: dossierID, + GranteeID: granteeID, + }) + if err != nil || len(grants) == 0 { + return "" + } + return grants[0].Role +} + +// GetGranteesWithAccess returns all grantees with any access to a dossier +// along with their role and ops +type GranteeSummary struct { + GranteeID string + Name string // Resolved from dossier + Role string + Ops string // Combined ops from all grants +} + +func GetGranteesWithAccess(dossierID string) ([]GranteeSummary, error) { + grants, err := AccessGrantList(&PermissionFilter{DossierID: dossierID}) + if err != nil { + return nil, err + } + + // Group by grantee + byGrantee := make(map[string]*GranteeSummary) + for _, g := range grants { + if g.GranteeID == "" { + continue // Skip templates + } + if byGrantee[g.GranteeID] == nil { + byGrantee[g.GranteeID] = &GranteeSummary{ + GranteeID: g.GranteeID, + Role: g.Role, + } + } + // Merge ops + byGrantee[g.GranteeID].Ops = mergeOps(byGrantee[g.GranteeID].Ops, g.Ops) + } + + // Resolve names (using nil ctx for internal operation) + var result []GranteeSummary + for _, gs := range byGrantee { + if d, err := DossierGet(nil, gs.GranteeID); err == nil { + gs.Name = d.Name + } + result = append(result, *gs) + } + + return result, nil +} diff --git a/lib/signal.go b/lib/signal.go new file mode 100644 index 0000000..1e76544 --- /dev/null +++ b/lib/signal.go @@ -0,0 +1,29 @@ +package lib + +import ( + "bytes" + "encoding/json" + "net/http" + "time" +) + +const signalAPI = "http://192.168.1.16:8080/api/v1/rpc" + +var signalRecipients = []string{"+17272252475"} + +func SendSignal(message string) { + go func() { + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "send", + "params": map[string]interface{}{ + "recipient": signalRecipients, + "message": message, + }, + "id": 1, + } + data, _ := json.Marshal(payload) + client := &http.Client{Timeout: 10 * time.Second} + client.Post(signalAPI, "application/json", bytes.NewReader(data)) + }() +} diff --git a/lib/translate.go b/lib/translate.go new file mode 100644 index 0000000..ba959dc --- /dev/null +++ b/lib/translate.go @@ -0,0 +1,64 @@ +package lib + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +var translations = make(map[string]map[string]string) + +// TranslateInit loads translation files from the given directory +func TranslateInit(langDir string) { + files, err := os.ReadDir(langDir) + if err != nil { + return + } + for _, f := range files { + if filepath.Ext(f.Name()) != ".yaml" { + continue + } + lang := f.Name()[:len(f.Name())-5] + data, err := os.ReadFile(filepath.Join(langDir, f.Name())) + if err != nil { + continue + } + var t map[string]string + if yaml.Unmarshal(data, &t) == nil { + translations[lang] = t + } + } +} + +// T translates a key to the given language, falling back to English +func T(lang, key string) string { + if t, ok := translations[lang]; ok { + if s, ok := t[key]; ok { + return s + } + } + if t, ok := translations["en"]; ok { + if s, ok := t[key]; ok { + return s + } + } + return "[" + key + "]" +} + +// CategoryTranslate returns the translated category name +func CategoryTranslate(cat int, lang string) string { + return T(lang, CategoryKey(cat)) +} + +// DossierLanguage returns the language for a dossier, defaulting to "en" +func DossierLanguage(dossierID string) string { + if dossierID == "" { + return "en" + } + d, err := DossierGet(nil, dossierID) // nil ctx = internal operation + if err != nil || d.Language == "" { + return "en" + } + return d.Language +} diff --git a/lib/types.go b/lib/types.go new file mode 100644 index 0000000..8a032db --- /dev/null +++ b/lib/types.go @@ -0,0 +1,375 @@ +package lib + +import ( + "fmt" + "path/filepath" + "time" +) + +// Category enum for entries and prompts +const ( + CategoryAll = iota + CategoryImaging + CategoryDocument + CategoryLab + CategoryGenome + CategoryUpload + CategoryConsultation + CategoryDiagnosis + CategoryVital + CategoryExercise + CategoryMedication + CategorySupplement + CategoryNutrition + CategoryFertility + CategorySymptom + CategoryNote + CategoryHistory + CategoryFamilyHistory + CategorySurgery + CategoryHospital + CategoryBirth + CategoryDevice + CategoryTherapy + CategoryAssessment + CategoryProvider + CategoryQuestion +) + +// GenomeTier enum - ordered by "fun" (scary last, other always last) +const ( + GenomeTierTraits = iota + 1 + GenomeTierAncestry + GenomeTierLongevity + GenomeTierMetabolism + GenomeTierMedication + GenomeTierMentalHealth + GenomeTierNeurological + GenomeTierFertility + GenomeTierBlood + GenomeTierCardiovascular + GenomeTierAutoimmune + GenomeTierDisease + GenomeTierCancer + GenomeTierOther = 99 +) + +// GenomeTierFromString maps tier category names to ints +var GenomeTierFromString = map[string]int{ + "traits": GenomeTierTraits, + "ancestry": GenomeTierAncestry, + "longevity": GenomeTierLongevity, + "metabolism": GenomeTierMetabolism, + "medication": GenomeTierMedication, + "mental_health": GenomeTierMentalHealth, + "neurological": GenomeTierNeurological, + "fertility": GenomeTierFertility, + "blood": GenomeTierBlood, + "cardiovascular": GenomeTierCardiovascular, + "autoimmune": GenomeTierAutoimmune, + "disease": GenomeTierDisease, + "cancer": GenomeTierCancer, + "other": GenomeTierOther, +} + +// CategoryFromString converts LLM triage output to category enum +var CategoryFromString = map[string]int{ + "imaging": CategoryImaging, + "slice": CategoryImaging, + "series": CategoryImaging, + "study": CategoryImaging, + "document": CategoryDocument, + "radiology_report": CategoryDocument, + "ultrasound": CategoryDocument, + "other": CategoryDocument, + "lab": CategoryLab, + "lab_report": CategoryLab, + "genome": CategoryGenome, + "genome_tier": CategoryGenome, + "rsid": CategoryGenome, + "variant": CategoryGenome, + "upload": CategoryUpload, + "consultation": CategoryConsultation, + "diagnosis": CategoryDiagnosis, + "vital": CategoryVital, + "exercise": CategoryExercise, + "medication": CategoryMedication, + "supplement": CategorySupplement, + "nutrition": CategoryNutrition, + "fertility": CategoryFertility, + "symptom": CategorySymptom, + "note": CategoryNote, + "history": CategoryHistory, + "family_history": CategoryFamilyHistory, + "surgery": CategorySurgery, + "hospitalization": CategoryHospital, + "birth": CategoryBirth, + "device": CategoryDevice, + "therapy": CategoryTherapy, + "assessment": CategoryAssessment, + "provider": CategoryProvider, + "question": CategoryQuestion, +} + +// CategoryKey returns the translation key for a category (e.g. "category003") +func CategoryKey(cat int) string { + return fmt.Sprintf("category%03d", cat) +} + +// categoryNames maps category ints back to their string names +var categoryNames = map[int]string{ + CategoryImaging: "imaging", + CategoryDocument: "document", + CategoryLab: "lab", + CategoryGenome: "genome", + CategoryUpload: "upload", + CategoryConsultation: "consultation", + CategoryDiagnosis: "diagnosis", + CategoryVital: "vital", + CategoryExercise: "exercise", + CategoryMedication: "medication", + CategorySupplement: "supplement", + CategoryNutrition: "nutrition", + CategoryFertility: "fertility", + CategorySymptom: "symptom", + CategoryNote: "note", + CategoryHistory: "history", + CategoryFamilyHistory: "family_history", + CategorySurgery: "surgery", + CategoryHospital: "hospitalization", + CategoryBirth: "birth", + CategoryDevice: "device", + CategoryTherapy: "therapy", + CategoryAssessment: "assessment", + CategoryProvider: "provider", + CategoryQuestion: "question", +} + +// CategoryTypes maps category names to their valid type values +var CategoryTypes = map[string][]string{ + "imaging": {"study", "series", "slice"}, + "document": {"radiology_report", "ultrasound", "other"}, + "lab": {"lab_report", "result"}, + "genome": {"extraction", "tier", "variant"}, + "upload": {"pending", "processed"}, + "consultation": {"visit", "telehealth"}, + "diagnosis": {"active", "resolved"}, + "vital": {"weight", "height", "blood_pressure", "heart_rate", "temperature", "oxygen", "glucose"}, + "exercise": {"activity", "workout"}, + "medication": {"prescription", "otc"}, + "supplement": {"vitamin", "mineral", "herbal"}, + "nutrition": {"meal", "snack"}, + "fertility": {"cycle", "ovulation", "pregnancy"}, + "symptom": {"acute", "chronic"}, + "note": {"general", "clinical"}, + "history": {"medical", "surgical"}, + "family_history": {"parent", "sibling", "grandparent"}, + "surgery": {"inpatient", "outpatient"}, + "hospitalization": {"admission", "discharge"}, + "birth": {"delivery", "newborn"}, + "device": {"implant", "wearable"}, + "therapy": {"physical", "occupational", "speech"}, + "assessment": {"screening", "evaluation"}, + "provider": {"physician", "specialist", "nurse"}, + "question": {"inquiry", "followup"}, +} + +// Categories returns all category definitions +func Categories() []struct { + ID int + Name string + Types []string +} { + var result []struct { + ID int + Name string + Types []string + } + for i := 1; i <= CategoryQuestion; i++ { + name := categoryNames[i] + result = append(result, struct { + ID int + Name string + Types []string + }{i, name, CategoryTypes[name]}) + } + return result +} + +// CategoryName returns the string name for a category int +func CategoryName(cat int) string { + if name, ok := categoryNames[cat]; ok { + return name + } + return "unknown" +} + +// ObjectDir is the base path for encrypted object storage +const ObjectDir = "/tank/inou/objects" + +// ObjectPath returns the storage path for an entry: {base}/{dossier_hex}/{first_byte}/{entry_hex} +func ObjectPath(dossierID, entryID string) string { + return filepath.Join(ObjectDir, dossierID, entryID[0:2], entryID) +} + +// ParseID converts 16-char hex string to int64 (for legacy/token use) +func ParseID(s string) int64 { + var id int64 + fmt.Sscanf(s, "%x", &id) + return id +} + +// FormatID converts int64 to 16-char hex string (for legacy/token use) +func FormatID(id int64) string { + return fmt.Sprintf("%016x", id) +} + +// Access represents a permission grant or role template +type Access struct { + AccessID string `db:"access_id,pk"` + DossierID string `db:"dossier_id"` // whose data (null = system template) + GranteeID string `db:"grantee_id"` // who gets access (null = role template) + EntryID string `db:"entry_id"` // specific entry (null = root level) + Role string `db:"role"` // "Trainer", "Family", custom + Ops string `db:"ops"` // "r", "rw", "rwd", "rwdm" + CreatedAt int64 `db:"created_at"` +} + +// HasOp checks if the access grant includes a specific operation +func (a *Access) HasOp(op rune) bool { + for _, c := range a.Ops { + if c == op { + return true + } + } + return false +} + +// CanRead returns true if ops includes 'r' +func (a *Access) CanRead() bool { return a.HasOp('r') } + +// CanWrite returns true if ops includes 'w' +func (a *Access) CanWrite() bool { return a.HasOp('w') } + +// CanDelete returns true if ops includes 'd' +func (a *Access) CanDelete() bool { return a.HasOp('d') } + +// CanManage returns true if ops includes 'm' +func (a *Access) CanManage() bool { return a.HasOp('m') } + +// Dossier represents a user profile (decrypted) +type Dossier struct { + DossierID string `db:"dossier_id,pk"` + EmailHash string `db:"email_hash"` + Email string `db:"email"` + Name string `db:"name"` + DateOfBirth string `db:"date_of_birth"` // encrypted YYYY-MM-DD + DOB time.Time `db:"-"` // parsed date, not stored + Sex int `db:"sex"` + Phone string `db:"phone"` + Language string `db:"language"` + Timezone string `db:"timezone"` + AuthCode int `db:"auth_code"` + AuthCodeExpiresAt int64 `db:"auth_code_expires_at"` + LastLogin int64 `db:"last_login"` + InvitedByDossierID string `db:"invited_by_dossier_id"` + CreatedAt int64 `db:"created_at"` + WeightUnit string `db:"weight_unit"` + HeightUnit string `db:"height_unit"` + LastPullAt int64 `db:"last_pull_at"` + IsProvider bool `db:"is_provider"` + ProviderName string `db:"provider_name"` + AwayMessage string `db:"away_message"` + AwayEnabled bool `db:"away_enabled"` + SessionToken string `db:"session_token"` +} + +// SexKey returns the translation key for the sex enum (ISO/IEC 5218) +// 0=not known, 1=male, 2=female, 9=not applicable +func (d *Dossier) SexKey() string { + switch d.Sex { + case 1: + return "sex_male" + case 2: + return "sex_female" + case 9: + return "sex_na" + default: + return "" + } +} + +// DossierAccess represents sharing permissions (legacy - use RBAC access table instead) +type DossierAccess struct { + AccessID string `db:"access_id,pk"` + AccessorDossierID string `db:"accessor_dossier_id"` + TargetDossierID string `db:"target_dossier_id"` + Relation int `db:"relation"` + IsCareReceiver bool `db:"is_care_receiver"` + CanEdit bool `db:"can_edit"` + Status int `db:"status"` + CreatedAt int64 `db:"created_at"` + AccessedAt int64 `db:"accessed_at"` +} + +// Entry represents any data item (decrypted) +type Entry struct { + EntryID string `db:"entry_id,pk"` + DossierID string `db:"dossier_id"` + ParentID string `db:"parent_id"` + ProductID string `db:"product_id"` + Category int `db:"category"` + Type string `db:"type"` + Value string `db:"value"` + Summary string `db:"summary"` + Ordinal int `db:"ordinal"` + Timestamp int64 `db:"timestamp"` + TimestampEnd int64 `db:"timestamp_end"` + Status int `db:"status"` + Tags string `db:"tags"` + Data string `db:"data"` +} + +// Audit represents an audit log entry +type AuditEntry struct { + AuditID string `db:"audit_id,pk"` + Actor1ID string `db:"actor1_id"` + Actor2ID string `db:"actor2_id"` + TargetID string `db:"target_id"` + Action string `db:"action,encrypt"` + Details string `db:"details,encrypt"` + RelationID int `db:"relation_id"` + Timestamp int64 `db:"timestamp"` +} + +// Prompt represents a scheduled question or tracker (decrypted) +type Prompt struct { + PromptID string `db:"prompt_id,pk"` + DossierID string `db:"dossier_id"` + Category string `db:"category"` + Type string `db:"type"` + Question string `db:"question"` + Frequency string `db:"frequency"` + TimeOfDay string `db:"time_of_day"` + Schedule string `db:"schedule"` + NextAsk int64 `db:"next_ask"` + ExpiresAt int64 `db:"expires_at"` + InputType string `db:"input_type"` + InputConfig string `db:"input_config"` + GroupName string `db:"group_name"` + TriggerEntry int64 `db:"trigger_entry"` + CreatedBy int64 `db:"created_by"` + SourceInput string `db:"source_input"` + + // Last response (for pre-filling) + LastResponse string `db:"last_response"` + LastResponseRaw string `db:"last_response_raw"` + LastResponseAt int64 `db:"last_response_at"` + + // State + Dismissed bool `db:"dismissed"` + Open bool `db:"open"` + Active bool `db:"active"` + CreatedAt int64 `db:"created_at"` + UpdatedAt int64 `db:"updated_at"` +} \ No newline at end of file diff --git a/lib/v2.go b/lib/v2.go new file mode 100644 index 0000000..8b127c2 --- /dev/null +++ b/lib/v2.go @@ -0,0 +1,1088 @@ +package lib + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "time" +) + +// ============================================================================ +// V2 API - Clean data layer using Store +// ============================================================================ +// +// RBAC ENFORCEMENT: +// All data access functions take AccessContext as first parameter. +// Permission checks happen HERE at the lowest level - there is NO WAY to bypass. +// +// Rules: +// - ctx == nil or ctx.IsSystem → allow (for internal/system operations) +// - ctx.AccessorID == dossierID → allow (owner) +// - Otherwise → check access grants in database +// +// ============================================================================ + +// --- ENTRY --- + +type EntryFilter struct { + DossierID string + Type string + Value string + FromDate int64 + ToDate int64 + Limit int +} + +// EntryWrite creates/updates entries. Requires write permission on parent. +func EntryWrite(ctx *AccessContext, entries ...*Entry) error { + if len(entries) == 0 { + return nil + } + + // RBAC: Check write permission for each entry + for _, e := range entries { + if e.DossierID == "" { + return fmt.Errorf("entry missing dossier_id") + } + // Check write on parent (or root if no parent) + if err := checkAccess(ctx, e.DossierID, e.ParentID, 'w'); err != nil { + return err + } + } + + for _, e := range entries { + if e.EntryID == "" { + e.EntryID = NewID() + } + } + if len(entries) == 1 { + return Save("entries", entries[0]) + } + return Save("entries", entries) +} + +// EntryRemove deletes entries. Requires delete permission. +func EntryRemove(ctx *AccessContext, ids ...string) error { + // RBAC: Check delete permission for each entry + for _, id := range ids { + e, err := entryGetRaw(id) + if err != nil { + continue // Entry doesn't exist, skip + } + if err := checkAccess(ctx, e.DossierID, id, 'd'); err != nil { + return err + } + } + return deleteByIDs("entries", "entry_id", ids) +} + +// EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root. +func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error { + // RBAC: Check delete permission on dossier root + if err := checkAccess(ctx, dossierID, "", 'd'); err != nil { + return err + } + + var entries []*Entry + if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ?", []any{dossierID}, &entries); err != nil { + return err + } + for _, e := range entries { + if err := Delete("entries", "entry_id", e.EntryID); err != nil { + return err + } + } + return nil +} + +// EntryGet retrieves an entry. Requires read permission. +func EntryGet(ctx *AccessContext, id string) (*Entry, error) { + e, err := entryGetRaw(id) + if err != nil { + return nil, err + } + + // RBAC: Check read permission + if err := checkAccess(ctx, e.DossierID, id, 'r'); err != nil { + return nil, err + } + + return e, nil +} + +// entryGetRaw retrieves an entry without permission check (internal use only) +func entryGetRaw(id string) (*Entry, error) { + e := &Entry{} + return e, Load("entries", id, e) +} + +// EntryList retrieves entries. Requires read permission on parent/dossier. +func EntryList(ctx *AccessContext, parent string, category int, f *EntryFilter) ([]*Entry, error) { + // RBAC: Determine dossier and check read permission + dossierID := "" + if f != nil { + dossierID = f.DossierID + } + if dossierID == "" && parent != "" { + // Get dossier from parent entry + if p, err := entryGetRaw(parent); err == nil { + dossierID = p.DossierID + } + } + if dossierID != "" { + if err := checkAccess(ctx, dossierID, parent, 'r'); err != nil { + return nil, err + } + } + + q := "SELECT * FROM entries WHERE 1=1" + args := []any{} + + if category > 0 { + q += " AND category = ?" + args = append(args, category) + } + + if parent == "" { + q += " AND (parent_id IS NULL OR parent_id = '')" + } else { + q += " AND parent_id = ?" + args = append(args, parent) + } + + if f != nil { + if f.DossierID != "" { + q += " AND dossier_id = ?" + args = append(args, f.DossierID) + } + if f.Type != "" { + q += " AND type = ?" + args = append(args, CryptoEncrypt(f.Type)) + } + if f.Value != "" { + q += " AND value = ?" + args = append(args, CryptoEncrypt(f.Value)) + } + if f.FromDate > 0 { + q += " AND timestamp >= ?" + args = append(args, f.FromDate) + } + if f.ToDate > 0 { + q += " AND timestamp < ?" + args = append(args, f.ToDate) + } + } + + q += " ORDER BY ordinal" + + if f != nil && f.Limit > 0 { + q += fmt.Sprintf(" LIMIT %d", f.Limit) + } + + var result []*Entry + err := Query(q, args, &result) + return result, err +} + +// --- DOSSIER --- + +type DossierFilter struct { + EmailHash string + DateOfBirth string + Limit int +} + +// DossierWrite creates/updates dossiers. Requires manage permission (or nil ctx for system). +func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error { + if len(dossiers) == 0 { + return nil + } + + // RBAC: For existing dossiers, check manage permission + for _, d := range dossiers { + if d.DossierID != "" { + // Update - need manage permission (unless creating own or system) + if err := checkAccess(ctx, d.DossierID, "", 'm'); err != nil { + return err + } + } + // New dossiers (no ID) are allowed - they'll get assigned an ID + } + + for _, d := range dossiers { + if d.DossierID == "" { + d.DossierID = NewID() + } + // Format DOB to encrypted string + if !d.DOB.IsZero() { + d.DateOfBirth = d.DOB.Format("2006-01-02") + } + } + if len(dossiers) == 1 { + return Save("dossiers", dossiers[0]) + } + return Save("dossiers", dossiers) +} + +// DossierRemove deletes dossiers. Requires manage permission. +func DossierRemove(ctx *AccessContext, ids ...string) error { + // RBAC: Check manage permission for each dossier + for _, id := range ids { + if err := checkAccess(ctx, id, "", 'm'); err != nil { + return err + } + } + return deleteByIDs("dossiers", "dossier_id", ids) +} + +// DossierGet retrieves a dossier. Requires read permission. +func DossierGet(ctx *AccessContext, id string) (*Dossier, error) { + // RBAC: Check read permission + if err := checkAccess(ctx, id, "", 'r'); err != nil { + return nil, err + } + + return dossierGetRaw(id) +} + +// dossierGetRaw retrieves a dossier without permission check (internal use only) +func dossierGetRaw(id string) (*Dossier, error) { + d := &Dossier{} + if err := Load("dossiers", id, d); err != nil { + return nil, err + } + // Parse DOB from encrypted string + if d.DateOfBirth != "" { + d.DOB, _ = time.Parse("2006-01-02", d.DateOfBirth) + } + return d, nil +} + +// DossierList retrieves dossiers. System only (for security - lists all dossiers). +func DossierList(ctx *AccessContext, f *DossierFilter) ([]*Dossier, error) { + // RBAC: Only system context can list all dossiers + if ctx != nil && !ctx.IsSystem { + return nil, ErrAccessDenied + } + + q := "SELECT * FROM dossiers WHERE 1=1" + args := []any{} + + if f != nil { + if f.EmailHash != "" { + q += " AND email_hash = ?" + args = append(args, f.EmailHash) + } + if f.DateOfBirth != "" { + q += " AND date_of_birth = ?" + args = append(args, CryptoEncrypt(f.DateOfBirth)) + } + if f.Limit > 0 { + q += fmt.Sprintf(" LIMIT %d", f.Limit) + } + } + + var result []*Dossier + err := Query(q, args, &result) + return result, err +} + +// DossierGetByEmail retrieves a dossier by email. System only (auth flow). +func DossierGetByEmail(ctx *AccessContext, email string) (*Dossier, error) { + // RBAC: Only system context can lookup by email (for auth) + if ctx != nil && !ctx.IsSystem { + return nil, ErrAccessDenied + } + + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("empty email") + } + q := "SELECT * FROM dossiers WHERE email = ? LIMIT 1" + var result []*Dossier + if err := Query(q, []any{CryptoEncrypt(email)}, &result); err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("not found") + } + return result[0], nil +} + +// DossierGetBySessionToken retrieves a dossier by session token. No RBAC (auth flow). +func DossierGetBySessionToken(token string) *Dossier { + if token == "" { + return nil + } + q := "SELECT * FROM dossiers WHERE session_token = ? LIMIT 1" + var result []*Dossier + if err := Query(q, []any{CryptoEncrypt(token)}, &result); err != nil { + return nil + } + if len(result) == 0 { + return nil + } + return result[0] +} + +// --- LEGACY ACCESS (dossier_access table) --- +// TODO: Migrate to new RBAC access table + +type AccessFilter struct { + AccessorID string + TargetID string + Status *int +} + +func AccessWrite(records ...*DossierAccess) error { + if len(records) == 0 { + return nil + } + for _, r := range records { + if r.AccessID == "" { + r.AccessID = NewID() + } + } + if len(records) == 1 { + return Save("dossier_access", records[0]) + } + return Save("dossier_access", records) +} + +func AccessRemove(accessorID, targetID string) error { + access, err := AccessGet(accessorID, targetID) + if err != nil { + return err + } + return Delete("dossier_access", "access_id", access.AccessID) +} + +func AccessGet(accessorID, targetID string) (*DossierAccess, error) { + q := "SELECT * FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ?" + var result []*DossierAccess + if err := Query(q, []any{accessorID, targetID}, &result); err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("not found") + } + return result[0], nil +} + +func AccessList(f *AccessFilter) ([]*DossierAccess, error) { + q := "SELECT * FROM dossier_access WHERE 1=1" + args := []any{} + + if f != nil { + if f.AccessorID != "" { + q += " AND accessor_dossier_id = ?" + args = append(args, f.AccessorID) + } + if f.TargetID != "" { + q += " AND target_dossier_id = ?" + args = append(args, f.TargetID) + } + if f.Status != nil { + q += " AND status = ?" + args = append(args, *f.Status) + } + } + + var result []*DossierAccess + err := Query(q, args, &result) + return result, err +} + +// --- AUDIT --- + +type AuditFilter struct { + ActorID string + TargetID string + Action string + FromDate int64 + ToDate int64 + Limit int +} + +func AuditWrite(entries ...*AuditEntry) error { + if len(entries) == 0 { + return nil + } + for _, a := range entries { + if a.AuditID == "" { + a.AuditID = NewID() + } + } + if len(entries) == 1 { + return Save("audit", entries[0]) + } + return Save("audit", entries) +} + +func AuditList(f *AuditFilter) ([]*AuditEntry, error) { + q := "SELECT * FROM audit WHERE 1=1" + args := []any{} + + if f != nil { + if f.ActorID != "" { + q += " AND actor1_id = ?" + args = append(args, f.ActorID) + } + if f.TargetID != "" { + q += " AND target_id = ?" + args = append(args, f.TargetID) + } + if f.Action != "" { + q += " AND action = ?" + args = append(args, CryptoEncrypt(f.Action)) + } + if f.FromDate > 0 { + q += " AND timestamp >= ?" + args = append(args, f.FromDate) + } + if f.ToDate > 0 { + q += " AND timestamp < ?" + args = append(args, f.ToDate) + } + } + + q += " ORDER BY timestamp DESC" + + if f != nil && f.Limit > 0 { + q += fmt.Sprintf(" LIMIT %d", f.Limit) + } + + var result []*AuditEntry + err := Query(q, args, &result) + return result, err +} + +// --- PROMPT --- + +type PromptFilter struct { + DossierID string + Category string + Type string + ActiveOnly bool + Limit int +} + +func PromptWrite(prompts ...*Prompt) error { + if len(prompts) == 0 { + return nil + } + for _, p := range prompts { + if p.PromptID == "" { + p.PromptID = NewID() + } + } + if len(prompts) == 1 { + return Save("prompts", prompts[0]) + } + return Save("prompts", prompts) +} + +func PromptRemove(ids ...string) error { + return deleteByIDs("prompts", "prompt_id", ids) +} + +func PromptList(f *PromptFilter) ([]*Prompt, error) { + q := "SELECT * FROM prompts WHERE 1=1" + args := []any{} + + if f != nil { + if f.DossierID != "" { + q += " AND dossier_id = ?" + args = append(args, f.DossierID) + } + if f.Category != "" { + q += " AND category = ?" + args = append(args, CryptoEncrypt(f.Category)) + } + if f.Type != "" { + q += " AND type = ?" + args = append(args, CryptoEncrypt(f.Type)) + } + if f.ActiveOnly { + q += " AND active = 1 AND dismissed = 0" + } + } + + q += " ORDER BY active DESC, time_of_day, created_at" + + if f != nil && f.Limit > 0 { + q += fmt.Sprintf(" LIMIT %d", f.Limit) + } + + var result []*Prompt + err := Query(q, args, &result) + return result, err +} + +// --- IMAGE --- + +type ImageOpts struct { + WC, WW float64 // window center/width (0 = defaults) + Zoom float64 // unused + PanX, PanY float64 // unused +} + +// ImageGet retrieves an image. Requires read permission. +func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) { + e, err := entryGetRaw(id) + if err != nil { + return nil, err + } + + // RBAC: Check read permission + if err := checkAccess(ctx, e.DossierID, id, 'r'); err != nil { + return nil, err + } + + var data struct { + WindowCenter float64 `json:"window_center"` + WindowWidth float64 `json:"window_width"` + PixelMin int `json:"pixel_min"` + PixelMax int `json:"pixel_max"` + RescaleSlope float64 `json:"rescale_slope"` + RescaleIntercept float64 `json:"rescale_intercept"` + } + json.Unmarshal([]byte(e.Data), &data) + + slope := data.RescaleSlope + if slope == 0 { + slope = 1 + } + + center, width := data.WindowCenter, data.WindowWidth + if data.RescaleIntercept != 0 { + center = (center - data.RescaleIntercept) / slope + width = width / slope + } + if center == 0 && width == 0 { + center = float64(data.PixelMin+data.PixelMax) / 2 + width = float64(data.PixelMax - data.PixelMin) + if width == 0 { + width = 1 + } + } + + if opts != nil { + if opts.WC != 0 { + center = opts.WC + } + if opts.WW != 0 { + width = opts.WW + } + } + + dec, err := objectReadRaw(e.DossierID, id) + if err != nil { + return nil, err + } + + img, err := png.Decode(bytes.NewReader(dec)) + if err != nil { + return nil, err + } + g16, ok := img.(*image.Gray16) + if !ok { + return nil, fmt.Errorf("not 16-bit grayscale") + } + + low, high := center-width/2, center+width/2 + lut := make([]uint8, 65536) + for i := range lut { + if float64(i) <= low { + lut[i] = 0 + } else if float64(i) >= high { + lut[i] = 255 + } else { + lut[i] = uint8((float64(i) - low) * 255 / width) + } + } + + bounds := g16.Bounds() + out := image.NewGray(bounds) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + out.SetGray(x, y, color.Gray{Y: lut[g16.Gray16At(x, y).Y]}) + } + } + + var buf bytes.Buffer + png.Encode(&buf, out) + return buf.Bytes(), nil +} + +// --- OBJECT STORAGE --- + +// ObjectWrite encrypts and writes data to the object store. Requires write permission. +func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error { + // RBAC: Check write permission + if err := checkAccess(ctx, dossierID, entryID, 'w'); err != nil { + return err + } + + path := ObjectPath(dossierID, entryID) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + encrypted := CryptoEncryptBytes(data) + return os.WriteFile(path, encrypted, 0644) +} + +// ObjectRead reads and decrypts data from the object store. Requires read permission. +func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) { + // RBAC: Check read permission + if err := checkAccess(ctx, dossierID, entryID, 'r'); err != nil { + return nil, err + } + + raw, err := os.ReadFile(ObjectPath(dossierID, entryID)) + if err != nil { + return nil, err + } + return CryptoDecryptBytes(raw) +} + +// objectReadRaw reads object without permission check (internal use only, e.g. ImageGet) +func objectReadRaw(dossierID, entryID string) ([]byte, error) { + raw, err := os.ReadFile(ObjectPath(dossierID, entryID)) + if err != nil { + return nil, err + } + return CryptoDecryptBytes(raw) +} + +// ObjectRemove deletes an object from the store. Requires delete permission. +func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error { + // RBAC: Check delete permission + if err := checkAccess(ctx, dossierID, entryID, 'd'); err != nil { + return err + } + return os.Remove(ObjectPath(dossierID, entryID)) +} + +// ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission. +func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error { + // RBAC: Check delete permission on dossier root + if err := checkAccess(ctx, dossierID, "", 'd'); err != nil { + return err + } + return os.RemoveAll(filepath.Join(ObjectDir, dossierID)) +} + +// DossierListAccessible is now in access.go with RBAC enforcement + +// DossierGetByEmailHash returns a dossier by email_hash. System only. +func DossierGetByEmailHash(ctx *AccessContext, emailHash string) (*Dossier, error) { + dossiers, err := DossierList(ctx, &DossierFilter{EmailHash: emailHash, Limit: 1}) + if err != nil { + return nil, err + } + if len(dossiers) == 0 { + return nil, fmt.Errorf("not found") + } + return dossiers[0], nil +} + +// DossierGetFirst returns the first dossier (for dev/test purposes only). System only. +func DossierGetFirst(ctx *AccessContext) (*Dossier, error) { + dossiers, err := DossierList(ctx, &DossierFilter{Limit: 1}) + if err != nil { + return nil, err + } + if len(dossiers) == 0 { + return nil, fmt.Errorf("no dossiers") + } + return dossiers[0], nil +} + +// AccessListByTargetWithNames returns access records with accessor names joined from dossiers +func AccessListByTargetWithNames(targetID string) ([]map[string]interface{}, error) { + // Get access records via store.go + accessList, err := AccessList(&AccessFilter{TargetID: targetID}) + if err != nil { + return nil, err + } + + var result []map[string]interface{} + for _, a := range accessList { + name := "" + if d, err := DossierGet(nil, a.AccessorDossierID); err == nil && d != nil { + name = d.Name + } + + result = append(result, map[string]interface{}{ + "accessor_id": a.AccessorDossierID, + "name": name, + "relation": a.Relation, + "is_care_receiver": a.IsCareReceiver, + "can_edit": a.CanEdit, + }) + } + return result, nil +} + +// PromptDistinctTypes returns distinct category/type pairs for a dossier's active prompts +func PromptDistinctTypes(dossierID string) (map[string][]string, error) { + var prompts []*Prompt + if err := Query("SELECT * FROM prompts WHERE dossier_id = ? AND active = 1", []any{dossierID}, &prompts); err != nil { + return nil, err + } + + // Extract distinct category/type pairs + seen := make(map[string]bool) + result := make(map[string][]string) + for _, p := range prompts { + key := p.Category + "|" + p.Type + if !seen[key] && p.Category != "" && p.Type != "" { + seen[key] = true + result[p.Category] = append(result[p.Category], p.Type) + } + } + return result, nil +} + +// --- RBAC PERMISSIONS --- + +type PermissionFilter struct { + DossierID string // whose data + GranteeID string // who has access + EntryID string // specific entry + Role string // filter by role name +} + +// AccessWrite saves one or more access grants +func AccessGrantWrite(grants ...*Access) error { + if len(grants) == 0 { + return nil + } + for _, a := range grants { + if a.AccessID == "" { + a.AccessID = NewID() + } + if a.CreatedAt == 0 { + a.CreatedAt = time.Now().Unix() + } + } + if len(grants) == 1 { + return Save("access", grants[0]) + } + return Save("access", grants) +} + +// AccessGrantRemove removes access grants by ID +func AccessGrantRemove(ids ...string) error { + for _, id := range ids { + if err := Delete("access", "access_id", id); err != nil { + return err + } + } + return nil +} + +// AccessGrantGet retrieves a single access grant by ID +func AccessGrantGet(id string) (*Access, error) { + a := &Access{} + return a, Load("access", id, a) +} + +// AccessGrantList retrieves access grants with optional filtering +func AccessGrantList(f *PermissionFilter) ([]*Access, error) { + q := "SELECT * FROM access WHERE 1=1" + args := []any{} + + if f != nil { + if f.DossierID != "" { + q += " AND dossier_id = ?" + args = append(args, f.DossierID) + } + if f.GranteeID != "" { + q += " AND grantee_id = ?" + args = append(args, f.GranteeID) + } + if f.EntryID != "" { + q += " AND entry_id = ?" + args = append(args, f.EntryID) + } + if f.Role != "" { + q += " AND role = ?" + args = append(args, CryptoEncrypt(f.Role)) + } + } + + q += " ORDER BY created_at DESC" + + var result []*Access + err := Query(q, args, &result) + return result, err +} + +// AccessGrantsForGrantee returns all grants for a specific grantee, grouped by dossier +func AccessGrantsForGrantee(granteeID string) (map[string][]*Access, error) { + grants, err := AccessGrantList(&PermissionFilter{GranteeID: granteeID}) + if err != nil { + return nil, err + } + + result := make(map[string][]*Access) + for _, g := range grants { + result[g.DossierID] = append(result[g.DossierID], g) + } + return result, nil +} + +// AccessRoleTemplates returns role templates (grantee_id is null) for a dossier +// Pass empty dossierID for system-wide templates +func AccessRoleTemplates(dossierID string) ([]*Access, error) { + q := "SELECT * FROM access WHERE grantee_id IS NULL OR grantee_id = ''" + args := []any{} + + if dossierID != "" { + q += " AND (dossier_id = ? OR dossier_id IS NULL OR dossier_id = '')" + args = append(args, dossierID) + } else { + q += " AND (dossier_id IS NULL OR dossier_id = '')" + } + + q += " ORDER BY role, entry_id" + + var result []*Access + err := Query(q, args, &result) + return result, err +} + +// AccessGrantRole copies role template entries for a specific grantee +func AccessGrantRole(dossierID, granteeID, role string) error { + // Get template entries for this role + templates, err := AccessRoleTemplates(dossierID) + if err != nil { + return err + } + + var grants []*Access + for _, t := range templates { + if t.Role != role { + continue + } + grants = append(grants, &Access{ + DossierID: dossierID, + GranteeID: granteeID, + EntryID: t.EntryID, + Role: role, + Ops: t.Ops, + }) + } + + if len(grants) == 0 { + // No template found, create default read-only grant + grants = append(grants, &Access{ + DossierID: dossierID, + GranteeID: granteeID, + Role: role, + Ops: "r", + }) + } + + return AccessGrantWrite(grants...) +} + +// AccessRevokeAll removes all access grants for a grantee to a specific dossier +func AccessRevokeAll(dossierID, granteeID string) error { + // Query for matching grants, then delete each by PK + grants, err := AccessGrantList(&PermissionFilter{DossierID: dossierID, GranteeID: granteeID}) + if err != nil { + return err + } + for _, g := range grants { + if err := Delete("access", "access_id", g.AccessID); err != nil { + return err + } + } + return nil +} + +// AccessRevokeEntry removes a specific entry grant for a grantee +func AccessRevokeEntry(dossierID, granteeID, entryID string) error { + grants, err := AccessGrantList(&PermissionFilter{DossierID: dossierID, GranteeID: granteeID, EntryID: entryID}) + if err != nil { + return err + } + for _, g := range grants { + if err := Delete("access", "access_id", g.AccessID); err != nil { + return err + } + } + return nil +} + +// --- GENOME QUERIES --- +// Note: These functions use nil ctx for internal operations. When called from +// API handlers, the API should perform its own RBAC check first. + +// GenomeGetExtraction returns the extraction entry for genome data +func GenomeGetExtraction(dossierID string) (*Entry, error) { + entries, err := EntryList(nil, "", CategoryGenome, &EntryFilter{ + DossierID: dossierID, + Type: "extraction", + Limit: 1, + }) + if err != nil || len(entries) == 0 { + return nil, fmt.Errorf("no genome data") + } + return entries[0], nil +} + +// GenomeTier represents a genome category tier +type GenomeTier struct { + TierID string + Category string +} + +// GenomeGetTiers returns all tier entries for a genome extraction +func GenomeGetTiers(dossierID, extractionID string) ([]GenomeTier, error) { + entries, err := EntryList(nil, extractionID, CategoryGenome, &EntryFilter{ + DossierID: dossierID, + Type: "tier", + }) + if err != nil { + return nil, err + } + + var tiers []GenomeTier + for _, e := range entries { + tiers = append(tiers, GenomeTier{ + TierID: e.EntryID, + Category: e.Value, // Value holds category name + }) + } + return tiers, nil +} + +// GenomeGetTierByCategory returns a specific tier by category name +func GenomeGetTierByCategory(dossierID, extractionID, category string) (*GenomeTier, error) { + entries, err := EntryList(nil, extractionID, CategoryGenome, &EntryFilter{ + DossierID: dossierID, + Type: "tier", + Value: category, + Limit: 1, + }) + if err != nil || len(entries) == 0 { + return nil, fmt.Errorf("tier not found") + } + return &GenomeTier{ + TierID: entries[0].EntryID, + Category: entries[0].Value, + }, nil +} + +// GenomeVariant represents a genome variant with its metadata +type GenomeVariant struct { + EntryID string + RSID string + Genotype string + Gene string + Magnitude float64 + Repute string + Summary string + Subcategory string + TierID string +} + +// GenomeGetVariants returns variants for specified tier IDs +func GenomeGetVariants(dossierID string, tierIDs []string) ([]GenomeVariant, error) { + if len(tierIDs) == 0 { + return nil, nil + } + + // Query entries for each tier and deduplicate by type (rsid) + seen := make(map[string]bool) + var variants []GenomeVariant + + for _, tierID := range tierIDs { + entries, err := EntryList(nil, tierID, CategoryGenome, &EntryFilter{DossierID: dossierID}) + if err != nil { + continue + } + + for _, e := range entries { + // Deduplicate by type (rsid) + if seen[e.Type] { + continue + } + seen[e.Type] = true + + var data struct { + Mag float64 `json:"mag"` + Rep string `json:"rep"` + Sum string `json:"sum"` + Sub string `json:"sub"` + } + json.Unmarshal([]byte(e.Data), &data) + + variants = append(variants, GenomeVariant{ + EntryID: e.EntryID, + RSID: e.Type, + Genotype: e.Value, + Gene: e.Tags, + Magnitude: data.Mag, + Repute: data.Rep, + Summary: data.Sum, + Subcategory: data.Sub, + TierID: tierID, + }) + } + } + return variants, nil +} + +// GenomeGetVariantsByTier returns variants for a specific tier +func GenomeGetVariantsByTier(dossierID, tierID string) ([]GenomeVariant, error) { + entries, err := EntryList(nil, tierID, CategoryGenome, &EntryFilter{DossierID: dossierID}) + if err != nil { + return nil, err + } + + var variants []GenomeVariant + for _, e := range entries { + var data struct { + Mag float64 `json:"mag"` + Rep string `json:"rep"` + Sum string `json:"sum"` + Sub string `json:"sub"` + } + json.Unmarshal([]byte(e.Data), &data) + + variants = append(variants, GenomeVariant{ + EntryID: e.EntryID, + RSID: e.Type, + Genotype: e.Value, + Gene: e.Tags, + Magnitude: data.Mag, + Repute: data.Rep, + Summary: data.Sum, + Subcategory: data.Sub, + TierID: tierID, + }) + } + return variants, nil +} + +// --- HELPERS --- + +func deleteByIDs(table, col string, ids []string) error { + for _, id := range ids { + if err := Delete(table, col, id); err != nil { + return err + } + } + return nil +} diff --git a/portal/static/download/inou.mcpb b/portal/static/download/inou.mcpb new file mode 100644 index 0000000..da559ce Binary files /dev/null and b/portal/static/download/inou.mcpb differ diff --git a/portal/static/download/things-mcpb.mcpb b/portal/static/download/things-mcpb.mcpb new file mode 100644 index 0000000..fd3e295 Binary files /dev/null and b/portal/static/download/things-mcpb.mcpb differ diff --git a/restart.sh b/restart.sh new file mode 100644 index 0000000..5020bd2 --- /dev/null +++ b/restart.sh @@ -0,0 +1,12 @@ +#!/bin/bash +cd /tank/inou + +echo "=== Inou Restart ===" + +# Stop +pkill -x inou-viewer 2>/dev/null && echo "Viewer: stopped" || echo "Viewer: not running" +pkill -x inou-portal 2>/dev/null && echo "Portal: stopped" || echo "Portal: not running" +sleep 1 + +# Start +./start.sh diff --git a/smtp.env b/smtp.env new file mode 100644 index 0000000..5934b58 --- /dev/null +++ b/smtp.env @@ -0,0 +1,5 @@ +SMTP_HOST=smtp.protonmail.ch +SMTP_PORT=587 +SMTP_USER=noreply@inou.com +SMTP_TOKEN=YKEPACTZJE6VJJBE +SMTP_FROM_NAME=inou health diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..8bf3f96 --- /dev/null +++ b/start.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Start Inou services +cd /tank/inou + +echo "=== Inou Start ===" + +# API (start first, portal proxies to it) +if pgrep -f "bin/api$" > /dev/null; then + echo "API: already running (PID $(pgrep -f 'bin/api$'))" +else + ./bin/api >> /tank/inou/logs/api.log 2>&1 & + sleep 0.5 + if pgrep -f "bin/api$" > /dev/null; then + echo "API: started (PID $!)" + else + echo "API: FAILED - check logs/api.log" + fi +fi + +# Viewer +if pgrep -f "bin/viewer$" > /dev/null; then + echo "Viewer: already running (PID $(pgrep -f 'bin/viewer$'))" +else + ./bin/viewer >> /tank/inou/logs/viewer.log 2>&1 & + sleep 0.5 + if pgrep -f "bin/viewer$" > /dev/null; then + echo "Viewer: started (PID $!)" + else + echo "Viewer: FAILED - check logs/viewer.log" + fi +fi + +# Portal +if pgrep -f "bin/portal$" > /dev/null; then + echo "Portal: already running (PID $(pgrep -f 'bin/portal$'))" +else + ./bin/portal >> /tank/inou/logs/portal.log 2>&1 & + sleep 0.5 + if pgrep -f "bin/portal$" > /dev/null; then + echo "Portal: started (PID $!)" + else + echo "Portal: FAILED - check logs/portal.log" + fi +fi + +echo "" +echo "Portal: https://inou.com" +echo "Viewer: https://inou.com:8767" +echo "API: https://inou.com/api/* (internal :8082)" diff --git a/static/4bit.png b/static/4bit.png new file mode 100644 index 0000000..5bba571 Binary files /dev/null and b/static/4bit.png differ diff --git a/static/4bit.webp b/static/4bit.webp new file mode 100644 index 0000000..ae2f8bd Binary files /dev/null and b/static/4bit.webp differ diff --git a/static/8bit.png b/static/8bit.png new file mode 100644 index 0000000..100d98f Binary files /dev/null and b/static/8bit.png differ diff --git a/static/api-docs.html b/static/api-docs.html new file mode 100644 index 0000000..f37c163 --- /dev/null +++ b/static/api-docs.html @@ -0,0 +1,71 @@ + + +inou API Documentation + +

inou Health Dossier API

+ +

Authentication

+

All endpoints require a token query parameter - your authentication token (dossier GUID).

+ +

Base URL

+

https://inou.com

+ +

Endpoints

+ +

GET /api/dossiers

+

List all patient dossiers accessible to this account.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/dossiers?token=YOUR_TOKEN

+ +

GET /api/studies

+

List all imaging studies in a dossier.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • dossier_guid (required) - The dossier GUID to query
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/studies?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID

+ +

GET /api/series

+

List all series in a study.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • dossier_guid (required) - The dossier GUID
  • +
  • study_guid (required) - The study GUID
  • +
  • filter (optional) - Filter by description (e.g., "T1", "FLAIR", "SAG")
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/series?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID&study_guid=STUDY_ID

+ +

GET /api/slices

+

List all slices in a series with position data.

+

Parameters:

+
    +
  • token (required) - Your authentication token
  • +
  • dossier_guid (required) - The dossier GUID
  • +
  • series_guid (required) - The series GUID
  • +
  • format (optional) - Set to "text" for plain text output
  • +
+

Example: GET https://inou.com/api/slices?token=YOUR_TOKEN&dossier_guid=DOSSIER_ID&series_guid=SERIES_ID

+ +

GET /image/{slice_guid}

+

Fetch a slice as PNG image.

+

Parameters:

+
    +
  • slice_guid (in path, required) - The slice GUID
  • +
  • token (required) - Your authentication token
  • +
  • ww (optional) - Window width for contrast (Brain=80, Bone=2000)
  • +
  • wc (optional) - Window center for brightness (Brain=40, Bone=500)
  • +
+

Example: GET https://inou.com/image/SLICE_GUID?token=YOUR_TOKEN

+ +

Default response is JSON. Add &format=text for plain text output.

+ + + diff --git a/static/api-docs.txt b/static/api-docs.txt new file mode 100644 index 0000000..71128b9 --- /dev/null +++ b/static/api-docs.txt @@ -0,0 +1,78 @@ +inou Health API +=============== + +Base URL: https://inou.com + +Authentication: Bearer token in Authorization header, or token query parameter. +Your token is your dossier ID (16-character hex). + +Example: Authorization: Bearer abc123def456789a + + +DATA TYPES +---------- +- Imaging: MRI, CT, X-ray, ultrasound (DICOM format) +- Labs: Blood tests, metabolic panels, etc. +- Genome: SNP variants with clinical annotations + + +IMAGING ENDPOINTS +----------------- + +GET /api/v1/dossiers + List all dossiers accessible to your account. + +GET /api/v1/dossiers/{dossier}/entries?category=imaging + List imaging studies for a dossier. + +GET /api/v1/dossiers/{dossier}/entries?parent={study} + List series in a study. + Optional: filter (e.g. T1, FLAIR, AX, SAG) + +GET /api/v1/dossiers/{dossier}/entries?parent={series} + List slices in a series with position data. + +GET /image/{slice}?token={dossier} + Fetch slice as PNG image. + Optional: ww (window width), wc (window center) + +GET /contact-sheet.webp/{series}?token={dossier} + Fetch thumbnail grid for navigation. + Optional: ww, wc + + +LAB ENDPOINTS +------------- + +GET /api/labs/tests?dossier={dossier} + List all available lab test names. + +GET /api/labs/results?dossier={dossier}&names={names} + Get lab results for specified tests. + Required: names (comma-separated test names) + Optional: from, to (YYYY-MM-DD), latest (true/false) + + +GENOME ENDPOINTS +---------------- + +GET /api/categories?dossier={dossier} + Get top-level observation categories. + Optional: type=genome (for genome categories) + Optional: category={category} (for subcategories) + +GET /api/genome?dossier={dossier} + Query genome variants. + Optional: gene (e.g. MTHFR, COMT) + Optional: search (gene, subcategory, or summary text) + Optional: category (filter by category) + Optional: rsids (comma-separated rs numbers) + Optional: min_magnitude (0-4) + Optional: include_hidden (true/false) + + +NAVIGATION FLOW +--------------- +Imaging: dossiers → studies → series → slices → image +Labs: dossiers → tests → results +Genome: dossiers → categories → variants diff --git a/static/app-ads.txt b/static/app-ads.txt new file mode 100644 index 0000000..e69de29 diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..37e7fba Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/download/inou.mcpb b/static/download/inou.mcpb new file mode 100644 index 0000000..8a1397e Binary files /dev/null and b/static/download/inou.mcpb differ diff --git a/static/download/mac/amd64/inou_bridge b/static/download/mac/amd64/inou_bridge new file mode 100644 index 0000000..ba5cf71 Binary files /dev/null and b/static/download/mac/amd64/inou_bridge differ diff --git a/static/download/mac/arm64/inou_bridge b/static/download/mac/arm64/inou_bridge new file mode 100644 index 0000000..c185e2c Binary files /dev/null and b/static/download/mac/arm64/inou_bridge differ diff --git a/static/download/win/amd64/inou_bridge.exe b/static/download/win/amd64/inou_bridge.exe new file mode 100644 index 0000000..7eb7147 Binary files /dev/null and b/static/download/win/amd64/inou_bridge.exe differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..0f13ed8 --- /dev/null +++ b/static/favicon.ico @@ -0,0 +1 @@ + diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..0f13ed8 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/static/input.css b/static/input.css new file mode 100644 index 0000000..029729d --- /dev/null +++ b/static/input.css @@ -0,0 +1,738 @@ +/* ======================================== + INOU INPUT SCREEN + Modern health data input with voice, camera, text + ======================================== */ + +/* Container */ +.input-container { + max-width: 480px; + margin: 0 auto; + padding: 24px 20px 32px; + min-height: 100vh; + min-height: 100dvh; /* Dynamic viewport for mobile */ + display: flex; + flex-direction: column; +} + +/* Header */ +.input-header { + text-align: center; + margin-bottom: 28px; +} + +.input-header h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text); + margin: 0 0 6px; + letter-spacing: -0.02em; +} + +.input-subtitle { + font-size: 1rem; + color: var(--text-muted); + font-weight: 300; + margin: 0; +} + +/* ======================================== + SEGMENTED CONTROL + ======================================== */ +.segment-control { + display: flex; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 4px; + margin-bottom: 24px; + gap: 4px; +} + +.segment-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 16px; + background: transparent; + border: none; + border-radius: 8px; + font-family: inherit; + font-size: 0.95rem; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.segment-btn:hover { + color: var(--text); +} + +.segment-btn.active { + background: var(--bg-card); + color: var(--accent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.segment-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* ======================================== + MODE PANELS + ======================================== */ +.mode-panels { + flex: 1; + position: relative; +} + +.mode-panel { + display: none; + animation: fadeIn 0.25s ease; +} + +.mode-panel.active { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ======================================== + TEXT INPUT PANEL + ======================================== */ +.text-input-wrap { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.text-input-wrap:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.text-input { + width: 100%; + min-height: 140px; + padding: 16px; + font-family: inherit; + font-size: 1rem; + line-height: 1.6; + color: var(--text); + background: transparent; + border: none; + resize: none; + outline: none; +} + +.text-input::placeholder { + color: var(--text-subtle); +} + +.text-input-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.char-count { + font-size: 0.8rem; + color: var(--text-subtle); +} + +.input-hints { + display: flex; + gap: 6px; +} + +.hint-tag { + font-size: 0.75rem; + padding: 4px 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-muted); +} + +/* ======================================== + VOICE INPUT PANEL + ======================================== */ +.voice-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px 16px; +} + +/* Mic Button */ +.mic-btn { + position: relative; + width: 96px; + height: 96px; + border-radius: 50%; + background: var(--bg-card); + border: 2px solid var(--border); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.mic-btn:hover { + border-color: var(--accent); + transform: scale(1.02); +} + +.mic-btn.listening { + border-color: var(--accent); + background: var(--accent-light); +} + +.mic-icon { + width: 36px; + height: 36px; + color: var(--text-muted); + transition: color 0.2s; +} + +.mic-btn.listening .mic-icon { + color: var(--accent); +} + +/* Pulse Animation */ +.mic-pulse { + position: absolute; + inset: -8px; + border-radius: 50%; + border: 2px solid var(--accent); + opacity: 0; + transform: scale(1); + pointer-events: none; +} + +.mic-btn.listening .mic-pulse { + animation: pulse 1.5s ease-out infinite; +} + +@keyframes pulse { + 0% { + opacity: 0.6; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1.4); + } +} + +/* Status Text */ +.mic-status { + margin-top: 16px; + font-size: 0.95rem; + color: var(--text-muted); + font-weight: 400; + text-align: center; +} + +.mic-btn.listening + .mic-status { + color: var(--accent); + font-weight: 500; +} + +/* Transcript Area */ +.transcript-area { + width: 100%; + margin-top: 24px; + padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + min-height: 100px; + display: none; +} + +.transcript-area.has-content { + display: block; +} + +.transcript-text { + font-size: 1rem; + line-height: 1.6; + color: var(--text); + margin: 0; +} + +.transcript-interim { + font-size: 1rem; + line-height: 1.6; + color: var(--text-subtle); + font-style: italic; + margin: 4px 0 0; +} + +/* Voice Unsupported */ +.voice-unsupported { + text-align: center; + padding: 32px; + color: var(--text-muted); +} + +.voice-unsupported svg { + color: var(--text-subtle); + margin-bottom: 12px; +} + +.voice-unsupported p { + margin: 0; + font-size: 1rem; +} + +.voice-fallback-hint { + margin-top: 8px !important; + font-size: 0.9rem !important; + color: var(--text-subtle) !important; +} + +/* ======================================== + CAMERA/SCAN PANEL + ======================================== */ +.camera-container { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + min-height: 360px; +} + +/* Camera Start State */ +.camera-start { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + min-height: 360px; +} + +.camera-start-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px 32px; + background: var(--accent-light); + border: 2px dashed var(--accent); + border-radius: 16px; + color: var(--accent); + font-family: inherit; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.camera-start-btn:hover { + background: var(--accent); + border-style: solid; + color: white; +} + +.camera-start-btn:hover svg { + stroke: white; +} + +.camera-hint { + margin-top: 16px; + font-size: 0.9rem; + color: var(--text-muted); + text-align: center; + max-width: 280px; +} + +/* Camera Viewfinder */ +.camera-viewfinder { + position: relative; + background: #000; +} + +#camera-video { + width: 100%; + height: auto; + display: block; + max-height: 400px; + object-fit: cover; +} + +.viewfinder-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.viewfinder-frame { + width: 85%; + height: 70%; + border: 2px solid rgba(255, 255, 255, 0.5); + border-radius: 8px; +} + +/* Camera Controls */ +.camera-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-around; + padding: 20px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); +} + +.camera-ctrl-btn { + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.camera-ctrl-btn svg { + width: 22px; + height: 22px; + color: white; +} + +.camera-ctrl-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Capture Button */ +.capture-btn { + width: 72px; + height: 72px; + border-radius: 50%; + background: white; + border: 4px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 4px; + transition: transform 0.1s; +} + +.capture-btn:hover { + transform: scale(1.05); +} + +.capture-btn:active { + transform: scale(0.95); +} + +.capture-btn-inner { + width: 100%; + height: 100%; + border-radius: 50%; + background: white; + border: 2px solid #ddd; +} + +/* Photo Preview */ +.photo-preview { + padding: 16px; +} + +#preview-img { + width: 100%; + height: auto; + border-radius: 8px; + max-height: 300px; + object-fit: contain; + background: var(--bg); +} + +.preview-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.preview-actions .btn { + flex: 1; +} + +/* OCR Result */ +.ocr-result { + padding: 16px; +} + +.ocr-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.ocr-header h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +/* Result Type Badges */ +.result-type-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; +} + +.result-type-badge.barcode { + background: var(--success-light); + color: var(--success); +} + +.result-type-badge.text { + background: var(--accent-light); + color: var(--accent); +} + +.result-type-badge.empty { + background: var(--bg); + color: var(--text-muted); +} + +.result-type-badge.error { + background: var(--danger-light); + color: var(--danger); +} + +.ocr-text { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + font-size: 0.95rem; + line-height: 1.6; + color: var(--text); + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; +} + +.ocr-text:focus { + outline: none; + border-color: var(--accent); +} + +.ocr-text[contenteditable="true"] { + border-color: var(--accent); + background: var(--bg-card); +} + +/* Barcode value styling - monospace, larger */ +.ocr-text.barcode-value { + font-family: "SF Mono", Monaco, "Courier New", monospace; + font-size: 1.25rem; + font-weight: 600; + text-align: center; + letter-spacing: 0.05em; + padding: 24px 16px; + color: var(--text); +} + +.ocr-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.ocr-actions .btn { + flex: 1; +} + +/* OCR Processing */ +.ocr-processing { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + min-height: 300px; +} + +.processing-spinner { + width: 48px; + height: 48px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.ocr-processing p { + margin-top: 16px; + font-size: 1rem; + color: var(--text-muted); +} + +/* Camera Error */ +.camera-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + min-height: 300px; +} + +.camera-error svg { + color: var(--danger); + margin-bottom: 16px; +} + +.camera-error p { + font-size: 1rem; + color: var(--text-muted); + margin: 0 0 20px; +} + +/* ======================================== + SUBMIT SECTION + ======================================== */ +.submit-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.submit-btn { + padding: 14px 24px; + font-size: 1.05rem; + gap: 10px; +} + +.submit-btn:disabled { + background: var(--border); + color: var(--text-subtle); + cursor: not-allowed; +} + +.submit-btn:disabled:hover { + background: var(--border); +} + +.submit-icon { + width: 20px; + height: 20px; +} + +/* ======================================== + MOBILE RESPONSIVE + ======================================== */ +@media (max-width: 480px) { + .input-container { + padding: 16px 16px 24px; + } + + .input-header h1 { + font-size: 1.5rem; + } + + .segment-btn { + padding: 10px 12px; + font-size: 0.9rem; + } + + .segment-btn span { + display: none; + } + + .segment-icon { + width: 24px; + height: 24px; + } + + .mic-btn { + width: 80px; + height: 80px; + } + + .mic-icon { + width: 32px; + height: 32px; + } + + .text-input { + min-height: 120px; + font-size: 16px; /* Prevents zoom on iOS */ + } + + .input-hints { + display: none; + } + + .camera-start { + min-height: 280px; + padding: 32px 16px; + } +} + +/* Larger phones / tablets */ +@media (min-width: 481px) and (max-width: 768px) { + .input-container { + max-width: 520px; + } +} + +/* ======================================== + SAFE AREA (notch/home indicator) + ======================================== */ +@supports (padding-bottom: env(safe-area-inset-bottom)) { + .input-container { + padding-bottom: calc(24px + env(safe-area-inset-bottom)); + } +} + +/* ======================================== + DARK MODE SUPPORT (future) + ======================================== */ +@media (prefers-color-scheme: dark) { + /* Ready for dark mode variables when inou supports it */ +} diff --git a/static/input.js b/static/input.js new file mode 100644 index 0000000..c6856c9 --- /dev/null +++ b/static/input.js @@ -0,0 +1,799 @@ +/** + * inou Health Input Screen + * Voice, Camera/OCR, and Text input for health data + */ + +(function() { + 'use strict'; + + // ======================================== + // STATE + // ======================================== + const state = { + mode: 'type', + inputValue: '', + isListening: false, + recognition: null, + transcript: '', + interimTranscript: '', + stream: null, + facingMode: 'environment', + capturedImage: null, + ocrText: '', + scanResult: null // { type: 'barcode'|'text'|'empty'|'error', format?, value } + }; + + // ======================================== + // DOM ELEMENTS + // ======================================== + const elements = {}; + + function initElements() { + // Segment control + elements.segmentBtns = document.querySelectorAll('.segment-btn'); + elements.modePanels = document.querySelectorAll('.mode-panel'); + + // Text input + elements.textInput = document.getElementById('text-input'); + elements.charCount = document.getElementById('char-count'); + + // Voice input + elements.micBtn = document.getElementById('mic-btn'); + elements.micStatus = document.getElementById('mic-status'); + elements.transcriptArea = document.getElementById('transcript-area'); + elements.transcriptText = document.getElementById('transcript-text'); + elements.transcriptInterim = document.getElementById('transcript-interim'); + elements.voiceUnsupported = document.getElementById('voice-unsupported'); + + // Camera/Scan + elements.cameraContainer = document.getElementById('camera-container'); + elements.cameraStart = document.getElementById('camera-start'); + elements.startCameraBtn = document.getElementById('start-camera-btn'); + elements.cameraViewfinder = document.getElementById('camera-viewfinder'); + elements.cameraVideo = document.getElementById('camera-video'); + elements.switchCameraBtn = document.getElementById('switch-camera-btn'); + elements.captureBtn = document.getElementById('capture-btn'); + elements.closeCameraBtn = document.getElementById('close-camera-btn'); + elements.photoPreview = document.getElementById('photo-preview'); + elements.previewImg = document.getElementById('preview-img'); + elements.retakeBtn = document.getElementById('retake-btn'); + elements.processBtn = document.getElementById('process-btn'); + elements.ocrResult = document.getElementById('ocr-result'); + elements.ocrHeader = document.querySelector('.ocr-header'); + elements.ocrText = document.getElementById('ocr-text'); + elements.ocrEditBtn = document.getElementById('ocr-edit-btn'); + elements.scanAnotherBtn = document.getElementById('scan-another-btn'); + elements.useOcrBtn = document.getElementById('use-ocr-btn'); + elements.ocrProcessing = document.getElementById('ocr-processing'); + elements.cameraError = document.getElementById('camera-error'); + elements.cameraErrorMsg = document.getElementById('camera-error-msg'); + elements.retryCameraBtn = document.getElementById('retry-camera-btn'); + elements.captureCanvas = document.getElementById('capture-canvas'); + + // Submit + elements.submitBtn = document.getElementById('submit-btn'); + } + + // ======================================== + // MODE SWITCHING + // ======================================== + function switchMode(mode) { + state.mode = mode; + + // Update segment buttons + elements.segmentBtns.forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); + + // Update panels + elements.modePanels.forEach(panel => { + panel.classList.toggle('active', panel.dataset.panel === mode); + }); + + // Stop voice if switching away + if (mode !== 'speak' && state.isListening) { + stopListening(); + } + + // Stop camera if switching away + if (mode !== 'scan' && state.stream) { + stopCamera(); + } + + updateSubmitButton(); + } + + // ======================================== + // TEXT INPUT + // ======================================== + function initTextInput() { + elements.textInput.addEventListener('input', handleTextInput); + elements.textInput.addEventListener('focus', handleTextFocus); + } + + function handleTextInput(e) { + const value = e.target.value; + state.inputValue = value; + elements.charCount.textContent = value.length; + autoResize(e.target); + updateSubmitButton(); + } + + function handleTextFocus() { + // Ensure mode is set to type when focusing text input + if (state.mode !== 'type') { + switchMode('type'); + } + } + + function autoResize(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.max(140, textarea.scrollHeight) + 'px'; + } + + // ======================================== + // VOICE INPUT (Web Speech API) + // ======================================== + function initVoiceInput() { + // Check for support + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + + if (!SpeechRecognition) { + elements.micBtn.style.display = 'none'; + elements.micStatus.style.display = 'none'; + elements.voiceUnsupported.style.display = 'block'; + return; + } + + // Initialize recognition + state.recognition = new SpeechRecognition(); + state.recognition.continuous = true; + state.recognition.interimResults = true; + state.recognition.lang = navigator.language || 'en-US'; + + // Event handlers + state.recognition.onstart = () => { + state.isListening = true; + elements.micBtn.classList.add('listening'); + elements.micStatus.textContent = 'Listening...'; + }; + + state.recognition.onend = () => { + state.isListening = false; + elements.micBtn.classList.remove('listening'); + elements.micStatus.textContent = 'Tap to start speaking'; + + // Finalize any interim transcript + if (state.interimTranscript) { + state.transcript += state.interimTranscript; + state.interimTranscript = ''; + updateTranscriptDisplay(); + } + updateSubmitButton(); + }; + + state.recognition.onerror = (event) => { + console.error('Speech recognition error:', event.error); + state.isListening = false; + elements.micBtn.classList.remove('listening'); + + let errorMsg = 'Tap to try again'; + if (event.error === 'not-allowed') { + errorMsg = 'Microphone access denied. Check permissions.'; + } else if (event.error === 'no-speech') { + errorMsg = 'No speech detected. Tap to try again.'; + } + elements.micStatus.textContent = errorMsg; + }; + + state.recognition.onresult = (event) => { + let interim = ''; + let final = ''; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + final += transcript + ' '; + } else { + interim += transcript; + } + } + + if (final) { + state.transcript += final; + } + state.interimTranscript = interim; + updateTranscriptDisplay(); + updateSubmitButton(); + }; + + // Click handler + elements.micBtn.addEventListener('click', toggleListening); + } + + function toggleListening() { + if (state.isListening) { + stopListening(); + } else { + startListening(); + } + } + + function startListening() { + if (!state.recognition) return; + + try { + state.recognition.start(); + } catch (e) { + // Already started + console.log('Recognition already started'); + } + } + + function stopListening() { + if (!state.recognition) return; + + try { + state.recognition.stop(); + } catch (e) { + console.log('Recognition already stopped'); + } + } + + function updateTranscriptDisplay() { + const hasContent = state.transcript || state.interimTranscript; + elements.transcriptArea.classList.toggle('has-content', hasContent); + elements.transcriptText.textContent = state.transcript; + elements.transcriptInterim.textContent = state.interimTranscript; + } + + // ======================================== + // CAMERA / OCR + // ======================================== + function initCamera() { + elements.startCameraBtn.addEventListener('click', startCamera); + elements.switchCameraBtn.addEventListener('click', switchCamera); + elements.captureBtn.addEventListener('click', capturePhoto); + elements.closeCameraBtn.addEventListener('click', closeCamera); + elements.retakeBtn.addEventListener('click', retakePhoto); + elements.processBtn.addEventListener('click', processOCR); + elements.ocrEditBtn.addEventListener('click', toggleOCREdit); + elements.scanAnotherBtn.addEventListener('click', scanAnother); + elements.useOcrBtn.addEventListener('click', useOCRText); + elements.retryCameraBtn.addEventListener('click', startCamera); + } + + async function startCamera() { + try { + // Hide all states, show viewfinder + hideAllCameraStates(); + elements.cameraViewfinder.style.display = 'block'; + + // Get camera stream + const constraints = { + video: { + facingMode: state.facingMode, + width: { ideal: 1920 }, + height: { ideal: 1080 } + } + }; + + state.stream = await navigator.mediaDevices.getUserMedia(constraints); + elements.cameraVideo.srcObject = state.stream; + await elements.cameraVideo.play(); + + } catch (err) { + console.error('Camera error:', err); + showCameraError(getCameraErrorMessage(err)); + } + } + + function getCameraErrorMessage(err) { + if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { + return 'Camera access denied. Please allow camera permissions.'; + } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { + return 'No camera found on this device.'; + } else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') { + return 'Camera is in use by another application.'; + } + return 'Could not access camera. Please try again.'; + } + + function showCameraError(message) { + hideAllCameraStates(); + elements.cameraErrorMsg.textContent = message; + elements.cameraError.style.display = 'flex'; + } + + function stopCamera() { + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } + elements.cameraVideo.srcObject = null; + } + + function closeCamera() { + stopCamera(); + hideAllCameraStates(); + elements.cameraStart.style.display = 'flex'; + } + + async function switchCamera() { + state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment'; + stopCamera(); + await startCamera(); + } + + function capturePhoto() { + const video = elements.cameraVideo; + const canvas = elements.captureCanvas; + + // Set canvas size to video size + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // Draw current frame + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0); + + // Get data URL + state.capturedImage = canvas.toDataURL('image/jpeg', 0.9); + + // Stop camera and show preview + stopCamera(); + hideAllCameraStates(); + elements.previewImg.src = state.capturedImage; + elements.photoPreview.style.display = 'block'; + } + + function retakePhoto() { + state.capturedImage = null; + startCamera(); + } + + // ======================================== + // BARCODE DETECTION + // ======================================== + + /** + * Try to detect barcode using native BarcodeDetector API + * Returns { found: boolean, format: string, value: string } or null + */ + async function detectBarcodeNative(imageSource) { + if (!('BarcodeDetector' in window)) { + return null; + } + + try { + const formats = await BarcodeDetector.getSupportedFormats(); + const detector = new BarcodeDetector({ formats }); + const barcodes = await detector.detect(imageSource); + + if (barcodes.length > 0) { + const barcode = barcodes[0]; + return { + found: true, + format: barcode.format, + value: barcode.rawValue + }; + } + } catch (err) { + console.log('Native barcode detection failed:', err); + } + + return { found: false }; + } + + /** + * Try to detect barcode via backend API + */ + async function detectBarcodeBackend(blob) { + try { + const formData = new FormData(); + formData.append('image', blob, 'capture.jpg'); + + const response = await fetch('/api/barcode', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + return { found: false }; + } + + const result = await response.json(); + if (result.found && result.value) { + return { + found: true, + format: result.format || 'unknown', + value: result.value + }; + } + } catch (err) { + console.log('Backend barcode detection failed:', err); + } + + return { found: false }; + } + + /** + * Main processing function: Barcode → OCR → Nothing + */ + async function processImage() { + if (!state.capturedImage) return; + + hideAllCameraStates(); + elements.ocrProcessing.style.display = 'flex'; + updateProcessingStatus('Scanning for barcode...'); + + try { + const blob = dataURLtoBlob(state.capturedImage); + + // Create an image element for native barcode detection + const img = new Image(); + img.src = state.capturedImage; + await new Promise(resolve => img.onload = resolve); + + // Step 1: Try native BarcodeDetector API (Chrome/Edge) + let barcodeResult = await detectBarcodeNative(img); + + // Step 2: If native fails, try backend barcode detection + if (!barcodeResult || !barcodeResult.found) { + barcodeResult = await detectBarcodeBackend(blob); + } + + // Step 3: If barcode found, show it + if (barcodeResult && barcodeResult.found) { + state.scanResult = { + type: 'barcode', + format: barcodeResult.format, + value: barcodeResult.value + }; + showScanResult(); + return; + } + + // Step 4: No barcode, try OCR + updateProcessingStatus('Extracting text...'); + + const formData = new FormData(); + formData.append('image', blob, 'capture.jpg'); + + const response = await fetch('/api/ocr', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + if (result.text && result.text.trim()) { + state.scanResult = { + type: 'text', + value: result.text + }; + showScanResult(); + return; + } + } + + // Step 5: Nothing found + state.scanResult = { + type: 'empty', + value: '' + }; + showScanResult(); + + } catch (err) { + console.error('Image processing error:', err); + state.scanResult = { + type: 'error', + value: 'Could not process image. Please try again.' + }; + showScanResult(); + } + } + + function updateProcessingStatus(message) { + const statusEl = elements.ocrProcessing.querySelector('p'); + if (statusEl) { + statusEl.textContent = message; + } + } + + function showScanResult() { + hideAllCameraStates(); + + const result = state.scanResult; + + if (result.type === 'barcode') { + // Show barcode result + elements.ocrHeader.innerHTML = ` +
+ + + + + + + + + Barcode (${formatBarcodeType(result.format)}) +
+ + `; + elements.ocrText.textContent = result.value; + elements.ocrText.classList.add('barcode-value'); + + } else if (result.type === 'text') { + // Show OCR result + elements.ocrHeader.innerHTML = ` +
+ + + + + + + Extracted Text +
+ + `; + elements.ocrText.textContent = result.value; + elements.ocrText.classList.remove('barcode-value'); + + } else if (result.type === 'empty') { + elements.ocrHeader.innerHTML = ` +
No content detected
+ `; + elements.ocrText.textContent = 'Try adjusting lighting or holding the camera closer to the document.'; + elements.ocrText.classList.remove('barcode-value'); + + } else { + // Error + elements.ocrHeader.innerHTML = ` +
Error
+ `; + elements.ocrText.textContent = result.value; + elements.ocrText.classList.remove('barcode-value'); + } + + elements.ocrText.contentEditable = 'false'; + elements.ocrResult.style.display = 'block'; + + // Re-bind edit button + const editBtn = document.getElementById('ocr-edit-btn'); + if (editBtn) { + editBtn.addEventListener('click', toggleOCREdit); + } + + updateSubmitButton(); + } + + function formatBarcodeType(format) { + const types = { + 'upc_a': 'UPC-A', + 'upc_e': 'UPC-E', + 'ean_13': 'EAN-13', + 'ean_8': 'EAN-8', + 'code_128': 'Code 128', + 'code_39': 'Code 39', + 'code_93': 'Code 93', + 'codabar': 'Codabar', + 'itf': 'ITF', + 'qr_code': 'QR Code', + 'data_matrix': 'Data Matrix', + 'aztec': 'Aztec', + 'pdf417': 'PDF417' + }; + return types[format] || format || 'Unknown'; + } + + // Legacy function name for backwards compatibility + async function processOCR() { + return processImage(); + } + + function toggleOCREdit() { + const isEditing = elements.ocrText.contentEditable === 'true'; + elements.ocrText.contentEditable = isEditing ? 'false' : 'true'; + if (!isEditing) { + elements.ocrText.focus(); + } + } + + function scanAnother() { + state.capturedImage = null; + state.ocrText = ''; + startCamera(); + } + + function useOCRText() { + // Get possibly edited text from the contenteditable div + const editedValue = elements.ocrText.textContent || ''; + + // Update scanResult with edited value + if (state.scanResult) { + state.scanResult.value = editedValue; + } + state.ocrText = editedValue; + + // Switch to type mode and populate + switchMode('type'); + elements.textInput.value = editedValue; + state.inputValue = editedValue; + elements.charCount.textContent = editedValue.length; + autoResize(elements.textInput); + updateSubmitButton(); + } + + function hideAllCameraStates() { + elements.cameraStart.style.display = 'none'; + elements.cameraViewfinder.style.display = 'none'; + elements.photoPreview.style.display = 'none'; + elements.ocrResult.style.display = 'none'; + elements.ocrProcessing.style.display = 'none'; + elements.cameraError.style.display = 'none'; + } + + function dataURLtoBlob(dataURL) { + const arr = dataURL.split(','); + const mime = arr[0].match(/:(.*?);/)[1]; + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new Blob([u8arr], { type: mime }); + } + + // ======================================== + // SUBMIT + // ======================================== + function initSubmit() { + elements.submitBtn.addEventListener('click', handleSubmit); + } + + function updateSubmitButton() { + let hasContent = false; + + switch (state.mode) { + case 'type': + hasContent = state.inputValue.trim().length > 0; + break; + case 'speak': + hasContent = state.transcript.trim().length > 0; + break; + case 'scan': + hasContent = state.scanResult && + state.scanResult.value && + state.scanResult.value.trim().length > 0 && + state.scanResult.type !== 'empty' && + state.scanResult.type !== 'error'; + break; + } + + elements.submitBtn.disabled = !hasContent; + } + + function handleSubmit() { + let content = ''; + let scanType = null; + let scanFormat = null; + + switch (state.mode) { + case 'type': + content = state.inputValue.trim(); + break; + case 'speak': + content = state.transcript.trim(); + break; + case 'scan': + if (state.scanResult) { + content = (elements.ocrText.textContent || state.scanResult.value || '').trim(); + scanType = state.scanResult.type; + scanFormat = state.scanResult.format || null; + } + break; + } + + if (!content) return; + + // Build payload + const payload = { + mode: state.mode, + content: content, + timestamp: new Date().toISOString() + }; + + // Add barcode-specific fields + if (state.mode === 'scan' && scanType === 'barcode') { + payload.scanType = 'barcode'; + payload.barcodeFormat = scanFormat; + payload.barcodeValue = content; + } else if (state.mode === 'scan') { + payload.scanType = 'text'; + } + + // Dispatch custom event for Flutter WebView or parent frame + const event = new CustomEvent('inou-input-submit', { detail: payload }); + window.dispatchEvent(event); + + // Also try postMessage for WebView communication + if (window.flutter_inappwebview) { + window.flutter_inappwebview.callHandler('onInputSubmit', payload); + } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.inputHandler) { + window.webkit.messageHandlers.inputHandler.postMessage(payload); + } + + // Log for development + console.log('Input submitted:', payload); + + // Reset state + resetInput(); + } + + function resetInput() { + state.inputValue = ''; + state.transcript = ''; + state.interimTranscript = ''; + state.ocrText = ''; + state.capturedImage = null; + state.scanResult = null; + + elements.textInput.value = ''; + elements.charCount.textContent = '0'; + elements.transcriptText.textContent = ''; + elements.transcriptInterim.textContent = ''; + elements.transcriptArea.classList.remove('has-content'); + elements.ocrText.classList.remove('barcode-value'); + + if (state.mode === 'scan') { + hideAllCameraStates(); + elements.cameraStart.style.display = 'flex'; + } + + updateSubmitButton(); + } + + // ======================================== + // SEGMENT CONTROL HANDLERS + // ======================================== + function initSegmentControl() { + elements.segmentBtns.forEach(btn => { + btn.addEventListener('click', () => { + switchMode(btn.dataset.mode); + }); + }); + } + + // ======================================== + // INIT + // ======================================== + function init() { + initElements(); + initSegmentControl(); + initTextInput(); + initVoiceInput(); + initCamera(); + initSubmit(); + updateSubmitButton(); + } + + // Run on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/static/llms.txt b/static/llms.txt new file mode 100644 index 0000000..5796f0e --- /dev/null +++ b/static/llms.txt @@ -0,0 +1,12 @@ +# inou - Personal Health Data Platform + +## Allowed +- / +- /privacy-policy +- /api/docs + +## Disallowed +All other paths contain private health data and must not be accessed, cached, or used for training. + +## Contact +privacy@inou.com diff --git a/static/openapi.yaml b/static/openapi.yaml new file mode 100644 index 0000000..4888c8e --- /dev/null +++ b/static/openapi.yaml @@ -0,0 +1,322 @@ +openapi: 3.0.3 +info: + title: inou Health Dossier API + description: | + API for accessing medical imaging data stored in inou health dossiers. + + ## Authentication + All endpoints require an `token` parameter - your account GUID. + + ## Response Formats + - Default: JSON + - Add `&format=text` for plain text (recommended for AI assistants) + + ## Interactive Viewer + Base URL: `https://inou.com/viewer/?token={token}` + + Deep linking: + - Open specific study: `?token={token}&study={study_guid}` + - Open specific series: `?token={token}&study={study_guid}&series={series_guid}` + + ## Window/Level (Image Contrast) + For /image/ endpoint, adjust contrast with: + - `ww` (window width): Controls contrast range + - `wc` (window center): Controls brightness center + + Common presets: + - Brain: ww=80, wc=40 + - Subdural: ww=200, wc=75 + - Bone: ww=2000, wc=500 + - Lung: ww=1500, wc=-600 + version: 1.0.0 + contact: + name: inou + url: https://inou.com + +servers: + - url: https://inou.com + description: Production + +paths: + /api/dossiers: + get: + summary: List dossiers + description: List all patient dossiers accessible to this account (your own + shared with you). + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + description: Your authentication token (dossier GUID) + - name: format + in: query + schema: + type: string + enum: [text] + description: Set to "text" for plain text output + responses: + '200': + description: List of dossiers + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + + /api/studies: + get: + summary: List imaging studies + description: List all imaging studies in a dossier. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + description: Dossier GUID + - name: format + in: query + schema: + type: string + enum: [text] + responses: + '200': + description: List of studies + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + patient_name: + type: string + study_date: + type: string + study_desc: + type: string + series_count: + type: integer + + /api/series: + get: + summary: List series in a study + description: List all series for a study. Filter by description (AX, T1, FLAIR, SAG, COR, etc). + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: study_guid + in: query + required: true + schema: + type: string + format: uuid + - name: filter + in: query + schema: + type: string + description: Filter by series description (e.g., "T1", "FLAIR", "SAG") + - name: format + in: query + schema: + type: string + enum: [text] + responses: + '200': + description: List of series + + /api/slices: + get: + summary: List slices in a series + description: List all slices with position data (mm coordinates, orientation, pixel spacing). + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: series_guid + in: query + required: true + schema: + type: string + format: uuid + - name: format + in: query + schema: + type: string + enum: [text] + responses: + '200': + description: List of slices with position info + + + /image/{slice_guid}: + get: + summary: Get slice image + description: Fetch a slice as PNG image. Adjust window/level for contrast. + parameters: + - name: slice_guid + in: path + required: true + schema: + type: string + format: uuid + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: ww + in: query + schema: + type: number + description: Window width (contrast range). Brain=80, Bone=2000, Lung=1500 + - name: wc + in: query + schema: + type: number + description: Window center (brightness). Brain=40, Bone=500, Lung=-600 + responses: + '200': + description: PNG image + content: + image/png: + schema: + type: string + format: binary + + /viewer/: + get: + summary: Interactive DICOM viewer + description: Open the web-based DICOM viewer with 3D crosshair navigation. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + description: Dossier GUID + - name: study + in: query + schema: + type: string + format: uuid + description: Open specific study + - name: series + in: query + schema: + type: string + format: uuid + description: Open specific series (requires study parameter) + responses: + '200': + description: HTML viewer page + + /api/labs/tests: + get: + summary: List lab test names + description: List all lab test names available for a dossier. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + responses: + '200': + description: List of test names + + /api/labs/results: + get: + summary: Get lab results + description: Get lab results by test name, with optional date filtering. + parameters: + - name: token + in: query + required: true + schema: + type: string + format: uuid + - name: dossier_guid + in: query + required: true + schema: + type: string + format: uuid + - name: names + in: query + required: true + schema: + type: string + description: Comma-separated test names + - name: from + in: query + schema: + type: string + format: date + description: Start date (YYYY-MM-DD) + - name: to + in: query + schema: + type: string + format: date + description: End date (YYYY-MM-DD) + - name: latest + in: query + schema: + type: boolean + description: Return only most recent result per test + responses: + '200': + description: Lab results diff --git a/static/pricing.html b/static/pricing.html new file mode 100644 index 0000000..7010c40 --- /dev/null +++ b/static/pricing.html @@ -0,0 +1,259 @@ + + + + + + inou health - Pricing + + + +
+

inou health

+

AI answers for you

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Monitor
+
Free
+
+
Optimize
+
$12/mo
+
+
Research
+
$35/mo
+
Health Data
Vitals (BP, HR, weight, temp)
Symptoms & conditions
Medications
Exercise & activity
Family history
Lab results
Consumer genome (23andMe)
Medical imaging (MRI, CT, X-ray)
Clinical genome sequencing
AI Features
MCP integration (Claude, ChatGPT)
Personalized AI answersLimited
Health trend analysis
Storage & Access
Multi-dossier support (family)
FIPS 140-3 encryption
Data export
+
+
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..1f8237f --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,16 @@ +User-agent: * +Allow: / +Allow: /privacy-policy +Allow: /connect +Disallow: /dossier +Disallow: /dashboard +Disallow: /share +Disallow: /invite +Disallow: /onboard +Disallow: /verify +Disallow: /start +Disallow: /login +Disallow: /set-lang +Disallow: /api/ + +Sitemap: https://inou.com/sitemap.xml diff --git a/static/sitemap.xml b/static/sitemap.xml new file mode 100644 index 0000000..4b482fa --- /dev/null +++ b/static/sitemap.xml @@ -0,0 +1,18 @@ + + + + https://inou.com/ + weekly + 1.0 + + + https://inou.com/privacy-policy + monthly + 0.5 + + + https://inou.com/connect + monthly + 0.7 + + diff --git a/static/slice12_thumb.png b/static/slice12_thumb.png new file mode 100644 index 0000000..2bd771d Binary files /dev/null and b/static/slice12_thumb.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..775787e --- /dev/null +++ b/static/style.css @@ -0,0 +1,1864 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #F8F7F6; + --bg-card: #FFFFFF; + --border: #E5E2DE; + --border-hover: #C4BFB8; + --text: #1C1917; + --text-muted: #78716C; + --text-subtle: #A8A29E; + --accent: #B45309; + --accent-hover: #92400E; + --accent-light: #FEF3C7; + --danger: #DC2626; + --danger-light: #FEF2F2; + --success: #059669; + --success-light: #ECFDF5; +} + +body { + font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + font-weight: 400; + line-height: 1.5; + font-size: 15px; +} + +/* Navigation */ +.nav { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 12px 24px; + max-width: 1200px; + margin: 0 auto; + border-bottom: 1px solid var(--border); +} + +.logo { + display: flex; + align-items: baseline; + gap: 6px; + font-family: "Sora", sans-serif; + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.02em; + text-transform: lowercase; + text-decoration: none; +} +.logo:hover { text-decoration: none; } +.logo .inou { color: var(--accent); font-weight: 700; } +.logo .health { color: var(--text-muted); font-weight: 300; } +.logo .logo-tagline { + font-size: 0.95rem; + font-weight: 300; + color: var(--text-muted); + letter-spacing: 0.04em; + text-transform: none; +} + +.nav-right { + display: flex; + align-items: baseline; + gap: 16px; +} + +/* User menu with hover dropdown */ +.nav-user-menu { + position: relative; +} + +.nav-user-name { + font-size: 0.85rem; + color: var(--text); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.nav-user-name:hover { + background: var(--border); +} + +.nav-user-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 100px; + z-index: 100; +} + +.nav-user-menu:hover .nav-user-dropdown { + display: block; + +} + +.nav-user-dropdown a { + display: block; + + padding: 8px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.nav-user-dropdown a:hover { + background: var(--bg); + color: var(--accent); +} + +/* Language menu */ +.lang-menu { + position: relative; +} + +.lang-current { + min-width: 36px; + display: inline-block; + text-align: center; + font-size: 0.8rem; + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; +} + +.lang-current:hover { + border-color: var(--border-hover); +} + +.lang-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 120px; + white-space: nowrap; + z-index: 100; + padding-top: 8px; + margin-top: 0; +} + +.lang-menu::after { + content: ''; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 8px; +} + +.lang-menu:hover .lang-dropdown { + display: block; + +} + +.lang-dropdown a { + display: block; + + padding: 6px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.lang-dropdown a:hover { + background: var(--bg); +} + +.lang-dropdown a.active { + color: var(--accent); + font-weight: 500; +} + +.nav-user { + font-size: 0.8rem; + color: var(--text-muted); +} + +.nav-user a { + color: var(--text); + text-decoration: none; +} + +.nav-user a:hover { + color: var(--accent); +} + +.lang-picker { + font-size: 1rem; + color: var(--text-muted); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; +} + +/* Container */ +.container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px; +} + +.container-narrow { + max-width: 360px; + margin: 0 auto; + padding: 60px 20px 40px; +} + +/* Dossier header */ +.dossier-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 24px; +} + +.dossier-header-left h1 { + margin-bottom: 0; +} + +.dossier-header-left p { + margin: 4px 0 0 0; +} + +/* Coming soon badge */ +.badge-soon { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + background: var(--bg); + border: 1px solid var(--border); + padding: 4px 8px; + border-radius: 4px; +} + +.data-card.coming-soon { + opacity: 0.6; +} + +/* Typography */ +h1 { + font-size: 2.25rem; + font-weight: 300; + line-height: 1.2; + margin-bottom: 16px; + letter-spacing: -0.03em; + color: var(--text); +} + +h1.small { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 4px; +} + +h2 { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 12px; + letter-spacing: -0.02em; +} + +h3 { + font-size: 1.125rem; + font-weight: 500; + margin-bottom: 4px; + color: var(--text); +} + +.intro { + font-size: 1rem; + color: var(--text-muted); + margin-bottom: 32px; +} + +.intro.small { + font-size: 1rem; + margin-bottom: 24px; +} + +.section-label { + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-subtle); + margin-bottom: 12px; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 10px 12px; + font-size: 1rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-group select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2378716C' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; + cursor: pointer; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.form-group input.code-input { + font-size: 1.375rem; + text-align: center; + letter-spacing: 0.4em; + font-weight: 500; + font-family: "SF Mono", "Monaco", monospace; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.radio-group { + display: flex; + gap: 16px; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 1rem; + color: var(--text); + cursor: pointer; + font-weight: 400; +} + +.radio-group input { + width: auto; + accent-color: var(--accent); +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + color: var(--text-muted); + cursor: pointer; +} + +.checkbox-group input { + width: auto; + accent-color: var(--accent); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + font-size: 1rem; + font-weight: 500; + font-family: inherit; + text-decoration: none; + border-radius: 6px; + transition: all 0.15s; + border: none; + cursor: pointer; + text-align: center; + gap: 6px; +} + +.btn-full { + width: 100%; +} + +.btn-primary { + background: var(--accent); + color: #FFFFFF; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--border-hover); + background: var(--bg); +} + +.btn-danger { + background: var(--danger-light); + color: var(--danger); + border: 1px solid #FECACA; +} + +.btn-danger:hover { + background: #FEE2E2; +} + +.btn-small { + padding: 6px 12px; + font-size: 1rem; +} + +.btn-disabled { + background: var(--bg-subtle); + color: var(--text-subtle); + cursor: not-allowed; + opacity: 0.6; +} + +.btn-icon { + padding: 4px 8px; + background: transparent; + color: var(--text-subtle); + border: none; + font-size: 1rem; + line-height: 1; + border-radius: 4px; +} + +.btn-icon:hover { + color: var(--danger); + background: var(--danger-light); +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +.card-link { + + text-decoration: none; + color: inherit; + padding: 0; + transition: all 0.15s; +} + +.card-link:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + text-decoration: none; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-header h3 { + margin-bottom: 0; +} + +.card-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.card-actions { + display: flex; + gap: 6px; +} + +.card-add { + border: 2px dashed var(--border); + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + cursor: pointer; + text-decoration: none; +} + +.card-add:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.card-add .plus { + font-size: 1.75rem; + color: var(--accent); + margin-bottom: 6px; +} + +.card-add span { + color: var(--text-muted); + font-size: 1rem; +} + +/* Profiles grid */ +.profiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +/* Profile card hover */ +.profile-card { + cursor: pointer; + transition: all 0.15s; +} + +.profile-card:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* Profile badge */ +.badge { + display: inline-block; + padding: 2px 8px; + font-size: 1rem; + font-weight: 500; + border-radius: 4px; + background: var(--accent-light); + color: var(--accent); +} + +.badge-care { + background: var(--success-light); + color: var(--success); +} + +/* Access list */ +.access-list { + margin-top: 8px; +} + +.access-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.access-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.access-info { + display: flex; + flex-direction: column; + gap: 1px; +} + +.access-name { + font-weight: 500; + font-size: 1rem; +} + +.access-relation { + font-size: 1rem; + color: var(--text-subtle); +} + +/* Messages */ +.error { + background: var(--danger-light); + border: 1px solid #FECACA; + color: var(--danger); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.info { + background: var(--accent-light); + border: 1px solid #FDE68A; + color: var(--accent); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.success { + background: var(--success-light); + border: 1px solid #A7F3D0; + color: var(--success); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +/* Trust section */ +.trust { + border-top: 1px solid var(--border); + padding-top: 32px; + margin-top: 32px; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.trust-item { + font-size: 1rem; + color: var(--text-muted); +} + +.trust-item strong { + + font-weight: 500; + color: var(--text); + margin-bottom: 2px; +} + +/* Footer */ +.footer { + margin-top: 40px; + padding-top: 12px; + border-top: 1px solid var(--border); + font-size: 1rem; + color: var(--text-subtle); +} + +/* Upload area */ +.upload-area { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + background: var(--bg-card); +} + +.upload-area:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-area.dragover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-icon { + color: var(--accent); + margin-bottom: 12px; +} + +.upload-text { + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.upload-hint { + font-size: 1rem; + color: var(--text-muted); +} + +/* Progress */ +.progress-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; + z-index: 1000; +} + +.progress-modal { + background: var(--bg-card); + padding: 32px; + border-radius: 12px; + text-align: center; + min-width: 280px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15); +} + +.progress-bar-wrap { + background: var(--border); + border-radius: 4px; + height: 6px; + overflow: hidden; + margin-top: 16px; +} + +.progress-bar { + background: var(--accent); + height: 100%; + width: 0%; + transition: width 0.2s; +} + +.progress-detail { + margin-top: 12px; + font-size: 1rem; + color: var(--text-muted); + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: auto; + margin-right: auto; +} + +/* File table */ +.file-table { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.file-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 1rem; +} + +.file-row:last-child { + border-bottom: none; +} + +.file-row.file-deleted { + background: var(--bg); + color: var(--text-subtle); +} + +.file-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.file-name { + color: var(--text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-deleted .file-name { + color: var(--text-subtle); + text-decoration: line-through; +} + +.file-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.file-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 1rem; + flex-shrink: 0; +} + +.status-expires { + color: var(--text-muted); +} + +.status-deleted { + color: var(--text-subtle); + font-style: italic; +} + +/* Link */ +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Helpers */ +.text-center { text-align: center; } +.text-muted { color: var(--text-muted); } +.text-small { font-size: 1rem; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mt-24 { margin-top: 24px; } +.mb-8 { margin-bottom: 0; } +.mb-16 { margin-bottom: 16px; } +.mb-24 { margin-bottom: 24px; } + +/* Relation cards */ +.relation-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.relation-card { + + padding: 10px 8px; + border: 1px solid var(--border); + border-radius: 6px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.relation-card:hover { + border-color: var(--accent); +} + +.relation-card input { + display: none; +} + +.relation-card input:checked + span { + font-weight: 600; +} + +.relation-card:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); +} + +.radio-pill { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 20px; + cursor: pointer; + margin-right: 4px; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.radio-pill:hover { + border-color: var(--accent); +} + +.radio-pill input { + display: none; +} + +.radio-pill:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); + font-weight: 500; +} + +/* Data cards on profile page */ +.data-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + cursor: pointer; +} + +.data-section-info h3 { + margin-bottom: 2px; +} + +.data-section-meta { + font-size: 1rem; + color: var(--text-muted); +} + +.data-section-arrow { + color: var(--accent); + font-size: 1.375rem; + font-weight: 500; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; +} + + +/* Category inline selector */ +.category-inline { + font-size: 0.8rem; + padding: 0.15rem 0.4rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text-muted); + cursor: pointer; +} +.category-inline:hover { + border-color: var(--accent); +} +.category-inline:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + +/* Status badge */ +.status-badge { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: var(--accent-light); + color: var(--accent); + border-radius: 4px; + margin-right: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +/* Form select for category */ +.form-select { + + width: 100%; + padding: 0.6rem 0.8rem; + font-size: 0.95rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + cursor: pointer; +} +.form-select:hover { + border-color: var(--border-hover); +} +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + + +/* Data Cards - Andrew McCalip inspired */ +.data-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 16px; + overflow: hidden; +} + +.data-card-header { + display: flex; + align-items: center; + padding: 16px; + gap: 12px; +} + +.data-card-indicator { + width: 4px; + height: 32px; + border-radius: 2px; + flex-shrink: 0; +} + +.data-card-indicator.imaging { background: var(--accent); } +.data-card-indicator.labs { background: #059669; } +.data-card-indicator.uploads { background: #6366f1; } +.data-card-indicator.vitals { background: #ec4899; } +.data-card-indicator.medications { background: #8b5cf6; } +.data-card-indicator.records { background: #06b6d4; } +.data-card-indicator.journal { background: #f59e0b; } +.data-card-indicator.privacy { background: #64748b; } + +.data-card-title { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.data-card-summary { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Data Table */ +.data-table { + border-top: 1px solid var(--border); +} + +.data-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px dashed var(--border); + gap: 16px; +} + +.data-row:last-child { + border-bottom: none; +} + +.data-row.expandable { + cursor: pointer; +} + +.data-row.expandable:hover { + background: var(--bg); +} + +.data-row-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.expand-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono, monospace); + font-size: 14px; + color: var(--text-muted); + flex-shrink: 0; +} + +.data-row.expanded .expand-icon { + transform: rotate(45deg); +} + +.data-row.single .data-row-main { + padding-left: 32px; +} + +.data-label { + font-weight: 500; + color: var(--text); +} + +.data-meta { + font-size: 0.85rem; + color: var(--text-muted); +} + +.data-values { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.data-value { + font-size: 0.85rem; + color: var(--text); + white-space: nowrap; +} + +.data-value.mono { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; +} + +.data-date { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; + color: var(--text-muted); + min-width: 80px; + text-align: right; +} + +/* Expandable children */ +.data-row-children { + display: none; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.data-row-children.show { + display: block; + +} + +.data-row.child { + padding-left: 48px; + border-bottom: 1px dashed var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.data-row.child:last-child { + border-bottom: none; +} + +.data-row.child .data-label { + font-weight: 400; + font-size: 0.9rem; + flex: 1; +} + +/* Section heading - smaller, uppercase */ +.section-heading { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text); +} + +/* Utility */ +.p-16 { padding: 16px; } + +/* Privacy actions row */ +.privacy-actions { + display: flex; + gap: 24px; + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.privacy-action { + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; +} + +.privacy-action:hover { + text-decoration: underline; +} + +/* Share form */ +.share-form { + padding: 24px; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.form-row:last-child { + margin-bottom: 0; + margin-top: 24px; +} + +.form-row > label { + flex: 0 0 180px; + + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + margin-bottom: 0; +} + +.form-input, +.form-select { + width: 100%; + padding: 12px 16px; + font-size: 1rem; + font-family: inherit; + color: var(--text); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.1); +} + +.form-input::placeholder { + color: var(--text-subtle); +} + +/* Custom select wrapper */ +.select-wrapper { + position: relative; +} + +.select-wrapper::after { + content: ''; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-muted); + pointer-events: none; +} + +.form-select { + appearance: none; + -webkit-appearance: none; + cursor: pointer; + padding-right: 40px; +} + +/* Checkbox styling */ +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + font-weight: 400 !important; +} + +.checkbox-label input[type="checkbox"] { + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-label span { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.4; + white-space: nowrap; +} + +/* Form row flex children */ +.form-row .form-input, +.form-row .select-wrapper { + flex: 1; +} + +.form-row .select-wrapper .form-select { + width: 100%; +} + +/* Install page */ +.install-content { + padding: 16px 24px 24px; +} + +.install-content p { + margin: 0 0 16px; + color: var(--text-muted); +} + +.download-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + font-size: 0.85rem; + overflow-x: auto; + white-space: pre; +} + +.form-spacer { + flex: 0 0 180px; +} + +/* Nav links */ +.nav-link { + font-size: 0.85rem; + color: var(--text-muted); + text-decoration: none; +} + +.nav-link:hover { + color: var(--text); + text-decoration: none; +} + + +/* Privacy page */ +.privacy-container { + max-width: 720px; + margin: 0 auto; + padding: 60px 40px; +} + +.privacy-container h1 { + font-family: Sora, sans-serif; + font-size: 2.5rem; + font-weight: 700; + color: #2C1810; + margin-bottom: 16px; +} + +.privacy-container .intro { + font-family: Sora, sans-serif; + font-size: 1.15rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 48px; +} + +.privacy-container h2 { + font-family: Sora, sans-serif; + font-size: 1.4rem; + font-weight: 600; + color: #2C1810; + margin-top: 48px; + margin-bottom: 24px; +} + +.privacy-container h3 { + font-family: Sora, sans-serif; + font-size: 1.1rem; + font-weight: 600; + color: #2C1810; + margin-top: 24px; + margin-bottom: 8px; +} + +.privacy-container p { + font-family: Sora, sans-serif; + font-size: 1rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 16px; +} + +.privacy-container strong { + font-weight: 600; + color: #2C1810; +} + +.privacy-container a { + color: #B45309; +} + +.privacy-container .legal-section { + margin-top: 48px; + padding-top: 32px; + border-top: 1px solid #E5DDD3; +} + +.inou-brand { + font-weight: 700; + color: #B45309; +} +/* Base styles */ +.sg-container { max-width: 1200px; margin: 0 auto; padding: 48px 24px 80px; } +.sg-section-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 0; } +.sg-card-content { padding: 32px; } +.sg-card-content-sm { padding: 24px; max-width: 480px; } +.sg-profile-card { padding: 20px; min-height: 140px; display: flex; flex-direction: column; } +.sg-profile-card h3 { font-size: 1.25rem; margin-bottom: 4px; } +.sg-profile-card .card-meta { margin-bottom: 8px; } +.sg-profile-dob { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 12px; } +.sg-profile-stats { display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 12px; } +.sg-profile-stat { display: flex; align-items: center; gap: 4px; } +.sg-row-link { color: var(--accent); font-size: 1.1rem; text-decoration: none; padding: 4px 8px; border-radius: 4px; } +.sg-row-link:hover { background: var(--accent-light); } +.sg-supp-dose { font-size: 0.85rem; color: var(--text-muted); } +.sg-supp-timing { font-size: 0.8rem; color: var(--text-subtle); } +.sg-supp-amount { font-size: 0.8rem; color: var(--text-muted); margin-left: 8px; } +.sg-footer { margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; } +.sg-footer-left { font-size: 0.9rem; color: var(--text-muted); display: flex; gap: 16px; align-items: center; } +.sg-footer-left a { color: var(--text-muted); text-decoration: none; } +.sg-footer-left a:hover { color: var(--accent); } +.sg-footer-right { font-family: "Sora", sans-serif; font-size: 1rem; } +.sg-footer-right .inou { font-weight: 700; color: var(--accent); } +.sg-footer-right .health { font-weight: 400; color: var(--text-muted); } +.sg-select { width: 100%; padding: 10px 12px; font-size: 1rem; font-family: inherit; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); color: var(--text); appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2378716C' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; cursor: pointer; } +.sg-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); } +.badge, .badge-care, .badge-soon, .status-badge { font-family: "Sora", sans-serif; } +.sg-gene-row { display: flex; flex-direction: column; gap: 4px; } +.sg-gene-main { display: flex; align-items: center; gap: 8px; } +.sg-gene-name { font-weight: 600; } +.sg-gene-rsid { font-size: 0.8rem; color: var(--text-muted); font-family: "SF Mono", Monaco, monospace; } +.sg-gene-allele { font-family: "SF Mono", Monaco, monospace; font-size: 0.95rem; font-weight: 600; background: var(--bg); padding: 2px 8px; border-radius: 4px; } +.sg-gene-summary { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; margin-top: 4px; } +.sg-gene-actions { display: flex; gap: 8px; margin-top: 8px; } +.sg-ask-ai { font-size: 0.75rem; padding: 4px 10px; background: var(--accent-light); color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; font-family: "Sora", sans-serif; font-weight: 500; } +.sg-ask-ai:hover { background: var(--accent); color: white; } +.sg-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center; padding: 16px; } +.sg-modal-overlay.show { display: flex; } +.sg-modal { background: var(--bg-card); border-radius: 12px; padding: 24px; max-width: 560px; width: 100%; box-shadow: 0 20px 40px rgba(0,0,0,0.2); } +.sg-modal h3 { margin-bottom: 16px; } +.sg-modal-prompt { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; font-family: "SF Mono", Monaco, monospace; font-size: 0.85rem; line-height: 1.6; margin-bottom: 16px; white-space: pre-wrap; } +.sg-modal-actions { display: flex; gap: 12px; justify-content: flex-end; } +.sg-settings-row { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid var(--border); gap: 24px; } +.sg-settings-row:last-child { border-bottom: none; } +.sg-settings-label { font-weight: 500; } +.sg-settings-desc { font-size: 0.85rem; color: var(--text-muted); margin-top: 2px; } +.sg-settings-control { min-width: 200px; } +.sg-llm-option { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; margin-bottom: 8px; } +.sg-llm-option:hover { border-color: var(--accent); } +.sg-llm-option.selected { border-color: var(--accent); background: var(--accent-light); } +.sg-llm-option input { display: none; } +.sg-llm-icon { width: 24px; height: 24px; border-radius: 4px; background: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 0.8rem; } +.sg-show-more { padding: 12px 16px; text-align: center; color: var(--accent); font-size: 0.85rem; cursor: pointer; border-top: 1px solid var(--border); } +.sg-show-more:hover { background: var(--accent-light); } +.sg-vital-history { padding: 12px 16px 12px 48px; background: var(--bg); border-top: 1px solid var(--border); } +.sg-vital-entry { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed var(--border); } +.sg-vital-entry:last-child { border-bottom: none; } +.sg-vital-date { font-size: 0.8rem; color: var(--text-muted); } +.sg-vital-val { font-family: "SF Mono", Monaco, monospace; font-size: 0.85rem; } +.sg-vital-graph { height: 60px; display: flex; align-items: flex-end; gap: 4px; padding: 8px 0; } +.sg-vital-bar { width: 24px; background: var(--accent); border-radius: 3px 3px 0 0; opacity: 0.7; } +.sg-vital-bar:last-child { opacity: 1; } +.sg-note-detail { padding: 16px; background: var(--bg); border-top: 1px solid var(--border); } +.sg-note-photos { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.sg-note-photo-item { text-align: center; } +.sg-note-photo-img { width: 80px; height: 80px; border-radius: 8px; object-fit: cover; border: 1px solid var(--border); background: #E5E2DE; display: flex; align-items: center; justify-content: center; font-size: 2rem; } +.sg-note-photo-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 4px; } +.sg-note-timeline { margin-top: 12px; } +.sg-note-timeline-entry { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px dashed var(--border); } +.sg-note-timeline-entry:last-child { border-bottom: none; } +.sg-note-timeline-date { font-size: 0.8rem; color: var(--text-muted); min-width: 80px; } +.sg-note-timeline-text { font-size: 0.9rem; color: var(--text); } +.sg-note-category { font-size: 0.75rem; color: var(--text-subtle); background: var(--bg); padding: 2px 6px; border-radius: 3px; margin-left: 8px; } +.sg-note-icon { width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 1rem; flex-shrink: 0; } +.sg-note-icon.temp { background: #FEE2E2; color: #DC2626; } +.sg-note-icon.weight { background: #DBEAFE; color: #2563EB; } +.sg-note-icon.bp { background: #FCE7F3; color: #DB2777; } +.sg-note-icon.note { background: var(--accent-light); color: var(--accent); } +.sg-note-icon.photo { background: #E0E7FF; color: #4F46E5; } +.sg-peptide-dates { font-size: 0.8rem; color: var(--text-muted); } +.sg-peptide-history { font-size: 0.75rem; color: var(--text-subtle); margin-top: 2px; } + +/* ======================================== + MOBILE RESPONSIVE STYLES + ======================================== */ + +/* Tablet and below */ +@media (max-width: 768px) { + .sg-container { padding: 24px 16px 60px; } + .sg-card-content { padding: 20px; } + .sg-card-content-sm { padding: 16px; max-width: 100%; } + + /* Settings row stacks */ + .sg-settings-row { flex-direction: column; align-items: flex-start; gap: 12px; } + .sg-settings-control { min-width: 100%; width: 100%; } + + /* Profile grid single column */ + .profiles-grid { grid-template-columns: 1fr !important; } + + /* Data card indicators grid */ + .sg-indicators-grid { grid-template-columns: repeat(2, 1fr) !important; } + + /* Gene row layout */ + .sg-gene-row > div:first-child { flex-direction: column; align-items: flex-start !important; gap: 8px; } + + /* Vital history less padding */ + .sg-vital-history { padding-left: 16px; } +} + +/* Phone portrait */ +@media (max-width: 480px) { + .sg-container { padding: 16px 12px 48px; } + .sg-container > h1 { font-size: 2rem; } + .sg-container > .intro { font-size: 1rem; } + + /* Data rows stack */ + .data-row:not(.child) { flex-direction: column; align-items: flex-start !important; gap: 8px; padding: 12px; } + .data-row .data-values { width: 100%; justify-content: flex-start; gap: 12px; } + .data-row .data-row-main { width: 100%; } + + /* Child rows stay horizontal but tighter */ + .data-row.child { padding: 10px 12px 10px 24px; } + .data-row.child .data-values { gap: 8px; } + + /* Buttons wrap */ + .sg-card-content > div[style*="flex-wrap"] { gap: 8px; } + + /* Modal full width */ + .sg-modal { padding: 16px; border-radius: 8px; } + .sg-modal-prompt { font-size: 0.8rem; padding: 12px; } + .sg-modal-actions { flex-direction: column; } + .sg-modal-actions .btn { width: 100%; } + + /* Footer stacks */ + .sg-footer { flex-direction: column; gap: 12px; align-items: center; text-align: center; } + .sg-footer-left { flex-direction: column; gap: 8px; } + + /* Typography scale values hide on mobile */ + .data-card:nth-child(2) .data-values { display: none; } + + /* Profile cards tighter */ + .sg-profile-card { padding: 16px; min-height: auto; } + .sg-profile-stats { flex-wrap: wrap; gap: 8px; } + + /* LLM options full width */ + .sg-llm-option { padding: 12px; } + + /* Photos smaller */ + .sg-note-photo-img { width: 64px; height: 64px; font-size: 1.5rem; } + + /* Timeline entry stacks */ + .sg-note-timeline-entry { flex-direction: column; gap: 4px; } + .sg-note-timeline-date { min-width: auto; } + + /* Gene allele and badge wrap */ + .sg-gene-row > div:first-child > div:last-child { flex-wrap: wrap; } +} + +/* Extra small phones */ +@media (max-width: 360px) { + .sg-container { padding: 12px 8px 40px; } + .data-row { padding: 10px 8px; } + .sg-note-photos { gap: 8px; } + .sg-note-photo-img { width: 56px; height: 56px; } +} + +/* Sticky footer - push to bottom when content is short */ +.sg-container { + min-height: calc(100vh - 48px); + padding-bottom: 24px !important; + display: flex; + flex-direction: column; +} + +.sg-container > .sg-footer { + margin-top: auto; +} + +.sg-container.sticky-footer { + padding-bottom: 24px; +} + +.sg-footer { + padding-top: 24px; +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} +.modal-content { + background: #fff; + border-radius: 12px; + padding: 24px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + max-height: 90vh; + overflow-y: auto; +} +.modal-content h3 { font-weight: 600; } +.modal-content ul { list-style: disc; } +.modal-content li { margin-bottom: 6px; } + +/* Genetics hidden category indicator */ +.genetics-hidden .data-label::after { + content: '⚠'; + margin-left: 8px; + font-size: 0.8rem; +} + +/* Install page */ +.install-container { + max-width: 1200px; + margin: 0 auto; + padding: 48px 24px 80px; +} +.install-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; +} +.install-header h1 { + font-size: 2.5rem; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} +.install-header p { + font-size: 1.15rem; + font-weight: 300; + color: var(--text-muted); +} +.install-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 24px; +} +.login-prompt { + background: var(--accent-light); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 16px 24px; + margin-bottom: 24px; + font-size: 1rem; + font-weight: 300; + color: var(--text); +} +.login-prompt a { + color: var(--accent); + font-weight: 500; +} + +/* Tabs */ +.ai-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + padding: 0 24px; +} +.ai-tab { + padding: 16px 24px; + cursor: pointer; + border: none; + background: none; + font-family: inherit; + font-size: 1rem; + color: var(--text-muted); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.2s; +} +.ai-tab:hover { color: var(--text); } +.ai-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 500; +} +.ai-content { + display: none; + padding: 32px; +} +.ai-content.active { display: block; } +.ai-content > p:first-child { + font-size: 1rem; + font-weight: 300; + color: var(--text-muted); + margin-bottom: 24px; +} + +/* Steps */ +.step { + margin-bottom: 24px; + padding: 24px; + background: var(--bg); + border-radius: 8px; +} +.step:last-child { margin-bottom: 0; } +.step-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.step-num { + width: 32px; + height: 32px; + background: var(--accent); + color: white; + border-radius: 50%; + text-align: center; + line-height: 32px; + font-weight: 600; + font-size: 0.9rem; + flex-shrink: 0; +} +.step-num.muted { background: var(--text-muted); } +.step-num.warning { background: #F59E0B; } +.step h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + margin: 0; +} +.step p { + font-size: 1rem; + font-weight: 300; + color: var(--text-muted); + line-height: 1.8; + margin: 0; +} +.step p + p { margin-top: 12px; } +.step a { color: var(--accent); } +.step ul { + margin: 12px 0 0 0; + padding-left: 20px; + color: var(--text-muted); + font-weight: 300; + line-height: 1.8; +} + +/* Code wrapper with copy button */ +.code-wrapper { + position: relative; + margin-top: 16px; +} +.code-wrapper pre { + background: #1C1917; + color: #F5F5F4; + padding: 16px; + padding-right: 48px; + border-radius: 6px; + font-family: "SF Mono", Monaco, monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + line-height: 1.6; +} +.copy-icon { + position: absolute; + top: 8px; + right: 8px; + background: transparent; + border: none; + cursor: pointer; + padding: 6px; + border-radius: 4px; + opacity: 0.6; + transition: opacity 0.2s, background 0.2s; +} +.copy-icon:hover { opacity: 1; background: rgba(255,255,255,0.1); } +.copy-icon svg { width: 18px; height: 18px; stroke: #A8A29E; fill: none; } +.copy-icon.copied svg { stroke: var(--success); } + +/* Quick start box */ +.quick-start { + background: var(--bg); + border-radius: 8px; + padding: 24px; + margin-bottom: 24px; +} +.quick-start h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + margin: 0 0 16px 0; +} +.quick-start p { + font-size: 1rem; + font-weight: 300; + color: var(--text-muted); + margin: 0; +} + +/* Step note (smaller text) */ +.step-note { + margin-top: 12px; + font-size: 0.9rem; +} + +/* Install page mobile */ +@media (max-width: 768px) { + .install-container { padding: 24px 16px 48px; } + .install-header { flex-direction: column; gap: 16px; } + .install-header h1 { font-size: 2rem; } + .ai-tabs { padding: 0 16px; overflow-x: auto; } + .ai-tab { padding: 12px 16px; font-size: 0.9rem; white-space: nowrap; } + .ai-content { padding: 24px 16px; } + .step { padding: 20px 16px; } +} +@media (max-width: 480px) { + .install-container { padding: 16px 12px 32px; } + .install-header h1 { font-size: 1.75rem; } + .install-header p { font-size: 1rem; } + .ai-tabs { padding: 0 12px; } + .ai-tab { padding: 10px 12px; font-size: 0.85rem; } + .ai-content { padding: 20px 12px; } + .step { padding: 16px 12px; } + .code-wrapper pre { font-size: 0.8rem; padding: 12px; padding-right: 40px; } +} diff --git a/static/style.css.backup b/static/style.css.backup new file mode 100644 index 0000000..c22f81d --- /dev/null +++ b/static/style.css.backup @@ -0,0 +1,1391 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #F8F7F6; + --bg-card: #FFFFFF; + --border: #E5E2DE; + --border-hover: #C4BFB8; + --text: #1C1917; + --text-muted: #78716C; + --text-subtle: #A8A29E; + --accent: #B45309; + --accent-hover: #92400E; + --accent-light: #FEF3C7; + --danger: #DC2626; + --danger-light: #FEF2F2; + --success: #059669; + --success-light: #ECFDF5; +} + +body { + font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + font-weight: 400; + line-height: 1.5; + font-size: 15px; +} + +/* Navigation */ +.nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 24px; + max-width: 1200px; + margin: 0 auto; + border-bottom: 1px solid var(--border); +} + +.logo { + font-family: "Sora", sans-serif; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0; + text-transform: lowercase; + color: #B45309; + text-decoration: none; +} + +.nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +/* User menu with hover dropdown */ +.nav-user-menu { + position: relative; +} + +.nav-user-name { + font-size: 0.85rem; + color: var(--text); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.nav-user-name:hover { + background: var(--border); +} + +.nav-user-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 100px; + z-index: 100; +} + +.nav-user-menu:hover .nav-user-dropdown { + display: block; + +} + +.nav-user-dropdown a { + display: block; + + padding: 8px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.nav-user-dropdown a:hover { + background: var(--bg); + color: var(--accent); +} + +/* Language menu */ +.lang-menu { + position: relative; +} + +.lang-current { + font-size: 0.8rem; + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; +} + +.lang-current:hover { + border-color: var(--border-hover); +} + +.lang-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + padding: 4px; + min-width: 120px; + z-index: 100; + margin-top: 4px; +} + +.lang-menu:hover .lang-dropdown { + display: block; + +} + +.lang-dropdown a { + display: block; + + padding: 6px 12px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + border-radius: 4px; +} + +.lang-dropdown a:hover { + background: var(--bg); +} + +.lang-dropdown a.active { + color: var(--accent); + font-weight: 500; +} + +.nav-user { + font-size: 0.8rem; + color: var(--text-muted); +} + +.nav-user a { + color: var(--text); + text-decoration: none; +} + +.nav-user a:hover { + color: var(--accent); +} + +.lang-picker { + font-size: 1rem; + color: var(--text-muted); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; +} + +/* Container */ +.container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px; +} + +.container-narrow { + max-width: 360px; + margin: 0 auto; + padding: 60px 20px 40px; +} + +/* Dossier header */ +.dossier-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 24px; +} + +.dossier-header-left h1 { + margin-bottom: 0; +} + +.dossier-header-left p { + margin: 4px 0 0 0; +} + +/* Coming soon badge */ +.badge-soon { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + background: var(--bg); + border: 1px solid var(--border); + padding: 4px 8px; + border-radius: 4px; +} + +.data-card.coming-soon { + opacity: 0.6; +} + +/* Typography */ +h1 { + font-size: 2.25rem; + font-weight: 300; + line-height: 1.2; + margin-bottom: 16px; + letter-spacing: -0.03em; + color: var(--text); +} + +h1.small { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 4px; +} + +h2 { + font-size: 1.5rem; + font-weight: 300; + margin-bottom: 12px; + letter-spacing: -0.02em; +} + +h3 { + font-size: 1.125rem; + font-weight: 500; + margin-bottom: 4px; + color: var(--text); +} + +.intro { + font-size: 1rem; + color: var(--text-muted); + margin-bottom: 32px; +} + +.intro.small { + font-size: 1rem; + margin-bottom: 24px; +} + +.section-label { + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-subtle); + margin-bottom: 12px; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 10px 12px; + font-size: 1rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.form-group input.code-input { + font-size: 1.375rem; + text-align: center; + letter-spacing: 0.4em; + font-weight: 500; + font-family: "SF Mono", "Monaco", monospace; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.radio-group { + display: flex; + gap: 16px; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 1rem; + color: var(--text); + cursor: pointer; + font-weight: 400; +} + +.radio-group input { + width: auto; + accent-color: var(--accent); +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 8px; + font-size: 1rem; + color: var(--text-muted); + cursor: pointer; +} + +.checkbox-group input { + width: auto; + accent-color: var(--accent); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 18px; + font-size: 1rem; + font-weight: 500; + font-family: inherit; + text-decoration: none; + border-radius: 6px; + transition: all 0.15s; + border: none; + cursor: pointer; + text-align: center; + gap: 6px; +} + +.btn-full { + width: 100%; +} + +.btn-primary { + background: var(--accent); + color: #FFFFFF; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + border-color: var(--border-hover); + background: var(--bg); +} + +.btn-danger { + background: var(--danger-light); + color: var(--danger); + border: 1px solid #FECACA; +} + +.btn-danger:hover { + background: #FEE2E2; +} + +.btn-small { + padding: 6px 12px; + font-size: 1rem; +} + +.btn-icon { + padding: 4px 8px; + background: transparent; + color: var(--text-subtle); + border: none; + font-size: 1rem; + line-height: 1; + border-radius: 4px; +} + +.btn-icon:hover { + color: var(--danger); + background: var(--danger-light); +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +.card-link { + + text-decoration: none; + color: inherit; + padding: 0; + transition: all 0.15s; +} + +.card-link:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + text-decoration: none; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-header h3 { + margin-bottom: 0; +} + +.card-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.card-actions { + display: flex; + gap: 6px; +} + +.card-add { + border: 2px dashed var(--border); + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + cursor: pointer; + text-decoration: none; +} + +.card-add:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.card-add .plus { + font-size: 1.75rem; + color: var(--accent); + margin-bottom: 6px; +} + +.card-add span { + color: var(--text-muted); + font-size: 1rem; +} + +/* Profiles grid */ +.profiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +/* Profile card hover */ +.profile-card { + cursor: pointer; + transition: all 0.15s; +} + +.profile-card:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* Profile badge */ +.badge { + display: inline-block; + padding: 2px 8px; + font-size: 1rem; + font-weight: 500; + border-radius: 4px; + background: var(--accent-light); + color: var(--accent); +} + +.badge-care { + background: var(--success-light); + color: var(--success); +} + +/* Access list */ +.access-list { + margin-top: 8px; +} + +.access-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.access-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.access-info { + display: flex; + flex-direction: column; + gap: 1px; +} + +.access-name { + font-weight: 500; + font-size: 1rem; +} + +.access-relation { + font-size: 1rem; + color: var(--text-subtle); +} + +/* Messages */ +.error { + background: var(--danger-light); + border: 1px solid #FECACA; + color: var(--danger); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.info { + background: var(--accent-light); + border: 1px solid #FDE68A; + color: var(--accent); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +.success { + background: var(--success-light); + border: 1px solid #A7F3D0; + color: var(--success); + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 1rem; +} + +/* Trust section */ +.trust { + border-top: 1px solid var(--border); + padding-top: 32px; + margin-top: 32px; +} + +.trust-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.trust-item { + font-size: 1rem; + color: var(--text-muted); +} + +.trust-item strong { + + font-weight: 500; + color: var(--text); + margin-bottom: 2px; +} + +/* Footer */ +.footer { + margin-top: 40px; + padding-top: 12px; + border-top: 1px solid var(--border); + font-size: 1rem; + color: var(--text-subtle); +} + +/* Upload area */ +.upload-area { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + background: var(--bg-card); +} + +.upload-area:hover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-area.dragover { + border-color: var(--accent); + background: var(--accent-light); +} + +.upload-icon { + color: var(--accent); + margin-bottom: 12px; +} + +.upload-text { + font-size: 1rem; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.upload-hint { + font-size: 1rem; + color: var(--text-muted); +} + +/* Progress */ +.progress-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; + z-index: 1000; +} + +.progress-modal { + background: var(--bg-card); + padding: 32px; + border-radius: 12px; + text-align: center; + min-width: 280px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15); +} + +.progress-bar-wrap { + background: var(--border); + border-radius: 4px; + height: 6px; + overflow: hidden; + margin-top: 16px; +} + +.progress-bar { + background: var(--accent); + height: 100%; + width: 0%; + transition: width 0.2s; +} + +.progress-detail { + margin-top: 12px; + font-size: 1rem; + color: var(--text-muted); + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: auto; + margin-right: auto; +} + +/* File table */ +.file-table { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.file-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 1rem; +} + +.file-row:last-child { + border-bottom: none; +} + +.file-row.file-deleted { + background: var(--bg); + color: var(--text-subtle); +} + +.file-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.file-name { + color: var(--text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-deleted .file-name { + color: var(--text-subtle); + text-decoration: line-through; +} + +.file-meta { + font-size: 1rem; + color: var(--text-subtle); +} + +.file-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 1rem; + flex-shrink: 0; +} + +.status-expires { + color: var(--text-muted); +} + +.status-deleted { + color: var(--text-subtle); + font-style: italic; +} + +/* Link */ +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Helpers */ +.text-center { text-align: center; } +.text-muted { color: var(--text-muted); } +.text-small { font-size: 1rem; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mt-24 { margin-top: 24px; } +.mb-8 { margin-bottom: 0; } +.mb-16 { margin-bottom: 16px; } +.mb-24 { margin-bottom: 24px; } + +/* Relation cards */ +.relation-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.relation-card { + + padding: 10px 8px; + border: 1px solid var(--border); + border-radius: 6px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.relation-card:hover { + border-color: var(--accent); +} + +.relation-card input { + display: none; +} + +.relation-card input:checked + span { + font-weight: 600; +} + +.relation-card:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); +} + +.radio-pill { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 20px; + cursor: pointer; + margin-right: 4px; + transition: all 0.15s; + font-size: 1rem; + background: var(--bg-card); +} + +.radio-pill:hover { + border-color: var(--accent); +} + +.radio-pill input { + display: none; +} + +.radio-pill:has(input:checked) { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); + font-weight: 500; +} + +/* Data cards on profile page */ +.data-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + cursor: pointer; +} + +.data-section-info h3 { + margin-bottom: 2px; +} + +.data-section-meta { + font-size: 1rem; + color: var(--text-muted); +} + +.data-section-arrow { + color: var(--accent); + font-size: 1.375rem; + font-weight: 500; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; +} + + +/* Category inline selector */ +.category-inline { + font-size: 0.8rem; + padding: 0.15rem 0.4rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text-muted); + cursor: pointer; +} +.category-inline:hover { + border-color: var(--accent); +} +.category-inline:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + +/* Status badge */ +.status-badge { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: var(--accent-light); + color: var(--accent); + border-radius: 4px; + margin-right: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +/* Form select for category */ +.form-select { + + width: 100%; + padding: 0.6rem 0.8rem; + font-size: 0.95rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text); + cursor: pointer; +} +.form-select:hover { + border-color: var(--border-hover); +} +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-light); +} + + +/* Data Cards - Andrew McCalip inspired */ +.data-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 16px; + overflow: hidden; +} + +.data-card-header { + display: flex; + align-items: center; + padding: 16px; + gap: 12px; +} + +.data-card-indicator { + width: 4px; + height: 32px; + border-radius: 2px; + flex-shrink: 0; +} + +.data-card-indicator.imaging { background: var(--accent); } +.data-card-indicator.labs { background: #059669; } +.data-card-indicator.uploads { background: #6366f1; } +.data-card-indicator.vitals { background: #ec4899; } +.data-card-indicator.medications { background: #8b5cf6; } +.data-card-indicator.records { background: #06b6d4; } +.data-card-indicator.journal { background: #f59e0b; } +.data-card-indicator.privacy { background: #64748b; } + +.data-card-title { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.data-card-summary { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Data Table */ +.data-table { + border-top: 1px solid var(--border); +} + +.data-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px dashed var(--border); + gap: 16px; +} + +.data-row:last-child { + border-bottom: none; +} + +.data-row.expandable { + cursor: pointer; +} + +.data-row.expandable:hover { + background: var(--bg); +} + +.data-row-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.expand-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono, monospace); + font-size: 14px; + color: var(--text-muted); + flex-shrink: 0; +} + +.data-row.expanded .expand-icon { + transform: rotate(45deg); +} + +.data-row.single .data-row-main { + padding-left: 32px; +} + +.data-label { + font-weight: 500; + color: var(--text); +} + +.data-meta { + font-size: 0.85rem; + color: var(--text-muted); +} + +.data-values { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} + +.data-value { + font-size: 0.85rem; + color: var(--text); + white-space: nowrap; +} + +.data-value.mono { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; +} + +.data-date { + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.8rem; + color: var(--text-muted); + min-width: 80px; + text-align: right; +} + +/* Expandable children */ +.data-row-children { + display: none; + background: var(--bg); + border-top: 1px solid var(--border); +} + +.data-row-children.show { + display: block; + +} + +.data-row.child { + padding-left: 48px; + border-bottom: 1px dashed var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.data-row.child:last-child { + border-bottom: none; +} + +.data-row.child .data-label { + font-weight: 400; + font-size: 0.9rem; + flex: 1; +} + +/* Section heading - smaller, uppercase */ +.section-heading { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text); +} + +/* Utility */ +.p-16 { padding: 16px; } + +/* Privacy actions row */ +.privacy-actions { + display: flex; + gap: 24px; + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.privacy-action { + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; +} + +.privacy-action:hover { + text-decoration: underline; +} + +/* Share form */ +.share-form { + padding: 24px; +} + +.form-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.form-row:last-child { + margin-bottom: 0; + margin-top: 24px; +} + +.form-row > label { + flex: 0 0 180px; + + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + margin-bottom: 0; +} + +.form-input, +.form-select { + width: 100%; + padding: 12px 16px; + font-size: 1rem; + font-family: inherit; + color: var(--text); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.1); +} + +.form-input::placeholder { + color: var(--text-subtle); +} + +/* Custom select wrapper */ +.select-wrapper { + position: relative; +} + +.select-wrapper::after { + content: ''; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-muted); + pointer-events: none; +} + +.form-select { + appearance: none; + -webkit-appearance: none; + cursor: pointer; + padding-right: 40px; +} + +/* Checkbox styling */ +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + font-weight: 400 !important; +} + +.checkbox-label input[type="checkbox"] { + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-label span { + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.4; + white-space: nowrap; +} + +/* Form row flex children */ +.form-row .form-input, +.form-row .select-wrapper { + flex: 1; +} + +.form-row .select-wrapper .form-select { + width: 100%; +} + +/* Install page */ +.install-content { + padding: 16px 24px 24px; +} + +.install-content p { + margin: 0 0 16px; + color: var(--text-muted); +} + +.download-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + font-family: 'SF Mono', Monaco, 'Courier New', monospace; + font-size: 0.85rem; + overflow-x: auto; + white-space: pre; +} + +.form-spacer { + flex: 0 0 180px; +} + +/* Nav links */ +.nav-link { + font-size: 0.85rem; + color: var(--text-muted); + text-decoration: none; +} + +.nav-link:hover { + color: var(--text); + text-decoration: none; +} + + +/* Privacy page */ +.privacy-container { + max-width: 720px; + margin: 0 auto; + padding: 60px 40px; +} + +.privacy-container h1 { + font-family: Sora, sans-serif; + font-size: 2.5rem; + font-weight: 700; + color: #2C1810; + margin-bottom: 16px; +} + +.privacy-container .intro { + font-family: Sora, sans-serif; + font-size: 1.15rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 48px; +} + +.privacy-container h2 { + font-family: Sora, sans-serif; + font-size: 1.4rem; + font-weight: 600; + color: #2C1810; + margin-top: 48px; + margin-bottom: 24px; +} + +.privacy-container h3 { + font-family: Sora, sans-serif; + font-size: 1.1rem; + font-weight: 600; + color: #2C1810; + margin-top: 24px; + margin-bottom: 8px; +} + +.privacy-container p { + font-family: Sora, sans-serif; + font-size: 1rem; + font-weight: 300; + color: #4A3728; + line-height: 1.8; + margin-bottom: 16px; +} + +.privacy-container strong { + font-weight: 600; + color: #2C1810; +} + +.privacy-container a { + color: #B45309; +} + +.privacy-container .legal-section { + margin-top: 48px; + padding-top: 32px; + border-top: 1px solid #E5DDD3; +} + +.inou-brand { + font-weight: 700; + color: #B45309; +} diff --git a/static/swagger.html b/static/swagger.html new file mode 100644 index 0000000..ad4d984 --- /dev/null +++ b/static/swagger.html @@ -0,0 +1,26 @@ + + + + + inou API + + + + +
+ + + + diff --git a/static/test_thumb.png b/static/test_thumb.png new file mode 100644 index 0000000..c16c999 Binary files /dev/null and b/static/test_thumb.png differ diff --git a/static/thumb_150px_q30.jpg b/static/thumb_150px_q30.jpg new file mode 100644 index 0000000..f7ac9e1 Binary files /dev/null and b/static/thumb_150px_q30.jpg differ diff --git a/static/thumb_150px_q50.jpg b/static/thumb_150px_q50.jpg new file mode 100644 index 0000000..4fcca36 Binary files /dev/null and b/static/thumb_150px_q50.jpg differ diff --git a/static/thumb_200px_q10.jpg b/static/thumb_200px_q10.jpg new file mode 100644 index 0000000..eb78aa0 Binary files /dev/null and b/static/thumb_200px_q10.jpg differ diff --git a/static/thumb_200px_q15.jpg b/static/thumb_200px_q15.jpg new file mode 100644 index 0000000..ecb5d31 Binary files /dev/null and b/static/thumb_200px_q15.jpg differ diff --git a/static/thumb_200px_q20.jpg b/static/thumb_200px_q20.jpg new file mode 100644 index 0000000..3c3be25 Binary files /dev/null and b/static/thumb_200px_q20.jpg differ diff --git a/static/thumb_256px_q10.jpg b/static/thumb_256px_q10.jpg new file mode 100644 index 0000000..3086ae7 Binary files /dev/null and b/static/thumb_256px_q10.jpg differ diff --git a/static/thumb_256px_q15.jpg b/static/thumb_256px_q15.jpg new file mode 100644 index 0000000..d843954 Binary files /dev/null and b/static/thumb_256px_q15.jpg differ diff --git a/static/thumb_300px_q10.jpg b/static/thumb_300px_q10.jpg new file mode 100644 index 0000000..323e952 Binary files /dev/null and b/static/thumb_300px_q10.jpg differ diff --git a/static/thumb_gif.gif b/static/thumb_gif.gif new file mode 100644 index 0000000..13630c1 Binary files /dev/null and b/static/thumb_gif.gif differ diff --git a/static/thumb_jpg_q10.jpg b/static/thumb_jpg_q10.jpg new file mode 100644 index 0000000..8f368b0 Binary files /dev/null and b/static/thumb_jpg_q10.jpg differ diff --git a/static/thumb_jpg_q20.jpg b/static/thumb_jpg_q20.jpg new file mode 100644 index 0000000..06f7916 Binary files /dev/null and b/static/thumb_jpg_q20.jpg differ diff --git a/static/thumb_jpg_q30.jpg b/static/thumb_jpg_q30.jpg new file mode 100644 index 0000000..f7ac9e1 Binary files /dev/null and b/static/thumb_jpg_q30.jpg differ diff --git a/static/thumb_jpg_q50.jpg b/static/thumb_jpg_q50.jpg new file mode 100644 index 0000000..4fcca36 Binary files /dev/null and b/static/thumb_jpg_q50.jpg differ diff --git a/static/thumb_jpg_q70.jpg b/static/thumb_jpg_q70.jpg new file mode 100644 index 0000000..d62d68d Binary files /dev/null and b/static/thumb_jpg_q70.jpg differ diff --git a/static/thumb_jpg_q85.jpg b/static/thumb_jpg_q85.jpg new file mode 100644 index 0000000..499be5e Binary files /dev/null and b/static/thumb_jpg_q85.jpg differ diff --git a/static/thumb_jpg_q95.jpg b/static/thumb_jpg_q95.jpg new file mode 100644 index 0000000..3f2f862 Binary files /dev/null and b/static/thumb_jpg_q95.jpg differ diff --git a/static/thumb_png_-1.png b/static/thumb_png_-1.png new file mode 100644 index 0000000..fdf70db Binary files /dev/null and b/static/thumb_png_-1.png differ diff --git a/static/thumb_png_-2.png b/static/thumb_png_-2.png new file mode 100644 index 0000000..601aea8 Binary files /dev/null and b/static/thumb_png_-2.png differ diff --git a/static/thumb_png_-3.png b/static/thumb_png_-3.png new file mode 100644 index 0000000..b2f8c71 Binary files /dev/null and b/static/thumb_png_-3.png differ diff --git a/static/thumb_png_0.png b/static/thumb_png_0.png new file mode 100644 index 0000000..1c605fd Binary files /dev/null and b/static/thumb_png_0.png differ diff --git a/static/thumbs.html b/static/thumbs.html new file mode 100644 index 0000000..3eca0ee --- /dev/null +++ b/static/thumbs.html @@ -0,0 +1,43 @@ + + +Thumbnail Test + + + +

Thumbnails

+
Loading...
+ + + diff --git a/static/viewer-screenshot.png b/static/viewer-screenshot.png new file mode 100644 index 0000000..d7e3b1a Binary files /dev/null and b/static/viewer-screenshot.png differ diff --git a/static/viewer.css b/static/viewer.css new file mode 100644 index 0000000..b949d55 --- /dev/null +++ b/static/viewer.css @@ -0,0 +1,513 @@ +@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600&display=swap'); +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; overflow: hidden; } +body { background: #000; color: #fff; font-family: monospace; display: flex; flex-direction: column; } +#header { padding: 10px 16px; background: #1a1a1a; display: flex; gap: 20px; align-items: center; font-family: 'Sora', sans-serif; flex-shrink: 0; } +#header-left { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +#header-right { display: flex; gap: 16px; align-items: center; margin-left: auto; } +#coordsBox { display: flex; gap: 5px; align-items: center; } +#coordsBox input { background: #222; color: #B45309; border: 1px solid #B45309; padding: 6px 10px; width: 200px; font-family: monospace; font-size: 12px; border-radius: 4px; } +#coordsBox button { background: #B45309; color: #000; border: none; padding: 6px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; } +#coordsBox button:hover { background: #D97706; } +#branding { display: flex; align-items: center; gap: 6px; font-size: 15px; } +#branding .brand-inou { color: #B45309; font-weight: 600; } +#branding .brand-health { color: #888; font-weight: 300; } +#panels { display: flex; flex: 1; overflow: hidden; min-height: 0; } +.panel { flex: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid #333; } +.panel:last-child { border-right: none; } +.panel-header { padding: 5px 10px; background: #2a2a2a; font-size: 12px; } +.panel-header .series-name { color: #B45309; font-weight: 500; } +.panel-content { flex: 1; display: flex; justify-content: center; align-items: center; overflow: hidden; position: relative; min-height: 0; } +.panel-content.zoomed { cursor: grab; } +.panel-content.panning { cursor: grabbing; } +.panel-content img { max-width: 100%; max-height: 100%; } +.thumbnails { height: 110px; background: #111; display: flex; padding: 8px 16px; gap: 10px; align-items: stretch; font-family: 'Sora', sans-serif; } +.wl-presets { display: flex; gap: 8px; flex-shrink: 0; } +.wl-preset { display: flex; flex-direction: column; align-items: center; cursor: pointer; opacity: 0.7; transition: opacity 0.15s; } +.wl-preset:hover { opacity: 1; } +.wl-preset.active { opacity: 1; } +.wl-preset img { height: 70px; width: auto; border: 2px solid transparent; border-radius: 4px; } +.wl-preset.active img { border-color: #B45309; } +.wl-preset span { font-size: 10px; color: #888; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; } +.wl-preset.active span { color: #B45309; } +.slice-scrubber { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 0 20px; min-width: 200px; max-width: 500px; } +.scrubber-label { font-size: 11px; color: #666; margin-bottom: 8px; } +.scrubber-track { height: 8px; background: #222; border-radius: 4px; position: relative; cursor: pointer; } +.scrubber-fill { height: 100%; background: linear-gradient(90deg, #F59E0B, #B45309); border-radius: 4px; position: absolute; left: 0; top: 0; pointer-events: none; } +.scrubber-handle { width: 16px; height: 16px; background: #B45309; border-radius: 50%; position: absolute; top: 50%; transform: translate(-50%, -50%); cursor: grab; box-shadow: 0 0 8px rgba(245,158,11,0.5); } +.scrubber-handle:active { cursor: grabbing; } +.scrubber-ticks { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: #444; } +.thumb { height: 70px; cursor: pointer; opacity: 0.6; } +.thumb:hover { opacity: 0.8; } +.thumb.active { opacity: 1; border: 2px solid #B45309; } +select { + background: #1a1a1a; + color: #fff; + border: 1px solid #333; + padding: 8px 32px 8px 12px; + border-radius: 8px; + font-family: 'Sora', sans-serif; + font-size: 13px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + transition: border-color 0.15s, background-color 0.15s; +} +select:hover { background-color: #222; border-color: #444; } +select:focus { outline: none; border-color: #B45309; } +button { + background: #1a1a1a; + color: #fff; + border: 1px solid #333; + padding: 8px 16px; + border-radius: 8px; + font-family: 'Sora', sans-serif; + font-size: 13px; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; +} +button:hover { background: #222; border-color: #444; } +button:disabled { background: #111; color: #555; cursor: not-allowed; border-color: #222; } +button:disabled:hover { background: #111; } + +.sync-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #888; + cursor: pointer; +} +.sync-label input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + border: 1px solid #444; + border-radius: 3px; + background: #1a1a1a; + cursor: pointer; + position: relative; +} +.sync-label input[type="checkbox"]:checked { + background: #B45309; + border-color: #B45309; +} +.sync-label input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid #000; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.sync-label span { white-space: nowrap; } + +#coordDisplay { + position: fixed; + bottom: 45px; + right: 20px; + background: rgba(0,0,0,0.8); + padding: 5px 12px; + font-size: 13px; + color: #B45309; + border: 1px solid rgba(245,158,11,0.3); + border-radius: 4px; + z-index: 1000; + font-variant-numeric: tabular-nums; +} +#wlHint { + position: fixed; + background: rgba(0,0,0,0.9); + padding: 6px 12px; + font-size: 12px; + font-family: 'Sora', sans-serif; + color: #fff; + border: 1px solid rgba(255,255,255,0.3); + border-radius: 6px; + z-index: 2000; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; +} +#wlHint.show { opacity: 1; } +#debugInfo { + color: #f88; + font-size: 11px; + display: none; +} +.rect-overlay { + position: absolute; + border: 2px solid #ff0; + background: rgba(255, 255, 0, 0.1); + pointer-events: none; +} +.img-wrapper { + position: relative; + display: inline-block; + transition: transform 0.15s ease-out; + transform-origin: center center; +} +.crosshair-h, .crosshair-v { + position: absolute; + background: rgba(245, 158, 11, 0.7); + pointer-events: none; +} +.crosshair-h { + height: 1px; + left: 0; + right: 0; +} +.crosshair-v { + width: 1px; + top: 0; + bottom: 0; +} +.panel-label { + position: absolute; + top: 5px; + left: 5px; + color: #B45309; + font-size: 14px; + font-weight: bold; + text-shadow: 1px 1px 2px #000; +} +#helpBtn { + background: #222; + border: 2px solid #B45309; + color: #B45309; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 16px; + font-weight: bold; + cursor: pointer; + margin-left: 20px; + line-height: 24px; + text-align: center; + padding: 0; +} +#helpBtn:hover { background: #B45309; color: #000; } + +/* Image info overlay - premium medical display */ +.image-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + font-family: "Sora", -apple-system, BlinkMacSystemFont, sans-serif; + z-index: 10; +} + +/* Corner info blocks */ +.overlay-top-left { + position: absolute; + top: 16px; + left: 16px; + display: flex; + flex-direction: column; + gap: 0; +} +.overlay-top-right { + position: absolute; + top: 16px; + right: 16px; + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0; +} +.overlay-bottom-left { + position: absolute; + bottom: 16px; + left: 16px; + display: flex; + flex-direction: column; + gap: 4px; +} +.overlay-bottom-right { + position: absolute; + bottom: 16px; + right: 16px; + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +/* Patient and study info */ +.overlay-patient { + font-size: 15px; + font-weight: 500; + color: rgba(255,255,255,0.95); + text-shadow: 0 1px 4px rgba(0,0,0,0.9), 0 0 20px rgba(0,0,0,0.6); + letter-spacing: 0.3px; + line-height: 20px; +} +.overlay-datetime { + font-size: 13px; + color: rgba(255,255,255,0.95); + text-shadow: 0 1px 4px rgba(0,0,0,0.9); + line-height: 20px; +} +.overlay-accession { + font-size: 13px; + color: rgba(255,255,255,0.5); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + line-height: 20px; +} +.overlay-institution { + font-size: 13px; + color: rgba(255,255,255,0.5); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + line-height: 20px; +} +.overlay-study { + font-size: 11px; + font-weight: 400; + color: rgba(255,255,255,0.6); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + line-height: 20px; +} + +/* Series name - prominent orange accent */ +.overlay-series { + font-size: 15px; + font-weight: 600; + color: #B45309; + text-shadow: 0 0 12px rgba(245,158,11,0.4), 0 1px 4px rgba(0,0,0,0.9); + letter-spacing: 0.5px; + line-height: 20px; +} + +/* Slice info */ +.overlay-slice { + font-size: 20px; + font-weight: 300; + color: rgba(255,255,255,0.95); + text-shadow: 0 1px 4px rgba(0,0,0,0.9); + font-variant-numeric: tabular-nums; +} +.overlay-slice-total { + font-size: 13px; + color: rgba(255,255,255,0.5); +} +.overlay-pos { + font-size: 13px; + font-weight: 400; + color: rgba(255,255,255,0.9); + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + font-variant-numeric: tabular-nums; + line-height: 20px; +} +.overlay-thickness { + font-size: 10px; + color: rgba(255,255,255,0.4); + line-height: 20px; +} + +/* W/L display - elegant pill design */ +.overlay-wl { + display: flex; + gap: 12px; + align-items: center; + align-self: flex-end; + background: rgba(0,0,0,0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + padding: 8px 14px; + border-radius: 20px; + border: 1px solid rgba(255,255,255,0.2); +} +.overlay-wl-item { + display: flex; + align-items: baseline; + gap: 6px; +} +.overlay-wl-label { + font-size: 10px; + font-weight: 500; + color: rgba(255,255,255,0.4); + text-transform: uppercase; + letter-spacing: 1px; +} +.overlay-wl-value { + font-size: 14px; + font-weight: 500; + color: rgba(255,255,255,0.9); + font-variant-numeric: tabular-nums; + min-width: 55px; + text-align: right; +} +.overlay-wl-value.wl-adjusted { + color: #ffeb3b !important; + text-shadow: 0 0 8px rgba(255,235,59,0.5); +} +.overlay-wl-divider { + width: 1px; + height: 16px; + background: rgba(255,255,255,0.15); +} + +/* Orientation markers - elegant edge labels */ +.overlay-orient { + position: absolute; + font-size: 20px; + font-weight: 600; + color: rgba(245,158,11,0.7); + text-shadow: 0 0 10px rgba(0,0,0,0.9), 0 0 20px rgba(0,0,0,0.6); + letter-spacing: 1px; +} +.overlay-orient-top { + top: 16px; + left: 50%; + transform: translateX(-50%); +} +.overlay-orient-bottom { + bottom: 16px; + left: 50%; + transform: translateX(-50%); +} +.overlay-orient-left { + left: 16px; + top: 50%; + transform: translateY(-50%); +} +.overlay-orient-right { + right: 16px; + top: 50%; + transform: translateY(-50%); +} + +/* Zoom indicator */ +.overlay-zoom { + font-size: 12px; + font-weight: 500; + color: rgba(255,255,255,0.6); + background: rgba(0,0,0,0.4); + padding: 4px 10px; + border-radius: 12px; + display: none; +} +.overlay-zoom.active { + display: inline-block; + color: #B45309; +} +/* Tour overlay */ +#tourOverlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 3000; +} +#tourOverlay.show { display: block; } +#tourSpotlight { + position: absolute; + border: 3px solid #B45309; + border-radius: 8px; + box-shadow: 0 0 0 9999px rgba(0,0,0,0.5), 0 0 20px #B45309; + pointer-events: none; + transition: all 0.3s ease; + z-index: 1; +} +#tourTooltip { + position: absolute; + background: #111; + border: 2px solid #B45309; + color: #fff; + padding: 15px 20px; + max-width: 450px; + border-radius: 8px; + font-size: 14px; + line-height: 1.5; + z-index: 3001; + display: block; +} +#tourTooltip h3 { color: #B45309; margin: 0 0 8px 0; font-size: 16px; } +#tourTooltip p { margin: 0 0 12px 0; } +#tourTooltip .tour-nav { display: flex; justify-content: space-between; align-items: center; } +#tourTooltip .tour-nav button { + background: #B45309; color: #000; border: none; padding: 6px 16px; + border-radius: 4px; cursor: pointer; font-weight: bold; +} +#tourTooltip .tour-nav button:hover { background: #0aa; } +#tourTooltip .tour-skip { background: transparent !important; color: #888 !important; } +#tourTooltip .tour-step { color: #666; font-size: 12px; } +#tourBtn { + background: #222; border: 1px solid #555; color: #B45309; + padding: 5px 10px; cursor: pointer; font-size: 12px; +} +#tourBtn:hover { background: #333; } +#helpModal { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.8); + z-index: 2000; + justify-content: center; + align-items: center; +} +#helpModal.show { display: flex; } +#helpContent { + background: #111; + border: 1px solid #B45309; + padding: 20px 30px; + max-width: 400px; + font-size: 14px; + line-height: 1.8; +} +#helpContent h2 { color: #B45309; margin: 0 0 15px 0; font-size: 18px; } +#helpContent table { width: 100%; } +#helpContent td { padding: 4px 0; } +#helpContent td:first-child { color: #ff0; width: 140px; } +#helpContent hr { border: none; border-top: 1px solid #333; margin: 12px 0; } + + +/* Light background overlay adjustments (for X-rays, etc.) */ +.panel.light-bg .overlay-patient, +.panel.light-bg .overlay-accession, +.panel.light-bg .overlay-study-desc, +.panel.light-bg .overlay-datetime, +.panel.light-bg .overlay-institution { + color: rgba(0,0,0,0.7); + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-series { + color: #92400E; + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-slice { + color: rgba(0,0,0,0.9); + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-slice-total { + color: rgba(0,0,0,0.5); +} +.panel.light-bg .overlay-pos, +.panel.light-bg .overlay-thickness { + color: rgba(0,0,0,0.7); + text-shadow: 0 1px 2px rgba(255,255,255,0.8); +} +.panel.light-bg .overlay-orient { + color: rgba(0,0,0,0.7); + text-shadow: 0 1px 3px rgba(255,255,255,0.9); +} +.panel.light-bg .overlay-wl { + background: rgba(255,255,255,0.6); + border-color: rgba(0,0,0,0.2); +} +.panel.light-bg .overlay-wl-label { + color: rgba(0,0,0,0.5); +} +.panel.light-bg .overlay-wl-value { + color: rgba(0,0,0,0.9); +} diff --git a/static/viewer.js b/static/viewer.js new file mode 100644 index 0000000..e0bd9eb --- /dev/null +++ b/static/viewer.js @@ -0,0 +1,1648 @@ +let studies = []; +let currentStudy = null; +let studyInfo = {}; +let seriesList = []; +let panels = []; +let panelCount = 0; +let is3DMode = false; +let seriesListByOrientation = { SAG: [], AX: [], COR: [] }; +let tokenParam = ''; // Will be set from URL if present + +// W/L presets for common viewing windows +const wlPresets = [ + { name: 'Default', wc: null, ww: null }, + { name: 'Brain', wc: 40, ww: 80 }, + { name: 'Subdural', wc: 80, ww: 200 }, + { name: 'Bone', wc: 500, ww: 2000 }, + { name: 'Stroke', wc: 40, ww: 40 }, + { name: 'Soft', wc: 50, ww: 400 } +]; + +// Detect if image background is light (for overlay color adjustment) +function detectImageBrightness(img, panelIdx) { + const div = document.getElementById('panel-' + panelIdx); + if (!div || !img.complete || !img.naturalWidth) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const sampleSize = 50; + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Sample top-left corner (where overlay text appears) + ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize); + const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data; + + let total = 0; + for (let i = 0; i < data.length; i += 4) { + total += (data[i] + data[i+1] + data[i+2]) / 3; + } + const avgBrightness = total / (data.length / 4); + + // Toggle light-bg class based on brightness threshold + div.classList.toggle('light-bg', avgBrightness > 160); +} + +function addToken(url) { + if (!tokenParam) return url; + return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam; +} + +// Rectangle drawing state +let isDrawing = false; +let startX = 0, startY = 0; +let currentRect = null; +let activePanel = null; + +// Window/Level adjustment state +let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw } +let isAdjustingWL = false; +let wlStartX = 0, wlStartY = 0; +let wlStartWc = 0, wlStartWw = 0; +let wlDebounceTimer = null; +let wlPanel = -1; + +function getImageUrl(sliceId, seriesId) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) { + params.push("wc=" + Math.round(wlState[seriesId].wc)); + params.push("ww=" + Math.round(wlState[seriesId].ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function getImageUrlWithWL(sliceId, seriesId, wc, ww) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (wc !== null && ww !== null) { + params.push("wc=" + Math.round(wc)); + params.push("ww=" + Math.round(ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function initWLState(seriesId, slices) { + if (!wlState[seriesId] && slices.length > 0) { + const s = slices[0]; + wlState[seriesId] = { adjusted: false, + wc: s.window_center || 128, + ww: s.window_width || 256, + originalWc: s.window_center || 128, + originalWw: s.window_width || 256 + }; + } +} + +function resetWL(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (state) { + state.wc = state.originalWc; + state.ww = state.originalWw; + state.adjusted = false; + reloadPanelImages(panelIdx); + } +} + +function reloadPanelImages(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return; + const div = document.getElementById("panel-" + panelIdx); + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + // Reload thumbnails too + const thumbs = div.querySelectorAll(".thumb"); + thumbs.forEach((t, i) => { + t.src = getImageUrl(panel.slices[i].id, panel.seriesId); + }); + updateOverlay(panelIdx); +} + +function updateOverlay(panelIdx) { + const panel = panels[panelIdx]; + if (!panel) return; + const div = document.getElementById("panel-" + panelIdx); + if (!div) return; + + // Get series info + const series = seriesList.find(s => s.id === panel.seriesId) || + (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null); + const seriesName = series ? series.series_desc : ""; + + // Get slice info + const slice = panel.slices[panel.currentSlice]; + + // Get W/L info + let wc = "", ww = ""; + let adjusted = false; + if (panel.seriesId && wlState[panel.seriesId]) { + const state = wlState[panel.seriesId]; + if (state.adjusted) { + wc = Math.round(state.wc); + ww = Math.round(state.ww); + adjusted = true; + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + + // Get zoom level + const orientation = panel.orientation || "AX"; + const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100; + + // Update all overlay elements + const q = s => div.querySelector(s); + + // Top left - patient/study info + if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " "); + if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : ""; + if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || ""; + if (q(".overlay-series")) q(".overlay-series").textContent = seriesName; + if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : ""; + if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : ""; + + // Top right - technical info + if (q(".overlay-datetime")) { + let dt = ""; + if (studyInfo.study_date) { + dt = studyInfo.study_date; + if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4); + } + q(".overlay-datetime").textContent = dt; + } + if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || ""; + if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : ""; + if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : ""; + + const wcEl = q(".overlay-wc"); + const wwEl = q(".overlay-ww"); + if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); } + if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); } + + if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : ""; + + // Orientation markers based on image_orientation + updateOrientationMarkers(div, slice, orientation); +} + +function updateOrientationMarkers(div, slice, orientationType) { + const left = div.querySelector(".overlay-orient-left"); + const right = div.querySelector(".overlay-orient-right"); + const top = div.querySelector(".overlay-orient-top"); + const bottom = div.querySelector(".overlay-orient-bottom"); + + // Default markers based on orientation type + let markers = { left: "", right: "", top: "", bottom: "" }; + + if (orientationType === "AX") { + markers = { left: "R", right: "L", top: "A", bottom: "P" }; + } else if (orientationType === "SAG") { + markers = { left: "A", right: "P", top: "S", bottom: "I" }; + } else if (orientationType === "COR") { + markers = { left: "R", right: "L", top: "S", bottom: "I" }; + } + + // TODO: Parse image_orientation DICOM tag for exact orientation if needed + + if (left) left.textContent = markers.left; + if (right) right.textContent = markers.right; + if (top) top.textContent = markers.top; + if (bottom) bottom.textContent = markers.bottom; +} + +// Zoom state - shared by orientation type +const zoomLevels = [1, 1.5, 2, 3, 4]; +let zoomState = { + AX: { level: 0, panX: 0, panY: 0 }, + SAG: { level: 0, panX: 0, panY: 0 }, + COR: { level: 0, panX: 0, panY: 0 } +}; +let hoveredPanel = 0; +let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper +let scrollAccumulator = 0; // for slower slice scrolling + +function toggleHelp() { + document.getElementById('helpModal').classList.toggle('show'); +} + +// Tour functionality +const tourSteps = [ + { + target: () => document.getElementById('header'), + title: 'Welcome', + text: 'Explore medical imaging with AI assistance.

Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com', + pos: 'bottom' + }, + { + target: () => document.querySelector('button[onclick="setPanels(1)"]'), + title: 'Panel Layout', + text: 'Switch between 1, 2, or 3 panels to compare different series side by side.', + pos: 'bottom' + }, + { + target: () => document.getElementById('btn3d'), + title: '3D Crosshair Mode', + text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.', + pos: 'bottom' + }, + { + target: () => document.getElementById('helpBtn'), + title: 'Keyboard Shortcuts', + text: 'Click here for a quick reference of all keyboard and mouse controls.', + pos: 'bottom' + }, + { + target: () => document.querySelector('.panel-content img'), + title: 'Select a Region', + text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.', + pos: 'bottom' + }, + { + target: () => document.getElementById('rectInfo'), + title: 'AI Communication', + text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.', + pos: 'bottom' + } +]; + +let tourIndex = 0; + +function startTour() { + tourIndex = 0; + document.getElementById('tourOverlay').classList.add('show'); + showTourStep(); +} + +function endTour() { + document.getElementById('tourOverlay').classList.remove('show'); + localStorage.setItem('tourSeen', 'true'); +} + +function showTourStep() { + const step = tourSteps[tourIndex]; + const target = step.target(); + if (!target) { nextTourStep(); return; } + + const rect = target.getBoundingClientRect(); + const spotlight = document.getElementById('tourSpotlight'); + const tooltip = document.getElementById('tourTooltip'); + + // Position spotlight + const pad = 8; + spotlight.style.left = (rect.left - pad) + 'px'; + spotlight.style.top = (rect.top - pad) + 'px'; + spotlight.style.width = (rect.width + pad * 2) + 'px'; + spotlight.style.height = (rect.height + pad * 2) + 'px'; + + // Build tooltip + const isLastStep = tourIndex >= tourSteps.length - 1; + tooltip.innerHTML = '

' + step.title + '

' + step.text + '

' + + '
' + + (isLastStep ? '' : '') + + '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' + + '' + + '
'; + + // Position tooltip + const ttWidth = tourIndex === 0 ? 420 : 300; + const ttHeight = tourIndex === 0 ? 280 : 150; + let ttLeft = rect.left + rect.width / 2 - ttWidth / 2; + ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft)); + tooltip.style.left = ttLeft + 'px'; + tooltip.style.width = ttWidth + 'px'; + + // Welcome screen: center vertically + if (tourIndex === 0) { + tooltip.style.top = '50%'; + tooltip.style.transform = 'translateY(-50%)'; + tooltip.style.left = '50%'; + tooltip.style.marginLeft = (-ttWidth / 2) + 'px'; + return; + } + tooltip.style.transform = 'none'; + tooltip.style.marginLeft = '0'; + + // Determine best vertical position + const spaceBelow = window.innerHeight - rect.bottom - 20; + const spaceAbove = rect.top - 20; + const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove; + + tooltip.style.bottom = 'auto'; + tooltip.style.top = 'auto'; + + if (placeBelow) { + let ttTop = rect.bottom + 15; + ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20); + tooltip.style.top = ttTop + 'px'; + } else { + let ttTop = rect.top - ttHeight - 15; + ttTop = Math.max(10, ttTop); + tooltip.style.top = ttTop + 'px'; + } +} + +function nextTourStep() { + tourIndex++; + if (tourIndex >= tourSteps.length) { + endTour(); + } else { + showTourStep(); + } +} + +// Pan state +let isPanning = false; +let panStartMouseX = 0, panStartMouseY = 0; +let panStartPanX = 0, panStartPanY = 0; +let panOrientation = null; + +function getCurrentImageRef(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return ''; + const slice = panel.slices[panel.currentSlice]; + const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown'; + const study = studies.find(s => s.id == document.getElementById('studySelect').value); + const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown'; + return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number; +} + +async function init() { + // Create W/L hint element + const wlHint = document.createElement('div'); + wlHint.id = 'wlHint'; + wlHint.textContent = 'Image updates after 0.3s'; + document.body.appendChild(wlHint); + + // Extract token from URL for subsequent API calls + const params = new URLSearchParams(window.location.search); + tokenParam = params.get('token') || ''; + + const res = await fetch(addToken('/api/studies')); + studies = await res.json(); + const sel = document.getElementById('studySelect'); + studies.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.study_date + ' - ' + s.study_desc; + sel.appendChild(opt); + }); + + if (studies.length > 0) sel.selectedIndex = 0; + + // Deep link by study/series GUID + const urlStudy = params.get('study'); + const urlSeries = params.get('series'); + + if (urlStudy) { + const idx = studies.findIndex(s => s.id === urlStudy); + if (idx >= 0) sel.selectedIndex = idx; + } + + if (studies.length > 0) { + await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise + if (urlSeries && seriesList.length > 0) { + const idx = seriesList.findIndex(s => s.id === urlSeries); + if (idx >= 0 && panels[0]) { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option + await loadSeries(0, seriesList[idx].id); + } + } + } + + // Auto-start tour for first-time users + if (!localStorage.getItem('tourSeen')) { + setTimeout(startTour, 800); + } +} + +async function addPanelEmpty() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); +} + +async function loadStudy(numPanels = 2) { + const studyId = document.getElementById('studySelect').value; + const res = await fetch(addToken('/api/series?study=' + studyId)); + seriesList = await res.json(); + // Fetch study info for overlay + const infoRes = await fetch(addToken("/api/studies?study=" + studyId)); + studyInfo = await infoRes.json(); + is3DMode = false; + + // Smart UI: show/hide elements based on series count + const seriesCount = seriesList.length; + const btn2panels = document.getElementById('btn2panels'); + const btn3panels = document.getElementById('btn3panels'); + const btn3d = document.getElementById('btn3d'); + const syncLabel = document.getElementById('syncLabel'); + + if (seriesCount === 1) { + // Single series: hide multi-panel options, 3D, sync + btn2panels.style.display = 'none'; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + numPanels = 1; // Force single panel + } else if (seriesCount === 2) { + // Two series: hide 3-panel, 3D, sync + btn2panels.style.display = ''; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + if (numPanels > 2) numPanels = 2; + } else { + // 3+ series: show all, check 3D availability + btn2panels.style.display = ''; + btn3panels.style.display = ''; + btn3d.style.display = ''; + syncLabel.style.display = ''; + + // Check if 3D mode is available (has SAG, AX, and COR) + const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG')); + const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX')); + const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR')); + btn3d.disabled = !(hasSag && hasAx && hasCor); + btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode'; + } + + await setPanels(numPanels); +} + +async function set3DMode() { + const studyId = document.getElementById('studySelect').value; + if (!studyId) return; + + is3DMode = true; + document.getElementById('syncScroll').checked = false; + + // Fetch series for each orientation + const [sagRes, axRes, corRes] = await Promise.all([ + fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=COR')) + ]); + seriesListByOrientation.SAG = await sagRes.json() || []; + seriesListByOrientation.AX = await axRes.json() || []; + seriesListByOrientation.COR = await corRes.json() || []; + + // Clear and create 3 panels + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + + await add3DPanel(0, 'SAG', seriesListByOrientation.SAG); + await add3DPanel(1, 'AX', seriesListByOrientation.AX); + await add3DPanel(2, 'COR', seriesListByOrientation.COR); +} + +function pickBestSeries(seriesOptions) { + if (!seriesOptions || !seriesOptions.length) return null; + // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc) + let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc)); + if (t1plus) return t1plus.id; + // Then T2 + let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc)); + if (t2) return t2.id; + // Then T1 (without contrast) + let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc)); + if (t1) return t1.id; + // Fallback to first + return seriesOptions[0].id; +} + +async function add3DPanel(idx, orientation, seriesOptions) { + panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + div.innerHTML = + '
' + + '' + orientation + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + + // Auto-select best series (T1+ > T2 > T1 > first) + const bestSeriesId = pickBestSeries(seriesOptions); + if (bestSeriesId) { + div.querySelector('select').value = bestSeriesId; + await loadSeries(idx, bestSeriesId); + } +} + +async function setPanels(count) { + is3DMode = false; + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + for (let i = 0; i < count; i++) { + await addPanel(); + } +} + +function getImageCoords(e, img) { + const rect = img.getBoundingClientRect(); + const scaleX = img.naturalWidth / rect.width; + const scaleY = img.naturalHeight / rect.height; + const x = Math.round((e.clientX - rect.left) * scaleX); + const y = Math.round((e.clientY - rect.top) * scaleY); + return { x, y, rect, scaleX, scaleY }; +} + +function getPanelOrientation(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return 'AX'; + // In 3D mode, orientation is stored on panel + if (panel.orientation) return panel.orientation; + // Otherwise, derive from series description + const series = seriesList.find(s => s.id == panel.seriesId); + if (series) { + const desc = series.series_desc.toUpperCase(); + if (desc.includes('SAG')) return 'SAG'; + if (desc.includes('COR')) return 'COR'; + } + return 'AX'; // default +} + +function applyZoom(orientation) { + const state = zoomState[orientation]; + const zoom = zoomLevels[state.level]; + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (!div) return; + const wrapper = div.querySelector('.img-wrapper'); + const content = div.querySelector('.panel-content'); + wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)'; + content.classList.toggle('zoomed', state.level > 0); + } + }); +} + +function zoomIn(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level < zoomLevels.length - 1) { + state.level++; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function zoomOut(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level > 0) { + state.level--; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function resetZoom(orientation) { + const state = zoomState[orientation]; + state.level = 0; + state.panX = 0; + state.panY = 0; + applyZoom(orientation); +} + +async function addPanel() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const wrapper = div.querySelector('.img-wrapper'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + // Update coords in image space + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + // Mouse down - start drawing + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + // Mouse up - finish drawing + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + // Auto-load default series, but skip if URL has a series parameter (will be loaded by init) + const params = new URLSearchParams(window.location.search); + const urlSeries = params.get('series'); + if (idx < seriesList.length && !(idx === 0 && urlSeries)) { + // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts + const preferred = pickDefaultSeries(seriesList, idx); + if (preferred) { + const select = div.querySelector('select'); + if (select) select.value = preferred.id; + await loadSeries(idx, preferred.id); + } + } +} + +function pickDefaultSeries(series, panelIdx) { + // Score each series - lower is better + const scored = series.map(s => { + const desc = s.series_desc.toUpperCase(); + let score = 100; + + // Strongly prefer structural sequences + if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50; + if (desc.includes('T1')) score -= 40; + if (desc.includes('FLAIR')) score -= 35; + + // Prefer axial for comparison + if (desc.includes('AX')) score -= 20; + + // Avoid diffusion/DWI/DTI + if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100; + if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80; + if (desc.includes('ADC') || desc.includes('TRACE')) score += 60; + + // Prefer moderate slice counts (20-50 is typical for structural) + if (s.slice_count > 200) score += 50; + if (s.slice_count > 500) score += 50; + + return { ...s, score }; + }); + + // Sort by score + scored.sort((a, b) => a.score - b.score); + + // For panel 0, pick best. For panel 1+, pick next best with SAME orientation + if (panelIdx === 0) { + return scored[0]; + } else { + // Get orientation of first panel's pick + const firstPick = scored[0]; + const firstDesc = firstPick.series_desc.toUpperCase(); + let firstOrientation = 'AX'; + if (firstDesc.includes('SAG')) firstOrientation = 'SAG'; + else if (firstDesc.includes('COR')) firstOrientation = 'COR'; + + // Find next best with same orientation (excluding first pick) + const sameOrientation = scored.filter(s => { + if (s.id === firstPick.id) return false; + const desc = s.series_desc.toUpperCase(); + if (firstOrientation === 'SAG') return desc.includes('SAG'); + if (firstOrientation === 'COR') return desc.includes('COR'); + return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR')); + }); + + + return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0]; + } +} + +function showRectFromInput() { + const input = document.getElementById('rectCoords').value; + const debug = document.getElementById('debugInfo'); + debug.textContent = 'Parsing: ' + input; + + // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)" + const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/); + if (fullMatch) { + const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch; + debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum; + + // Find matching study + const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim())); + if (!study) { + debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim(); + debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', '); + return; + } + debug.textContent = 'Found study id=' + study.id; + + document.getElementById('studySelect').value = study.id; + fetch(addToken('/api/series?study=' + study.id)) + .then(res => res.json()) + .then(series => { + seriesList = series; + debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', '); + const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim()); + if (!targetSeries) { + debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"'; + return; + } + debug.textContent = 'Found series id=' + targetSeries.id; + setPanels(1).then(() => { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.value = targetSeries.id; + loadSeries(0, targetSeries.id).then(() => { + const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum)); + debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum; + if (sliceIdx >= 0) { + goToSlice(0, sliceIdx); + setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100); + } + }); + }); + }); + return; + } + + debug.textContent = 'No full match, trying coords only...'; + // Fallback: just coordinates + const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/); + if (!match) { + debug.textContent = 'No coord match either'; + return; + } + + const x1 = parseInt(match[1]), y1 = parseInt(match[2]); + const x2 = parseInt(match[3]), y2 = parseInt(match[4]); + debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2; + + const panelIdx = activePanel !== null ? activePanel : 0; + drawRect(panelIdx, x1, y1, x2, y2); +} + +function drawRect(panelIdx, x1, y1, x2, y2) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + + const img = panel.querySelector('.panel-content img'); + const rectOverlay = panel.querySelector('.rect-overlay'); + + const rect = img.getBoundingClientRect(); + // Divide out zoom since rect overlay is inside the transformed wrapper + const orientation = getPanelOrientation(panelIdx); + const zoom = zoomLevels[zoomState[orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + rectOverlay.style.left = (x1 * scaleX) + 'px'; + rectOverlay.style.top = (y1 * scaleY) + 'px'; + rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px'; + rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px'; + rectOverlay.style.display = 'block'; + + currentRect = { panelIdx, x1, y1, x2, y2 }; + activePanel = panelIdx; +} + +function copyCoords() { + const input = document.getElementById('rectCoords'); + input.select(); + document.execCommand('copy'); +} + +function clearRect() { + document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none'); + document.getElementById('rectCoords').value = ''; + currentRect = null; +} + +async function loadSeries(panelIdx, seriesId) { + if (!seriesId) return; + const res = await fetch(addToken('/api/slices?series=' + seriesId)); + const data = await res.json(); + const slices = data.slices || data; + panels[panelIdx].seriesId = seriesId; + panels[panelIdx].slices = slices; + panels[panelIdx].currentSlice = 0; + initWLState(seriesId, slices); + + const panel = document.getElementById('panel-' + panelIdx); + const thumbs = panel.querySelector('.thumbnails'); + + // Create W/L presets + scrubber + const midSliceId = slices[Math.floor(slices.length / 2)]?.id; + const presetsHtml = wlPresets.map((p, i) => + '
' + + '' + + '' + p.name + '
' + ).join(''); + + const scrubberHtml = + '
' + + '
Slice 1 / ' + slices.length + '
' + + '
' + + '
' + + '
' + + '
' + + '
1' + slices.length + '
' + + '
'; + + thumbs.innerHTML = '
' + presetsHtml + '
' + scrubberHtml; + + // Setup scrubber interaction + setupScrubber(panelIdx); + + // Preload all slice images for smooth scrolling + slices.forEach(s => { + const img = new Image(); + img.src = getImageUrlWithWL(s.id, seriesId, null, null); + }); + + // Start at middle slice + const midSlice = Math.floor(slices.length / 2); + goToSlice(panelIdx, midSlice); +} + +function update3DCrosshairs() { + if (!is3DMode) return; + + const getData = (p) => { + if (!p || !p.slices.length) return null; + const s = p.slices[p.currentSlice]; + // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz" + let rowVec = [1,0,0], colVec = [0,1,0]; + if (s.image_orientation) { + const parts = s.image_orientation.split('\\').map(Number); + if (parts.length === 6) { + rowVec = [parts[0], parts[1], parts[2]]; + colVec = [parts[3], parts[4], parts[5]]; + } + } + + // Compute CENTER of slice (not corner) + const psRow = s.pixel_spacing_row || 0.5; + const psCol = s.pixel_spacing_col || 0.5; + const halfWidth = (s.cols / 2) * psCol; + const halfHeight = (s.rows / 2) * psRow; + + const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0]; + const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1]; + const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2]; + + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + center_x: centerX, + center_y: centerY, + center_z: centerZ, + rows: s.rows, + cols: s.cols, + psRow: psRow, + psCol: psCol, + rowVec: rowVec, + colVec: colVec + }; + }; + + const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; + + const sagPanel = panels.find(p => p.orientation === 'SAG'); + const axPanel = panels.find(p => p.orientation === 'AX'); + const corPanel = panels.find(p => p.orientation === 'COR'); + + const sagData = getData(sagPanel); + const axData = getData(axPanel); + const corData = getData(corPanel); + + panels.forEach((p, idx) => { + if (!p.slices.length || !p.orientation) return; + + const div = document.getElementById('panel-' + idx); + const img = div.querySelector('.panel-content img'); + const hLine = div.querySelector('.crosshair-h'); + const vLine = div.querySelector('.crosshair-v'); + + if (!img.naturalWidth) { + hLine.style.display = 'none'; + vLine.style.display = 'none'; + return; + } + + const myData = getData(p); + const rect = img.getBoundingClientRect(); + // Divide out zoom since crosshairs are inside the transformed wrapper + const zoom = zoomLevels[zoomState[p.orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + // Build target point from CENTER of other slices + // SAG through-plane = X, AX through-plane = Z, COR through-plane = Y + let targetX = myData.center_x, targetY = myData.center_y, targetZ = myData.center_z; + if (sagData && p.orientation !== 'SAG') targetX = sagData.center_x; + if (axData && p.orientation !== 'AX') targetZ = axData.center_z; + if (corData && p.orientation !== 'COR') targetY = corData.center_y; + + // Offset from corner to target + const offset = [targetX - myData.pos_x, targetY - myData.pos_y, targetZ - myData.pos_z]; + + // Project onto row/col directions + const vPixel = dot(offset, myData.rowVec) / myData.psCol; + const hPixel = dot(offset, myData.colVec) / myData.psRow; + + if (hPixel >= 0 && hPixel <= myData.rows) { + hLine.style.top = (hPixel * scaleY) + 'px'; + hLine.style.display = 'block'; + } else { + hLine.style.display = 'none'; + } + + if (vPixel >= 0 && vPixel <= myData.cols) { + vLine.style.left = (vPixel * scaleX) + 'px'; + vLine.style.display = 'block'; + } else { + vLine.style.display = 'none'; + } + }); +} + +function goToSlice(panelIdx, sliceIdx) { + const panel = panels[panelIdx]; + if (!panel.slices.length) return; + panel.currentSlice = sliceIdx; + + const div = document.getElementById('panel-' + panelIdx); + const img = div.querySelector('.panel-content img'); + img.onload = () => detectImageBrightness(img, panelIdx); + img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId); + + // Clear rectangle when changing slice + div.querySelector('.rect-overlay').style.display = 'none'; + + div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx)); + + // Update scrubber position + updateScrubber(panelIdx, sliceIdx); + + updateOverlay(panelIdx); + + // Update crosshairs in 3D mode + if (is3DMode) { + setTimeout(update3DCrosshairs, 50); + } + + if (document.getElementById('syncScroll').checked && !is3DMode) { + const loc = panel.slices[sliceIdx].slice_location; + panels.forEach((p, i) => { + if (i !== panelIdx && p.slices.length) { + const closest = p.slices.reduce((prev, curr, idx) => + Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0); + if (p.currentSlice !== closest) { + p.currentSlice = closest; + const pDiv = document.getElementById('panel-' + i); + pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId); + pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest)); + updateOverlay(i); + } + } + }); + } +} + +// Track hovered panel for keyboard zoom +document.addEventListener('mousemove', (e) => { + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + hoveredPanel = idx; + } + } + }); +}, { passive: true }); + +document.addEventListener('wheel', e => { + if (!panels.length) return; + + // Find which panel the mouse is over + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + + if (targetPanel < 0) return; + hoveredPanel = targetPanel; + + // Track cursor relative to wrapper (for zoom-to-cursor) + // Account for current zoom since getBoundingClientRect returns transformed bounds + const div = document.getElementById('panel-' + targetPanel); + const wrapper = div.querySelector('.img-wrapper'); + const wrapperRect = wrapper.getBoundingClientRect(); + const orientation = getPanelOrientation(targetPanel); + const currentZoom = zoomLevels[zoomState[orientation].level]; + cursorX = (e.clientX - wrapperRect.left) / currentZoom; + cursorY = (e.clientY - wrapperRect.top) / currentZoom; + + // Shift+wheel = zoom + if (e.shiftKey) { + e.preventDefault(); + const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) { + zoomIn(targetPanel); + } else if (delta > 0) { + zoomOut(targetPanel); + } + return; + } + + // Regular wheel = scroll slices + const delta = e.deltaY > 0 ? 1 : -1; + const p = panels[targetPanel]; + if (!p.slices.length) return; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx); +}, { passive: false }); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (document.getElementById('tourOverlay').classList.contains('show')) { + endTour(); + } else if (document.getElementById('helpModal').classList.contains('show')) { + toggleHelp(); + } else { + clearRect(); + } + return; + } + if (!panels.length) return; + + // +/- for zoom (affects hovered panel's orientation group) + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + zoomIn(hoveredPanel); + return; + } + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + zoomOut(hoveredPanel); + return; + } + + // Arrow keys for slice navigation + let delta = 0; + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1; + if (delta === 0) return; + e.preventDefault(); + const p = panels[0]; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(0, newIdx); +}); + +// Cancel drawing if mouse leaves window +document.addEventListener('mouseup', (e) => { + isDrawing = false; + if (isPanning) { + isPanning = false; + // Restore transition + document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = ''); + document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning')); + } +}); + +// Shift+click pan +document.addEventListener('mousedown', (e) => { + if (e.button !== 0 || !e.shiftKey) return; + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + const state = zoomState[orientation]; + if (state.level === 0) return; // no pan at 1x zoom + + isPanning = true; + panOrientation = orientation; + panStartMouseX = e.clientX; + panStartMouseY = e.clientY; + panStartPanX = state.panX; + panStartPanY = state.panY; + // Disable transition during pan for smooth movement + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (div) div.querySelector('.img-wrapper').style.transition = 'none'; + } + }); + document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning')); +}); + +document.addEventListener('mousemove', (e) => { + if (!isPanning || !panOrientation) return; + + const state = zoomState[panOrientation]; + const zoom = zoomLevels[state.level]; + // With transform: scale(zoom) translate(panX, panY), translate values are scaled + // Divide by zoom for 1:1 screen-to-image movement + const dx = (e.clientX - panStartMouseX) / zoom; + const dy = (e.clientY - panStartMouseY) / zoom; + state.panX = panStartPanX + dx; + state.panY = panStartPanY + dy; + applyZoom(panOrientation); +}); + +// Double-click to reset zoom +document.addEventListener('dblclick', (e) => { + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + resetZoom(orientation); +}); + +// Ctrl+click for Window/Level adjustment +document.addEventListener("mousedown", (e) => { + if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const panel = panels[targetPanel]; + if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return; + + isAdjustingWL = true; + isDrawing = false; // Prevent rect drawing + document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none"); + wlPanel = targetPanel; + wlStartX = e.clientX; + wlStartY = e.clientY; + wlStartWc = wlState[panel.seriesId].wc; + wlStartWw = wlState[panel.seriesId].ww; + document.body.style.cursor = "crosshair"; + + // Show hint + const hint = document.getElementById('wlHint'); + hint.style.left = (e.clientX + 15) + 'px'; + hint.style.top = (e.clientY - 10) + 'px'; + hint.classList.add('show'); +}); + +document.addEventListener("mousemove", (e) => { + if (!isAdjustingWL || wlPanel < 0) return; + + const panel = panels[wlPanel]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (!state) return; + + // Horizontal = width, Vertical = center + const dx = e.clientX - wlStartX; + const dy = e.clientY - wlStartY; + + state.ww = Math.max(1, wlStartWw + dx * 2); + state.wc = wlStartWc - dy * 2; // invert: drag up = brighter + state.adjusted = true; + + // Update overlay C/W values in real-time + const div = document.getElementById("panel-" + wlPanel); + const wcEl = div.querySelector(".overlay-wc"); + const wwEl = div.querySelector(".overlay-ww"); + if (wcEl) wcEl.textContent = Math.round(state.wc); + if (wwEl) wwEl.textContent = Math.round(state.ww); + + // Debounce image reload + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + wlDebounceTimer = setTimeout(() => { + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + }, 150); +}); + +document.addEventListener("mouseup", (e) => { + if (isAdjustingWL) { + isAdjustingWL = false; + document.body.style.cursor = ""; + document.getElementById('wlHint').classList.remove('show'); + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + if (wlPanel >= 0) { + reloadPanelImages(wlPanel); + } + wlPanel = -1; + } +}); + +// Track right-click for double-click detection +let lastRightClickTime = 0; +let lastRightClickPanel = -1; + +// Double right-click to reset Window/Level +document.addEventListener("mousedown", (e) => { + if (e.button !== 2) return; + + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const now = Date.now(); + if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) { + // Double right-click detected - reset W/L + resetWL(targetPanel); + lastRightClickTime = 0; + lastRightClickPanel = -1; + e.preventDefault(); + return; + } + lastRightClickTime = now; + lastRightClickPanel = targetPanel; +}); + +// Update crosshairs on window resize +// Prevent context menu on panels for right-click W/L adjustment +document.addEventListener("contextmenu", (e) => { + if (!e.target.closest("#panels")) return; + e.preventDefault(); +}); + +window.addEventListener('resize', () => { + if (is3DMode) update3DCrosshairs(); +}); + +// W/L Preset functions +function applyWLPreset(el) { + const panelIdx = parseInt(el.dataset.panel); + const wcAttr = el.dataset.wc; + const wwAttr = el.dataset.ww; + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + + // Update wlState - null means reset to original + if (wcAttr === 'null' || wwAttr === 'null') { + wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc; + wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw; + wlState[panel.seriesId].adjusted = false; + } else { + wlState[panel.seriesId].wc = parseInt(wcAttr); + wlState[panel.seriesId].ww = parseInt(wwAttr); + wlState[panel.seriesId].adjusted = true; + } + + // Update active preset + const container = el.closest('.thumbnails'); + container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active')); + el.classList.add('active'); + + // Reload image + reloadPanelImages(panelIdx); +} + +function setupScrubber(panelIdx) { + const panel = document.getElementById('panel-' + panelIdx); + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const track = scrubber.querySelector('.scrubber-track'); + let isDragging = false; + + const updateFromPosition = (e) => { + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const pct = x / rect.width; + const sliceCount = panels[panelIdx].slices.length; + const sliceIdx = Math.round(pct * (sliceCount - 1)); + goToSlice(panelIdx, sliceIdx); + }; + + track.addEventListener('mousedown', (e) => { + isDragging = true; + updateFromPosition(e); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (isDragging) updateFromPosition(e); + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); +} + +function updateScrubber(panelIdx, sliceIdx) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const sliceCount = panels[panelIdx].slices.length; + const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0; + + const fill = scrubber.querySelector('.scrubber-fill'); + const handle = scrubber.querySelector('.scrubber-handle'); + const current = scrubber.querySelector('.scrubber-current'); + + if (fill) fill.style.width = pct + '%'; + if (handle) handle.style.left = pct + '%'; + if (current) current.textContent = sliceIdx + 1; +} + +init(); diff --git a/static/viewer.js.bak b/static/viewer.js.bak new file mode 100644 index 0000000..d0838b5 --- /dev/null +++ b/static/viewer.js.bak @@ -0,0 +1,1646 @@ +let studies = []; +let currentStudy = null; +let studyInfo = {}; +let seriesList = []; +let panels = []; +let panelCount = 0; +let is3DMode = false; +let seriesListByOrientation = { SAG: [], AX: [], COR: [] }; +let tokenParam = ''; // Will be set from URL if present + +// W/L presets for common viewing windows +const wlPresets = [ + { name: 'Default', wc: null, ww: null }, + { name: 'Brain', wc: 40, ww: 80 }, + { name: 'Subdural', wc: 80, ww: 200 }, + { name: 'Bone', wc: 500, ww: 2000 }, + { name: 'Stroke', wc: 40, ww: 40 }, + { name: 'Soft', wc: 50, ww: 400 } +]; + +// Detect if image background is light (for overlay color adjustment) +function detectImageBrightness(img, panelIdx) { + const div = document.getElementById('panel-' + panelIdx); + if (!div || !img.complete || !img.naturalWidth) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const sampleSize = 50; + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Sample top-left corner (where overlay text appears) + ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize); + const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data; + + let total = 0; + for (let i = 0; i < data.length; i += 4) { + total += (data[i] + data[i+1] + data[i+2]) / 3; + } + const avgBrightness = total / (data.length / 4); + + // Toggle light-bg class based on brightness threshold + div.classList.toggle('light-bg', avgBrightness > 160); +} + +function addToken(url) { + if (!tokenParam) return url; + return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam; +} + +// Rectangle drawing state +let isDrawing = false; +let startX = 0, startY = 0; +let currentRect = null; +let activePanel = null; + +// Window/Level adjustment state +let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw } +let isAdjustingWL = false; +let wlStartX = 0, wlStartY = 0; +let wlStartWc = 0, wlStartWw = 0; +let wlDebounceTimer = null; +let wlPanel = -1; + +function getImageUrl(sliceId, seriesId) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) { + params.push("wc=" + Math.round(wlState[seriesId].wc)); + params.push("ww=" + Math.round(wlState[seriesId].ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function getImageUrlWithWL(sliceId, seriesId, wc, ww) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (wc !== null && ww !== null) { + params.push("wc=" + Math.round(wc)); + params.push("ww=" + Math.round(ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function initWLState(seriesId, slices) { + if (!wlState[seriesId] && slices.length > 0) { + const s = slices[0]; + wlState[seriesId] = { adjusted: false, + wc: s.window_center || 128, + ww: s.window_width || 256, + originalWc: s.window_center || 128, + originalWw: s.window_width || 256 + }; + } +} + +function resetWL(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (state) { + state.wc = state.originalWc; + state.ww = state.originalWw; + state.adjusted = false; + reloadPanelImages(panelIdx); + } +} + +function reloadPanelImages(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return; + const div = document.getElementById("panel-" + panelIdx); + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + // Reload thumbnails too + const thumbs = div.querySelectorAll(".thumb"); + thumbs.forEach((t, i) => { + t.src = getImageUrl(panel.slices[i].id, panel.seriesId); + }); + updateOverlay(panelIdx); +} + +function updateOverlay(panelIdx) { + const panel = panels[panelIdx]; + if (!panel) return; + const div = document.getElementById("panel-" + panelIdx); + if (!div) return; + + // Get series info + const series = seriesList.find(s => s.id === panel.seriesId) || + (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null); + const seriesName = series ? series.series_desc : ""; + + // Get slice info + const slice = panel.slices[panel.currentSlice]; + + // Get W/L info + let wc = "", ww = ""; + let adjusted = false; + if (panel.seriesId && wlState[panel.seriesId]) { + const state = wlState[panel.seriesId]; + if (state.adjusted) { + wc = Math.round(state.wc); + ww = Math.round(state.ww); + adjusted = true; + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + + // Get zoom level + const orientation = panel.orientation || "AX"; + const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100; + + // Update all overlay elements + const q = s => div.querySelector(s); + + // Top left - patient/study info + if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " "); + if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : ""; + if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || ""; + if (q(".overlay-series")) q(".overlay-series").textContent = seriesName; + if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : ""; + if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : ""; + + // Top right - technical info + if (q(".overlay-datetime")) { + let dt = ""; + if (studyInfo.study_date) { + dt = studyInfo.study_date; + if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4); + } + q(".overlay-datetime").textContent = dt; + } + if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || ""; + if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : ""; + if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : ""; + + const wcEl = q(".overlay-wc"); + const wwEl = q(".overlay-ww"); + if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); } + if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); } + + if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : ""; + + // Orientation markers based on image_orientation + updateOrientationMarkers(div, slice, orientation); +} + +function updateOrientationMarkers(div, slice, orientationType) { + const left = div.querySelector(".overlay-orient-left"); + const right = div.querySelector(".overlay-orient-right"); + const top = div.querySelector(".overlay-orient-top"); + const bottom = div.querySelector(".overlay-orient-bottom"); + + // Default markers based on orientation type + let markers = { left: "", right: "", top: "", bottom: "" }; + + if (orientationType === "AX") { + markers = { left: "R", right: "L", top: "A", bottom: "P" }; + } else if (orientationType === "SAG") { + markers = { left: "A", right: "P", top: "S", bottom: "I" }; + } else if (orientationType === "COR") { + markers = { left: "R", right: "L", top: "S", bottom: "I" }; + } + + // TODO: Parse image_orientation DICOM tag for exact orientation if needed + + if (left) left.textContent = markers.left; + if (right) right.textContent = markers.right; + if (top) top.textContent = markers.top; + if (bottom) bottom.textContent = markers.bottom; +} + +// Zoom state - shared by orientation type +const zoomLevels = [1, 1.5, 2, 3, 4]; +let zoomState = { + AX: { level: 0, panX: 0, panY: 0 }, + SAG: { level: 0, panX: 0, panY: 0 }, + COR: { level: 0, panX: 0, panY: 0 } +}; +let hoveredPanel = 0; +let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper +let scrollAccumulator = 0; // for slower slice scrolling + +function toggleHelp() { + document.getElementById('helpModal').classList.toggle('show'); +} + +// Tour functionality +const tourSteps = [ + { + target: () => document.getElementById('header'), + title: 'Welcome to Inou', + text: '
異能
"extraordinary ability"
Explore medical imaging with AI assistance.

Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com', + pos: 'bottom' + }, + { + target: () => document.querySelector('button[onclick="setPanels(1)"]'), + title: 'Panel Layout', + text: 'Switch between 1, 2, or 3 panels to compare different series side by side.', + pos: 'bottom' + }, + { + target: () => document.getElementById('btn3d'), + title: '3D Crosshair Mode', + text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.', + pos: 'bottom' + }, + { + target: () => document.getElementById('helpBtn'), + title: 'Keyboard Shortcuts', + text: 'Click here for a quick reference of all keyboard and mouse controls.', + pos: 'bottom' + }, + { + target: () => document.querySelector('.panel-content img'), + title: 'Select a Region', + text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.', + pos: 'bottom' + }, + { + target: () => document.getElementById('rectInfo'), + title: 'AI Communication', + text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.', + pos: 'bottom' + } +]; + +let tourIndex = 0; + +function startTour() { + tourIndex = 0; + document.getElementById('tourOverlay').classList.add('show'); + showTourStep(); +} + +function endTour() { + document.getElementById('tourOverlay').classList.remove('show'); + localStorage.setItem('tourSeen', 'true'); +} + +function showTourStep() { + const step = tourSteps[tourIndex]; + const target = step.target(); + if (!target) { nextTourStep(); return; } + + const rect = target.getBoundingClientRect(); + const spotlight = document.getElementById('tourSpotlight'); + const tooltip = document.getElementById('tourTooltip'); + + // Position spotlight + const pad = 8; + spotlight.style.left = (rect.left - pad) + 'px'; + spotlight.style.top = (rect.top - pad) + 'px'; + spotlight.style.width = (rect.width + pad * 2) + 'px'; + spotlight.style.height = (rect.height + pad * 2) + 'px'; + + // Build tooltip + const isLastStep = tourIndex >= tourSteps.length - 1; + tooltip.innerHTML = '

' + step.title + '

' + step.text + '

' + + '
' + + (isLastStep ? '' : '') + + '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' + + '' + + '
'; + + // Position tooltip + const ttWidth = tourIndex === 0 ? 420 : 300; + const ttHeight = tourIndex === 0 ? 280 : 150; + let ttLeft = rect.left + rect.width / 2 - ttWidth / 2; + ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft)); + tooltip.style.left = ttLeft + 'px'; + tooltip.style.width = ttWidth + 'px'; + + // Welcome screen: center vertically + if (tourIndex === 0) { + tooltip.style.top = '50%'; + tooltip.style.transform = 'translateY(-50%)'; + tooltip.style.left = '50%'; + tooltip.style.marginLeft = (-ttWidth / 2) + 'px'; + return; + } + tooltip.style.transform = 'none'; + tooltip.style.marginLeft = '0'; + + // Determine best vertical position + const spaceBelow = window.innerHeight - rect.bottom - 20; + const spaceAbove = rect.top - 20; + const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove; + + tooltip.style.bottom = 'auto'; + tooltip.style.top = 'auto'; + + if (placeBelow) { + let ttTop = rect.bottom + 15; + ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20); + tooltip.style.top = ttTop + 'px'; + } else { + let ttTop = rect.top - ttHeight - 15; + ttTop = Math.max(10, ttTop); + tooltip.style.top = ttTop + 'px'; + } +} + +function nextTourStep() { + tourIndex++; + if (tourIndex >= tourSteps.length) { + endTour(); + } else { + showTourStep(); + } +} + +// Pan state +let isPanning = false; +let panStartMouseX = 0, panStartMouseY = 0; +let panStartPanX = 0, panStartPanY = 0; +let panOrientation = null; + +function getCurrentImageRef(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return ''; + const slice = panel.slices[panel.currentSlice]; + const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown'; + const study = studies.find(s => s.id == document.getElementById('studySelect').value); + const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown'; + return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number; +} + +async function init() { + // Create W/L hint element + const wlHint = document.createElement('div'); + wlHint.id = 'wlHint'; + wlHint.textContent = 'Image updates after 0.3s'; + document.body.appendChild(wlHint); + + // Extract token from URL for subsequent API calls + const params = new URLSearchParams(window.location.search); + tokenParam = params.get('token') || ''; + + const res = await fetch(addToken('/api/studies')); + studies = await res.json(); + const sel = document.getElementById('studySelect'); + studies.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.study_date + ' - ' + s.study_desc; + sel.appendChild(opt); + }); + + if (studies.length > 0) sel.selectedIndex = 0; + + // Deep link by study/series GUID + const urlStudy = params.get('study'); + const urlSeries = params.get('series'); + + if (urlStudy) { + const idx = studies.findIndex(s => s.id === urlStudy); + if (idx >= 0) sel.selectedIndex = idx; + } + + if (studies.length > 0) { + await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise + if (urlSeries && seriesList.length > 0) { + const idx = seriesList.findIndex(s => s.id === urlSeries); + if (idx >= 0 && panels[0]) { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option + await loadSeries(0, seriesList[idx].id); + } + } + } + + // Auto-start tour for first-time users + if (!localStorage.getItem('tourSeen')) { + setTimeout(startTour, 800); + } +} + +async function addPanelEmpty() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); +} + +async function loadStudy(numPanels = 2) { + const studyId = document.getElementById('studySelect').value; + const res = await fetch(addToken('/api/series?study=' + studyId)); + seriesList = await res.json(); + // Fetch study info for overlay + const infoRes = await fetch(addToken("/api/studies?study=" + studyId)); + studyInfo = await infoRes.json(); + is3DMode = false; + + // Smart UI: show/hide elements based on series count + const seriesCount = seriesList.length; + const btn2panels = document.getElementById('btn2panels'); + const btn3panels = document.getElementById('btn3panels'); + const btn3d = document.getElementById('btn3d'); + const syncLabel = document.getElementById('syncLabel'); + + if (seriesCount === 1) { + // Single series: hide multi-panel options, 3D, sync + btn2panels.style.display = 'none'; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + numPanels = 1; // Force single panel + } else if (seriesCount === 2) { + // Two series: hide 3-panel, 3D, sync + btn2panels.style.display = ''; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + if (numPanels > 2) numPanels = 2; + } else { + // 3+ series: show all, check 3D availability + btn2panels.style.display = ''; + btn3panels.style.display = ''; + btn3d.style.display = ''; + syncLabel.style.display = ''; + + // Check if 3D mode is available (has SAG, AX, and COR) + const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG')); + const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX')); + const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR')); + btn3d.disabled = !(hasSag && hasAx && hasCor); + btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode'; + } + + setPanels(numPanels); +} + +async function set3DMode() { + const studyId = document.getElementById('studySelect').value; + if (!studyId) return; + + is3DMode = true; + document.getElementById('syncScroll').checked = false; + + // Fetch series for each orientation + const [sagRes, axRes, corRes] = await Promise.all([ + fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=COR')) + ]); + seriesListByOrientation.SAG = await sagRes.json() || []; + seriesListByOrientation.AX = await axRes.json() || []; + seriesListByOrientation.COR = await corRes.json() || []; + + // Clear and create 3 panels + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + + await add3DPanel(0, 'SAG', seriesListByOrientation.SAG); + await add3DPanel(1, 'AX', seriesListByOrientation.AX); + await add3DPanel(2, 'COR', seriesListByOrientation.COR); +} + +function pickBestSeries(seriesOptions) { + if (!seriesOptions || !seriesOptions.length) return null; + // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc) + let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc)); + if (t1plus) return t1plus.id; + // Then T2 + let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc)); + if (t2) return t2.id; + // Then T1 (without contrast) + let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc)); + if (t1) return t1.id; + // Fallback to first + return seriesOptions[0].id; +} + +async function add3DPanel(idx, orientation, seriesOptions) { + panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + div.innerHTML = + '
' + + '' + orientation + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + + // Auto-select best series (T1+ > T2 > T1 > first) + const bestSeriesId = pickBestSeries(seriesOptions); + if (bestSeriesId) { + div.querySelector('select').value = bestSeriesId; + await loadSeries(idx, bestSeriesId); + } +} + +function setPanels(count) { + is3DMode = false; + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + for (let i = 0; i < count; i++) { + addPanel(); + } +} + +function getImageCoords(e, img) { + const rect = img.getBoundingClientRect(); + const scaleX = img.naturalWidth / rect.width; + const scaleY = img.naturalHeight / rect.height; + const x = Math.round((e.clientX - rect.left) * scaleX); + const y = Math.round((e.clientY - rect.top) * scaleY); + return { x, y, rect, scaleX, scaleY }; +} + +function getPanelOrientation(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return 'AX'; + // In 3D mode, orientation is stored on panel + if (panel.orientation) return panel.orientation; + // Otherwise, derive from series description + const series = seriesList.find(s => s.id == panel.seriesId); + if (series) { + const desc = series.series_desc.toUpperCase(); + if (desc.includes('SAG')) return 'SAG'; + if (desc.includes('COR')) return 'COR'; + } + return 'AX'; // default +} + +function applyZoom(orientation) { + const state = zoomState[orientation]; + const zoom = zoomLevels[state.level]; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (!div) return; + const wrapper = div.querySelector('.img-wrapper'); + const content = div.querySelector('.panel-content'); + wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)'; + content.classList.toggle('zoomed', state.level > 0); + } + }); +} + +function zoomIn(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level < zoomLevels.length - 1) { + state.level++; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function zoomOut(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level > 0) { + state.level--; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function resetZoom(orientation) { + const state = zoomState[orientation]; + state.level = 0; + state.panX = 0; + state.panY = 0; + applyZoom(orientation); +} + +async function addPanel() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const wrapper = div.querySelector('.img-wrapper'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + // Update coords in image space + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + // Mouse down - start drawing + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + // Mouse up - finish drawing + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + if (idx < seriesList.length) { + // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts + const preferred = pickDefaultSeries(seriesList, idx); + if (preferred) { + const select = div.querySelector('select'); + if (select) select.value = preferred.id; + loadSeries(idx, preferred.id); + } + } +} + +function pickDefaultSeries(series, panelIdx) { + // Score each series - lower is better + const scored = series.map(s => { + const desc = s.series_desc.toUpperCase(); + let score = 100; + + // Strongly prefer structural sequences + if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50; + if (desc.includes('T1')) score -= 40; + if (desc.includes('FLAIR')) score -= 35; + + // Prefer axial for comparison + if (desc.includes('AX')) score -= 20; + + // Avoid diffusion/DWI/DTI + if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100; + if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80; + if (desc.includes('ADC') || desc.includes('TRACE')) score += 60; + + // Prefer moderate slice counts (20-50 is typical for structural) + if (s.slice_count > 200) score += 50; + if (s.slice_count > 500) score += 50; + + return { ...s, score }; + }); + + // Sort by score + scored.sort((a, b) => a.score - b.score); + + // For panel 0, pick best. For panel 1+, pick next best with SAME orientation + if (panelIdx === 0) { + return scored[0]; + } else { + // Get orientation of first panel's pick + const firstPick = scored[0]; + const firstDesc = firstPick.series_desc.toUpperCase(); + let firstOrientation = 'AX'; + if (firstDesc.includes('SAG')) firstOrientation = 'SAG'; + else if (firstDesc.includes('COR')) firstOrientation = 'COR'; + + // Find next best with same orientation (excluding first pick) + const sameOrientation = scored.filter(s => { + if (s.id === firstPick.id) return false; + const desc = s.series_desc.toUpperCase(); + if (firstOrientation === 'SAG') return desc.includes('SAG'); + if (firstOrientation === 'COR') return desc.includes('COR'); + return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR')); + }); + + + return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0]; + } +} + +function showRectFromInput() { + const input = document.getElementById('rectCoords').value; + const debug = document.getElementById('debugInfo'); + debug.textContent = 'Parsing: ' + input; + + // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)" + const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/); + if (fullMatch) { + const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch; + debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum; + + // Find matching study + const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim())); + if (!study) { + debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim(); + debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', '); + return; + } + debug.textContent = 'Found study id=' + study.id; + + document.getElementById('studySelect').value = study.id; + fetch(addToken('/api/series?study=' + study.id)) + .then(res => res.json()) + .then(series => { + seriesList = series; + debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', '); + const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim()); + if (!targetSeries) { + debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"'; + return; + } + debug.textContent = 'Found series id=' + targetSeries.id; + setPanels(1); + setTimeout(() => { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.value = targetSeries.id; + loadSeries(0, targetSeries.id).then(() => { + const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum)); + debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum; + if (sliceIdx >= 0) { + goToSlice(0, sliceIdx); + setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100); + } + }); + }, 50); + }); + return; + } + + debug.textContent = 'No full match, trying coords only...'; + // Fallback: just coordinates + const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/); + if (!match) { + debug.textContent = 'No coord match either'; + return; + } + + const x1 = parseInt(match[1]), y1 = parseInt(match[2]); + const x2 = parseInt(match[3]), y2 = parseInt(match[4]); + debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2; + + const panelIdx = activePanel !== null ? activePanel : 0; + drawRect(panelIdx, x1, y1, x2, y2); +} + +function drawRect(panelIdx, x1, y1, x2, y2) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + + const img = panel.querySelector('.panel-content img'); + const rectOverlay = panel.querySelector('.rect-overlay'); + + const rect = img.getBoundingClientRect(); + // Divide out zoom since rect overlay is inside the transformed wrapper + const orientation = getPanelOrientation(panelIdx); + const zoom = zoomLevels[zoomState[orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + rectOverlay.style.left = (x1 * scaleX) + 'px'; + rectOverlay.style.top = (y1 * scaleY) + 'px'; + rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px'; + rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px'; + rectOverlay.style.display = 'block'; + + currentRect = { panelIdx, x1, y1, x2, y2 }; + activePanel = panelIdx; +} + +function copyCoords() { + const input = document.getElementById('rectCoords'); + input.select(); + document.execCommand('copy'); +} + +function clearRect() { + document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none'); + document.getElementById('rectCoords').value = ''; + currentRect = null; +} + +async function loadSeries(panelIdx, seriesId) { + if (!seriesId) return; + const res = await fetch(addToken('/api/slices?series=' + seriesId)); + const slices = await res.json(); + panels[panelIdx].seriesId = seriesId; + panels[panelIdx].slices = slices; + panels[panelIdx].currentSlice = 0; + initWLState(seriesId, slices); + + const panel = document.getElementById('panel-' + panelIdx); + const thumbs = panel.querySelector('.thumbnails'); + + // Create W/L presets + scrubber + const midSliceId = slices[Math.floor(slices.length / 2)]?.id; + const presetsHtml = wlPresets.map((p, i) => + '
' + + '' + + '' + p.name + '
' + ).join(''); + + const scrubberHtml = + '
' + + '
Slice 1 / ' + slices.length + '
' + + '
' + + '
' + + '
' + + '
' + + '
1' + slices.length + '
' + + '
'; + + thumbs.innerHTML = '
' + presetsHtml + '
' + scrubberHtml; + + // Setup scrubber interaction + setupScrubber(panelIdx); + + // Preload all slice images for smooth scrolling + slices.forEach(s => { + const img = new Image(); + img.src = getImageUrlWithWL(s.id, seriesId, null, null); + }); + + // Start at middle slice + const midSlice = Math.floor(slices.length / 2); + goToSlice(panelIdx, midSlice); +} + +function update3DCrosshairs() { + if (!is3DMode) return; + + const getData = (p) => { + if (!p || !p.slices.length) return null; + const s = p.slices[p.currentSlice]; + // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz" + let rowVec = [1,0,0], colVec = [0,1,0]; + if (s.image_orientation) { + const parts = s.image_orientation.split('\\').map(Number); + if (parts.length === 6) { + rowVec = [parts[0], parts[1], parts[2]]; + colVec = [parts[3], parts[4], parts[5]]; + } + } + + // Compute CENTER of slice (not corner) + const psRow = s.pixel_spacing_row || 0.5; + const psCol = s.pixel_spacing_col || 0.5; + const halfWidth = (s.cols / 2) * psCol; + const halfHeight = (s.rows / 2) * psRow; + + const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0]; + const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1]; + const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2]; + + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + slice_loc: s.slice_location, + center_x: centerX, + center_y: centerY, + center_z: centerZ, + rows: s.rows, + cols: s.cols, + psRow: psRow, + psCol: psCol, + rowVec: rowVec, + colVec: colVec + }; + }; + + // Get reference data from FIRST slice of each series (fixed reference frame) + const getRefData = (panel) => { + if (!panel || !panel.slices || !panel.slices.length) return null; + const s = panel.slices[0]; // Use first slice as reference + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + rows: s.rows, + cols: s.cols, + psRow: s.pixel_spacing_row, + psCol: s.pixel_spacing_col + }; + }; + + const sagPanel = panels.find(p => p.orientation === 'SAG'); + const axPanel = panels.find(p => p.orientation === 'AX'); + const corPanel = panels.find(p => p.orientation === 'COR'); + + // Current slice_location from each panel (through-plane position) + const sagLoc = sagPanel?.slices[sagPanel.currentSlice]?.slice_location || 0; // X + const axLoc = axPanel?.slices[axPanel.currentSlice]?.slice_location || 0; // Z + const corLoc = corPanel?.slices[corPanel.currentSlice]?.slice_location || 0; // Y + + // Reference frames (first slice of each series) + const sagRef = getRefData(sagPanel); + const axRef = getRefData(axPanel); + const corRef = getRefData(corPanel); + + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + if (!p.slices.length || !p.orientation) return; + + const div = document.getElementById('panel-' + idx); + const img = div.querySelector('.panel-content img'); + const hLine = div.querySelector('.crosshair-h'); + const vLine = div.querySelector('.crosshair-v'); + + console.log("Panel " + idx + ": naturalWidth=" + img.naturalWidth); + if (!img.naturalWidth) { + hLine.style.display = 'none'; + vLine.style.display = 'none'; + return; + } + + // DEBUG: Fixed position at top-left corner + hLine.style.top = '0px'; + hLine.style.display = 'block'; + vLine.style.left = '0px'; + vLine.style.display = 'block'; + }); +} + +function goToSlice(panelIdx, sliceIdx) { + const panel = panels[panelIdx]; + if (!panel.slices.length) return; + panel.currentSlice = sliceIdx; + + const div = document.getElementById('panel-' + panelIdx); + const img = div.querySelector('.panel-content img'); + img.onload = () => detectImageBrightness(img, panelIdx); + img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId); + + // Clear rectangle when changing slice + div.querySelector('.rect-overlay').style.display = 'none'; + + div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx)); + + // Update scrubber position + updateScrubber(panelIdx, sliceIdx); + + updateOverlay(panelIdx); + + // Update crosshairs in 3D mode + if (is3DMode) { + setTimeout(update3DCrosshairs, 50); + } + + if (document.getElementById('syncScroll').checked && !is3DMode) { + const loc = panel.slices[sliceIdx].slice_location; + panels.forEach((p, i) => { + if (i !== panelIdx && p.slices.length) { + const closest = p.slices.reduce((prev, curr, idx) => + Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0); + if (p.currentSlice !== closest) { + p.currentSlice = closest; + const pDiv = document.getElementById('panel-' + i); + pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId); + pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest)); + updateOverlay(i); + } + } + }); + } +} + +// Track hovered panel for keyboard zoom +document.addEventListener('mousemove', (e) => { + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + hoveredPanel = idx; + } + } + }); +}, { passive: true }); + +document.addEventListener('wheel', e => { + if (!panels.length) return; + + // Find which panel the mouse is over + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + + if (targetPanel < 0) return; + hoveredPanel = targetPanel; + + // Track cursor relative to wrapper (for zoom-to-cursor) + // Account for current zoom since getBoundingClientRect returns transformed bounds + const div = document.getElementById('panel-' + targetPanel); + const wrapper = div.querySelector('.img-wrapper'); + const wrapperRect = wrapper.getBoundingClientRect(); + const orientation = getPanelOrientation(targetPanel); + const currentZoom = zoomLevels[zoomState[orientation].level]; + cursorX = (e.clientX - wrapperRect.left) / currentZoom; + cursorY = (e.clientY - wrapperRect.top) / currentZoom; + + // Shift+wheel = zoom + if (e.shiftKey) { + e.preventDefault(); + const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) { + zoomIn(targetPanel); + } else if (delta > 0) { + zoomOut(targetPanel); + } + return; + } + + // Regular wheel = scroll slices + const delta = e.deltaY > 0 ? 1 : -1; + const p = panels[targetPanel]; + if (!p.slices.length) return; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx); +}, { passive: false }); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (document.getElementById('tourOverlay').classList.contains('show')) { + endTour(); + } else if (document.getElementById('helpModal').classList.contains('show')) { + toggleHelp(); + } else { + clearRect(); + } + return; + } + if (!panels.length) return; + + // +/- for zoom (affects hovered panel's orientation group) + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + zoomIn(hoveredPanel); + return; + } + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + zoomOut(hoveredPanel); + return; + } + + // Arrow keys for slice navigation + let delta = 0; + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1; + if (delta === 0) return; + e.preventDefault(); + const p = panels[0]; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(0, newIdx); +}); + +// Cancel drawing if mouse leaves window +document.addEventListener('mouseup', (e) => { + isDrawing = false; + if (isPanning) { + isPanning = false; + // Restore transition + document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = ''); + document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning')); + } +}); + +// Shift+click pan +document.addEventListener('mousedown', (e) => { + if (e.button !== 0 || !e.shiftKey) return; + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + const state = zoomState[orientation]; + if (state.level === 0) return; // no pan at 1x zoom + + isPanning = true; + panOrientation = orientation; + panStartMouseX = e.clientX; + panStartMouseY = e.clientY; + panStartPanX = state.panX; + panStartPanY = state.panY; + // Disable transition during pan for smooth movement + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (div) div.querySelector('.img-wrapper').style.transition = 'none'; + } + }); + document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning')); +}); + +document.addEventListener('mousemove', (e) => { + if (!isPanning || !panOrientation) return; + + const state = zoomState[panOrientation]; + const zoom = zoomLevels[state.level]; + // With transform: scale(zoom) translate(panX, panY), translate values are scaled + // Divide by zoom for 1:1 screen-to-image movement + const dx = (e.clientX - panStartMouseX) / zoom; + const dy = (e.clientY - panStartMouseY) / zoom; + state.panX = panStartPanX + dx; + state.panY = panStartPanY + dy; + applyZoom(panOrientation); +}); + +// Double-click to reset zoom +document.addEventListener('dblclick', (e) => { + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + resetZoom(orientation); +}); + +// Ctrl+click for Window/Level adjustment +document.addEventListener("mousedown", (e) => { + if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const panel = panels[targetPanel]; + if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return; + + isAdjustingWL = true; + isDrawing = false; // Prevent rect drawing + document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none"); + wlPanel = targetPanel; + wlStartX = e.clientX; + wlStartY = e.clientY; + wlStartWc = wlState[panel.seriesId].wc; + wlStartWw = wlState[panel.seriesId].ww; + document.body.style.cursor = "crosshair"; + + // Show hint + const hint = document.getElementById('wlHint'); + hint.style.left = (e.clientX + 15) + 'px'; + hint.style.top = (e.clientY - 10) + 'px'; + hint.classList.add('show'); +}); + +document.addEventListener("mousemove", (e) => { + if (!isAdjustingWL || wlPanel < 0) return; + + const panel = panels[wlPanel]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (!state) return; + + // Horizontal = width, Vertical = center + const dx = e.clientX - wlStartX; + const dy = e.clientY - wlStartY; + + state.ww = Math.max(1, wlStartWw + dx * 2); + state.wc = wlStartWc - dy * 2; // invert: drag up = brighter + state.adjusted = true; + + // Update overlay C/W values in real-time + const div = document.getElementById("panel-" + wlPanel); + const wcEl = div.querySelector(".overlay-wc"); + const wwEl = div.querySelector(".overlay-ww"); + if (wcEl) wcEl.textContent = Math.round(state.wc); + if (wwEl) wwEl.textContent = Math.round(state.ww); + + // Debounce image reload + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + wlDebounceTimer = setTimeout(() => { + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + }, 150); +}); + +document.addEventListener("mouseup", (e) => { + if (isAdjustingWL) { + isAdjustingWL = false; + document.body.style.cursor = ""; + document.getElementById('wlHint').classList.remove('show'); + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + if (wlPanel >= 0) { + reloadPanelImages(wlPanel); + } + wlPanel = -1; + } +}); + +// Track right-click for double-click detection +let lastRightClickTime = 0; +let lastRightClickPanel = -1; + +// Double right-click to reset Window/Level +document.addEventListener("mousedown", (e) => { + if (e.button !== 2) return; + + let targetPanel = -1; + panels.forEach((p, idx) => { + console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const now = Date.now(); + if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) { + // Double right-click detected - reset W/L + resetWL(targetPanel); + lastRightClickTime = 0; + lastRightClickPanel = -1; + e.preventDefault(); + return; + } + lastRightClickTime = now; + lastRightClickPanel = targetPanel; +}); + +// Update crosshairs on window resize +// Prevent context menu on panels for right-click W/L adjustment +document.addEventListener("contextmenu", (e) => { + if (!e.target.closest("#panels")) return; + e.preventDefault(); +}); + +window.addEventListener('resize', () => { + if (is3DMode) update3DCrosshairs(); +}); + +// W/L Preset functions +function applyWLPreset(el) { + const panelIdx = parseInt(el.dataset.panel); + const wcAttr = el.dataset.wc; + const wwAttr = el.dataset.ww; + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + + // Update wlState - null means reset to original + if (wcAttr === 'null' || wwAttr === 'null') { + wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc; + wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw; + wlState[panel.seriesId].adjusted = false; + } else { + wlState[panel.seriesId].wc = parseInt(wcAttr); + wlState[panel.seriesId].ww = parseInt(wwAttr); + wlState[panel.seriesId].adjusted = true; + } + + // Update active preset + const container = el.closest('.thumbnails'); + container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active')); + el.classList.add('active'); + + // Reload image + reloadPanelImages(panelIdx); +} + +function setupScrubber(panelIdx) { + const panel = document.getElementById('panel-' + panelIdx); + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const track = scrubber.querySelector('.scrubber-track'); + let isDragging = false; + + const updateFromPosition = (e) => { + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const pct = x / rect.width; + const sliceCount = panels[panelIdx].slices.length; + const sliceIdx = Math.round(pct * (sliceCount - 1)); + goToSlice(panelIdx, sliceIdx); + }; + + track.addEventListener('mousedown', (e) => { + isDragging = true; + updateFromPosition(e); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (isDragging) updateFromPosition(e); + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); +} + +function updateScrubber(panelIdx, sliceIdx) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const sliceCount = panels[panelIdx].slices.length; + const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0; + + const fill = scrubber.querySelector('.scrubber-fill'); + const handle = scrubber.querySelector('.scrubber-handle'); + const current = scrubber.querySelector('.scrubber-current'); + + if (fill) fill.style.width = pct + '%'; + if (handle) handle.style.left = pct + '%'; + if (current) current.textContent = sliceIdx + 1; +} + +init(); diff --git a/static/viewer_js b/static/viewer_js new file mode 100644 index 0000000..e439fca --- /dev/null +++ b/static/viewer_js @@ -0,0 +1,1646 @@ +let studies = []; +let currentStudy = null; +let studyInfo = {}; +let seriesList = []; +let panels = []; +let panelCount = 0; +let is3DMode = false; +let seriesListByOrientation = { SAG: [], AX: [], COR: [] }; +let tokenParam = ''; // Will be set from URL if present + +// W/L presets for common viewing windows +const wlPresets = [ + { name: 'Default', wc: null, ww: null }, + { name: 'Brain', wc: 40, ww: 80 }, + { name: 'Subdural', wc: 80, ww: 200 }, + { name: 'Bone', wc: 500, ww: 2000 }, + { name: 'Stroke', wc: 40, ww: 40 }, + { name: 'Soft', wc: 50, ww: 400 } +]; + +// Detect if image background is light (for overlay color adjustment) +function detectImageBrightness(img, panelIdx) { + const div = document.getElementById('panel-' + panelIdx); + if (!div || !img.complete || !img.naturalWidth) return; + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const sampleSize = 50; + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Sample top-left corner (where overlay text appears) + ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize); + const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data; + + let total = 0; + for (let i = 0; i < data.length; i += 4) { + total += (data[i] + data[i+1] + data[i+2]) / 3; + } + const avgBrightness = total / (data.length / 4); + + // Toggle light-bg class based on brightness threshold + div.classList.toggle('light-bg', avgBrightness > 160); +} + +function addToken(url) { + if (!tokenParam) return url; + return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam; +} + +// Rectangle drawing state +let isDrawing = false; +let startX = 0, startY = 0; +let currentRect = null; +let activePanel = null; + +// Window/Level adjustment state +let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw } +let isAdjustingWL = false; +let wlStartX = 0, wlStartY = 0; +let wlStartWc = 0, wlStartWw = 0; +let wlDebounceTimer = null; +let wlPanel = -1; + +function getImageUrl(sliceId, seriesId) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) { + params.push("wc=" + Math.round(wlState[seriesId].wc)); + params.push("ww=" + Math.round(wlState[seriesId].ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function getImageUrlWithWL(sliceId, seriesId, wc, ww) { + let url = "/image/" + sliceId; + const params = []; + if (tokenParam) params.push("token=" + tokenParam); + if (wc !== null && ww !== null) { + params.push("wc=" + Math.round(wc)); + params.push("ww=" + Math.round(ww)); + } + if (params.length) url += "?" + params.join("&"); + return url; +} + +function initWLState(seriesId, slices) { + if (!wlState[seriesId] && slices.length > 0) { + const s = slices[0]; + wlState[seriesId] = { adjusted: false, + wc: s.window_center || 128, + ww: s.window_width || 256, + originalWc: s.window_center || 128, + originalWw: s.window_width || 256 + }; + } +} + +function resetWL(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (state) { + state.wc = state.originalWc; + state.ww = state.originalWw; + state.adjusted = false; + reloadPanelImages(panelIdx); + } +} + +function reloadPanelImages(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return; + const div = document.getElementById("panel-" + panelIdx); + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + // Reload thumbnails too + const thumbs = div.querySelectorAll(".thumb"); + thumbs.forEach((t, i) => { + t.src = getImageUrl(panel.slices[i].id, panel.seriesId); + }); + updateOverlay(panelIdx); +} + +function updateOverlay(panelIdx) { + const panel = panels[panelIdx]; + if (!panel) return; + const div = document.getElementById("panel-" + panelIdx); + if (!div) return; + + // Get series info + const series = seriesList.find(s => s.id === panel.seriesId) || + (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null); + const seriesName = series ? series.series_desc : ""; + + // Get slice info + const slice = panel.slices[panel.currentSlice]; + + // Get W/L info + let wc = "", ww = ""; + let adjusted = false; + if (panel.seriesId && wlState[panel.seriesId]) { + const state = wlState[panel.seriesId]; + if (state.adjusted) { + wc = Math.round(state.wc); + ww = Math.round(state.ww); + adjusted = true; + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + } else if (slice) { + wc = Math.round(slice.window_center || 0); + ww = Math.round(slice.window_width || 0); + } + + // Get zoom level + const orientation = panel.orientation || "AX"; + const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100; + + // Update all overlay elements + const q = s => div.querySelector(s); + + // Top left - patient/study info + if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " "); + if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : ""; + if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || ""; + if (q(".overlay-series")) q(".overlay-series").textContent = seriesName; + if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : ""; + if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : ""; + + // Top right - technical info + if (q(".overlay-datetime")) { + let dt = ""; + if (studyInfo.study_date) { + dt = studyInfo.study_date; + if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4); + } + q(".overlay-datetime").textContent = dt; + } + if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || ""; + if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : ""; + if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : ""; + + const wcEl = q(".overlay-wc"); + const wwEl = q(".overlay-ww"); + if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); } + if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); } + + if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : ""; + + // Orientation markers based on image_orientation + updateOrientationMarkers(div, slice, orientation); +} + +function updateOrientationMarkers(div, slice, orientationType) { + const left = div.querySelector(".overlay-orient-left"); + const right = div.querySelector(".overlay-orient-right"); + const top = div.querySelector(".overlay-orient-top"); + const bottom = div.querySelector(".overlay-orient-bottom"); + + // Default markers based on orientation type + let markers = { left: "", right: "", top: "", bottom: "" }; + + if (orientationType === "AX") { + markers = { left: "R", right: "L", top: "A", bottom: "P" }; + } else if (orientationType === "SAG") { + markers = { left: "A", right: "P", top: "S", bottom: "I" }; + } else if (orientationType === "COR") { + markers = { left: "R", right: "L", top: "S", bottom: "I" }; + } + + // TODO: Parse image_orientation DICOM tag for exact orientation if needed + + if (left) left.textContent = markers.left; + if (right) right.textContent = markers.right; + if (top) top.textContent = markers.top; + if (bottom) bottom.textContent = markers.bottom; +} + +// Zoom state - shared by orientation type +const zoomLevels = [1, 1.5, 2, 3, 4]; +let zoomState = { + AX: { level: 0, panX: 0, panY: 0 }, + SAG: { level: 0, panX: 0, panY: 0 }, + COR: { level: 0, panX: 0, panY: 0 } +}; +let hoveredPanel = 0; +let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper +let scrollAccumulator = 0; // for slower slice scrolling + +function toggleHelp() { + document.getElementById('helpModal').classList.toggle('show'); +} + +// Tour functionality +const tourSteps = [ + { + target: () => document.getElementById('header'), + title: 'Welcome to Inou', + text: '
異能
"extraordinary ability"
Explore medical imaging with AI assistance.

Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com', + pos: 'bottom' + }, + { + target: () => document.querySelector('button[onclick="setPanels(1)"]'), + title: 'Panel Layout', + text: 'Switch between 1, 2, or 3 panels to compare different series side by side.', + pos: 'bottom' + }, + { + target: () => document.getElementById('btn3d'), + title: '3D Crosshair Mode', + text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.', + pos: 'bottom' + }, + { + target: () => document.getElementById('helpBtn'), + title: 'Keyboard Shortcuts', + text: 'Click here for a quick reference of all keyboard and mouse controls.', + pos: 'bottom' + }, + { + target: () => document.querySelector('.panel-content img'), + title: 'Select a Region', + text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.', + pos: 'bottom' + }, + { + target: () => document.getElementById('rectInfo'), + title: 'AI Communication', + text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.', + pos: 'bottom' + } +]; + +let tourIndex = 0; + +function startTour() { + tourIndex = 0; + document.getElementById('tourOverlay').classList.add('show'); + showTourStep(); +} + +function endTour() { + document.getElementById('tourOverlay').classList.remove('show'); + localStorage.setItem('tourSeen', 'true'); +} + +function showTourStep() { + const step = tourSteps[tourIndex]; + const target = step.target(); + if (!target) { nextTourStep(); return; } + + const rect = target.getBoundingClientRect(); + const spotlight = document.getElementById('tourSpotlight'); + const tooltip = document.getElementById('tourTooltip'); + + // Position spotlight + const pad = 8; + spotlight.style.left = (rect.left - pad) + 'px'; + spotlight.style.top = (rect.top - pad) + 'px'; + spotlight.style.width = (rect.width + pad * 2) + 'px'; + spotlight.style.height = (rect.height + pad * 2) + 'px'; + + // Build tooltip + const isLastStep = tourIndex >= tourSteps.length - 1; + tooltip.innerHTML = '

' + step.title + '

' + step.text + '

' + + '
' + + (isLastStep ? '' : '') + + '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' + + '' + + '
'; + + // Position tooltip + const ttWidth = tourIndex === 0 ? 420 : 300; + const ttHeight = tourIndex === 0 ? 280 : 150; + let ttLeft = rect.left + rect.width / 2 - ttWidth / 2; + ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft)); + tooltip.style.left = ttLeft + 'px'; + tooltip.style.width = ttWidth + 'px'; + + // Welcome screen: center vertically + if (tourIndex === 0) { + tooltip.style.top = '50%'; + tooltip.style.transform = 'translateY(-50%)'; + tooltip.style.left = '50%'; + tooltip.style.marginLeft = (-ttWidth / 2) + 'px'; + return; + } + tooltip.style.transform = 'none'; + tooltip.style.marginLeft = '0'; + + // Determine best vertical position + const spaceBelow = window.innerHeight - rect.bottom - 20; + const spaceAbove = rect.top - 20; + const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove; + + tooltip.style.bottom = 'auto'; + tooltip.style.top = 'auto'; + + if (placeBelow) { + let ttTop = rect.bottom + 15; + ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20); + tooltip.style.top = ttTop + 'px'; + } else { + let ttTop = rect.top - ttHeight - 15; + ttTop = Math.max(10, ttTop); + tooltip.style.top = ttTop + 'px'; + } +} + +function nextTourStep() { + tourIndex++; + if (tourIndex >= tourSteps.length) { + endTour(); + } else { + showTourStep(); + } +} + +// Pan state +let isPanning = false; +let panStartMouseX = 0, panStartMouseY = 0; +let panStartPanX = 0, panStartPanY = 0; +let panOrientation = null; + +function getCurrentImageRef(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return ''; + const slice = panel.slices[panel.currentSlice]; + const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown'; + const study = studies.find(s => s.id == document.getElementById('studySelect').value); + const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown'; + return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number; +} + +async function init() { + // Create W/L hint element + const wlHint = document.createElement('div'); + wlHint.id = 'wlHint'; + wlHint.textContent = 'Image updates after 0.3s'; + document.body.appendChild(wlHint); + + // Extract token from URL for subsequent API calls + const params = new URLSearchParams(window.location.search); + tokenParam = params.get('token') || ''; + + const res = await fetch(addToken('/api/studies')); + studies = await res.json(); + const sel = document.getElementById('studySelect'); + studies.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.study_date + ' - ' + s.study_desc; + sel.appendChild(opt); + }); + + if (studies.length > 0) sel.selectedIndex = 0; + + // Deep link by study/series GUID + const urlStudy = params.get('study'); + const urlSeries = params.get('series'); + + if (urlStudy) { + const idx = studies.findIndex(s => s.id === urlStudy); + if (idx >= 0) sel.selectedIndex = idx; + } + + if (studies.length > 0) { + await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise + if (urlSeries && seriesList.length > 0) { + const idx = seriesList.findIndex(s => s.id === urlSeries); + if (idx >= 0 && panels[0]) { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option + await loadSeries(0, seriesList[idx].id); + } + } + } + + // Auto-start tour for first-time users + if (!localStorage.getItem('tourSeen')) { + setTimeout(startTour, 800); + } +} + +async function addPanelEmpty() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); +} + +async function loadStudy(numPanels = 2) { + const studyId = document.getElementById('studySelect').value; + const res = await fetch(addToken('/api/series?study=' + studyId)); + seriesList = await res.json(); + // Fetch study info for overlay + const infoRes = await fetch(addToken("/api/studies?study=" + studyId)); + studyInfo = await infoRes.json(); + is3DMode = false; + + // Smart UI: show/hide elements based on series count + const seriesCount = seriesList.length; + const btn2panels = document.getElementById('btn2panels'); + const btn3panels = document.getElementById('btn3panels'); + const btn3d = document.getElementById('btn3d'); + const syncLabel = document.getElementById('syncLabel'); + + if (seriesCount === 1) { + // Single series: hide multi-panel options, 3D, sync + btn2panels.style.display = 'none'; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + numPanels = 1; // Force single panel + } else if (seriesCount === 2) { + // Two series: hide 3-panel, 3D, sync + btn2panels.style.display = ''; + btn3panels.style.display = 'none'; + btn3d.style.display = 'none'; + syncLabel.style.display = 'none'; + if (numPanels > 2) numPanels = 2; + } else { + // 3+ series: show all, check 3D availability + btn2panels.style.display = ''; + btn3panels.style.display = ''; + btn3d.style.display = ''; + syncLabel.style.display = ''; + + // Check if 3D mode is available (has SAG, AX, and COR) + const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG')); + const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX')); + const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR')); + btn3d.disabled = !(hasSag && hasAx && hasCor); + btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode'; + } + + setPanels(numPanels); +} + +async function set3DMode() { + const studyId = document.getElementById('studySelect').value; + if (!studyId) return; + + is3DMode = true; + document.getElementById('syncScroll').checked = false; + + // Fetch series for each orientation + const [sagRes, axRes, corRes] = await Promise.all([ + fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')), + fetch(addToken('/api/series?study=' + studyId + '&orientation=COR')) + ]); + seriesListByOrientation.SAG = await sagRes.json() || []; + seriesListByOrientation.AX = await axRes.json() || []; + seriesListByOrientation.COR = await corRes.json() || []; + + // Clear and create 3 panels + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + + await add3DPanel(0, 'SAG', seriesListByOrientation.SAG); + await add3DPanel(1, 'AX', seriesListByOrientation.AX); + await add3DPanel(2, 'COR', seriesListByOrientation.COR); +} + +function pickBestSeries(seriesOptions) { + if (!seriesOptions || !seriesOptions.length) return null; + // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc) + let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc)); + if (t1plus) return t1plus.id; + // Then T2 + let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc)); + if (t2) return t2.id; + // Then T1 (without contrast) + let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc)); + if (t1) return t1.id; + // Fallback to first + return seriesOptions[0].id; +} + +async function add3DPanel(idx, orientation, seriesOptions) { + panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + div.innerHTML = + '
' + + '' + orientation + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const rectOverlay = div.querySelector('.rect-overlay'); + + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + + // Auto-select best series (T1+ > T2 > T1 > first) + const bestSeriesId = pickBestSeries(seriesOptions); + if (bestSeriesId) { + div.querySelector('select').value = bestSeriesId; + await loadSeries(idx, bestSeriesId); + } +} + +function setPanels(count) { + is3DMode = false; + document.getElementById('panels').innerHTML = ''; + panels = []; + panelCount = 0; + for (let i = 0; i < count; i++) { + addPanel(); + } +} + +function getImageCoords(e, img) { + const rect = img.getBoundingClientRect(); + const scaleX = img.naturalWidth / rect.width; + const scaleY = img.naturalHeight / rect.height; + const x = Math.round((e.clientX - rect.left) * scaleX); + const y = Math.round((e.clientY - rect.top) * scaleY); + return { x, y, rect, scaleX, scaleY }; +} + +function getPanelOrientation(panelIdx) { + const panel = panels[panelIdx]; + if (!panel || !panel.slices.length) return 'AX'; + // In 3D mode, orientation is stored on panel + if (panel.orientation) return panel.orientation; + // Otherwise, derive from series description + const series = seriesList.find(s => s.id == panel.seriesId); + if (series) { + const desc = series.series_desc.toUpperCase(); + if (desc.includes('SAG')) return 'SAG'; + if (desc.includes('COR')) return 'COR'; + } + return 'AX'; // default +} + +function applyZoom(orientation) { + const state = zoomState[orientation]; + const zoom = zoomLevels[state.level]; + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (!div) return; + const wrapper = div.querySelector('.img-wrapper'); + const content = div.querySelector('.panel-content'); + wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)'; + content.classList.toggle('zoomed', state.level > 0); + } + }); +} + +function zoomIn(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level < zoomLevels.length - 1) { + state.level++; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function zoomOut(panelIdx) { + const orientation = getPanelOrientation(panelIdx); + const state = zoomState[orientation]; + if (state.level > 0) { + state.level--; + // Keep pan at 0 to center the image + state.panX = 0; + state.panY = 0; + applyZoom(orientation); + } +} + +function resetZoom(orientation) { + const state = zoomState[orientation]; + state.level = 0; + state.panX = 0; + state.panY = 0; + applyZoom(orientation); +} + +async function addPanel() { + const idx = panelCount++; + const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; + panels.push(panel); + + const div = document.createElement('div'); + div.className = 'panel'; + div.id = 'panel-' + idx; + + // Series header: show dropdown only if multiple series + let headerContent; + if (seriesList.length === 1) { + headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; + } else { + headerContent = ''; + } + + div.innerHTML = + '
' + headerContent + '
' + + '
C
W
' + + '
'; + document.getElementById('panels').appendChild(div); + + const img = div.querySelector('.panel-content img'); + const wrapper = div.querySelector('.img-wrapper'); + const rectOverlay = div.querySelector('.rect-overlay'); + + // Mouse move - show coordinates + img.addEventListener('mousemove', e => { + const { x, y } = getImageCoords(e, img); + document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; + + if (isDrawing && activePanel === idx) { + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = (e.clientX - rect.left); + const curY = (e.clientY - rect.top); + const sx = Math.min(startX, curX); + const sy = Math.min(startY, curY); + const w = Math.abs(curX - startX); + const h = Math.abs(curY - startY); + rectOverlay.style.left = sx + 'px'; + rectOverlay.style.top = sy + 'px'; + rectOverlay.style.width = w + 'px'; + rectOverlay.style.height = h + 'px'; + rectOverlay.style.display = 'block'; + + // Update coords in image space + const x1 = Math.round(Math.min(startX, curX) * scaleX); + const y1 = Math.round(Math.min(startY, curY) * scaleY); + const x2 = Math.round(Math.max(startX, curX) * scaleX); + const y2 = Math.round(Math.max(startY, curY) * scaleY); + const imgRef = getCurrentImageRef(activePanel); + document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } + }); + + img.addEventListener('mouseleave', () => { + document.getElementById('coordDisplay').textContent = 'x: -, y: -'; + }); + + + // Mouse down - start drawing + img.addEventListener('mousedown', e => { + if (e.button !== 0) return; // Only left-click for drawing + if (e.shiftKey) return; // Shift+drag is for panning + e.preventDefault(); + isDrawing = true; + activePanel = idx; + const rect = img.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + rectOverlay.style.display = 'none'; + }); + + + // Mouse up - finish drawing + img.addEventListener('mouseup', e => { + if (isDrawing && activePanel === idx) { + isDrawing = false; + const { rect, scaleX, scaleY } = getImageCoords(e, img); + const curX = e.clientX - rect.left; + const curY = e.clientY - rect.top; + if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { + currentRect = { + panelIdx: idx, + x1: Math.round(Math.min(startX, curX) * scaleX), + y1: Math.round(Math.min(startY, curY) * scaleY), + x2: Math.round(Math.max(startX, curX) * scaleX), + y2: Math.round(Math.max(startY, curY) * scaleY) + }; + const imgRef = getCurrentImageRef(idx); + document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; + document.getElementById('rectInfo').style.display = 'block'; + } else { + rectOverlay.style.display = 'none'; + } + } + }); + + if (idx < seriesList.length) { + // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts + const preferred = pickDefaultSeries(seriesList, idx); + if (preferred) { + const select = div.querySelector('select'); + if (select) select.value = preferred.id; + loadSeries(idx, preferred.id); + } + } +} + +function pickDefaultSeries(series, panelIdx) { + // Score each series - lower is better + const scored = series.map(s => { + const desc = s.series_desc.toUpperCase(); + let score = 100; + + // Strongly prefer structural sequences + if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50; + if (desc.includes('T1')) score -= 40; + if (desc.includes('FLAIR')) score -= 35; + + // Prefer axial for comparison + if (desc.includes('AX')) score -= 20; + + // Avoid diffusion/DWI/DTI + if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100; + if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80; + if (desc.includes('ADC') || desc.includes('TRACE')) score += 60; + + // Prefer moderate slice counts (20-50 is typical for structural) + if (s.slice_count > 200) score += 50; + if (s.slice_count > 500) score += 50; + + return { ...s, score }; + }); + + // Sort by score + scored.sort((a, b) => a.score - b.score); + + // For panel 0, pick best. For panel 1+, pick next best with SAME orientation + if (panelIdx === 0) { + return scored[0]; + } else { + // Get orientation of first panel's pick + const firstPick = scored[0]; + const firstDesc = firstPick.series_desc.toUpperCase(); + let firstOrientation = 'AX'; + if (firstDesc.includes('SAG')) firstOrientation = 'SAG'; + else if (firstDesc.includes('COR')) firstOrientation = 'COR'; + + // Find next best with same orientation (excluding first pick) + const sameOrientation = scored.filter(s => { + if (s.id === firstPick.id) return false; + const desc = s.series_desc.toUpperCase(); + if (firstOrientation === 'SAG') return desc.includes('SAG'); + if (firstOrientation === 'COR') return desc.includes('COR'); + return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR')); + }); + + + return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0]; + } +} + +function showRectFromInput() { + const input = document.getElementById('rectCoords').value; + const debug = document.getElementById('debugInfo'); + debug.textContent = 'Parsing: ' + input; + + // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)" + const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/); + if (fullMatch) { + const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch; + debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum; + + // Find matching study + const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim())); + if (!study) { + debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim(); + debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', '); + return; + } + debug.textContent = 'Found study id=' + study.id; + + document.getElementById('studySelect').value = study.id; + fetch(addToken('/api/series?study=' + study.id)) + .then(res => res.json()) + .then(series => { + seriesList = series; + debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', '); + const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim()); + if (!targetSeries) { + debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"'; + return; + } + debug.textContent = 'Found series id=' + targetSeries.id; + setPanels(1); + setTimeout(() => { + const panel = document.getElementById('panel-0'); + const select = panel.querySelector('select'); + if (select) select.value = targetSeries.id; + loadSeries(0, targetSeries.id).then(() => { + const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum)); + debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum; + if (sliceIdx >= 0) { + goToSlice(0, sliceIdx); + setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100); + } + }); + }, 50); + }); + return; + } + + debug.textContent = 'No full match, trying coords only...'; + // Fallback: just coordinates + const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/); + if (!match) { + debug.textContent = 'No coord match either'; + return; + } + + const x1 = parseInt(match[1]), y1 = parseInt(match[2]); + const x2 = parseInt(match[3]), y2 = parseInt(match[4]); + debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2; + + const panelIdx = activePanel !== null ? activePanel : 0; + drawRect(panelIdx, x1, y1, x2, y2); +} + +function drawRect(panelIdx, x1, y1, x2, y2) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + + const img = panel.querySelector('.panel-content img'); + const rectOverlay = panel.querySelector('.rect-overlay'); + + const rect = img.getBoundingClientRect(); + // Divide out zoom since rect overlay is inside the transformed wrapper + const orientation = getPanelOrientation(panelIdx); + const zoom = zoomLevels[zoomState[orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + rectOverlay.style.left = (x1 * scaleX) + 'px'; + rectOverlay.style.top = (y1 * scaleY) + 'px'; + rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px'; + rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px'; + rectOverlay.style.display = 'block'; + + currentRect = { panelIdx, x1, y1, x2, y2 }; + activePanel = panelIdx; +} + +function copyCoords() { + const input = document.getElementById('rectCoords'); + input.select(); + document.execCommand('copy'); +} + +function clearRect() { + document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none'); + document.getElementById('rectCoords').value = ''; + currentRect = null; +} + +async function loadSeries(panelIdx, seriesId) { + if (!seriesId) return; + const res = await fetch(addToken('/api/slices?series=' + seriesId + '&thumbs=false')); + const data = await res.json(); + const slices = data.slices || data; + panels[panelIdx].seriesId = seriesId; + panels[panelIdx].slices = slices; + panels[panelIdx].currentSlice = 0; + initWLState(seriesId, slices); + + const panel = document.getElementById('panel-' + panelIdx); + const thumbs = panel.querySelector('.thumbnails'); + + // Create W/L presets + scrubber + const midSliceId = slices[Math.floor(slices.length / 2)]?.id; + const presetsHtml = wlPresets.map((p, i) => + '
' + + '' + + '' + p.name + '
' + ).join(''); + + const scrubberHtml = + '
' + + '
Slice 1 / ' + slices.length + '
' + + '
' + + '
' + + '
' + + '
' + + '
1' + slices.length + '
' + + '
'; + + thumbs.innerHTML = '
' + presetsHtml + '
' + scrubberHtml; + + // Setup scrubber interaction + setupScrubber(panelIdx); + + // Preload all slice images for smooth scrolling + slices.forEach(s => { + const img = new Image(); + img.src = getImageUrlWithWL(s.id, seriesId, null, null); + }); + + // Start at middle slice + const midSlice = Math.floor(slices.length / 2); + goToSlice(panelIdx, midSlice); +} + +function update3DCrosshairs() { + if (!is3DMode) return; + + const getData = (p) => { + if (!p || !p.slices.length) return null; + const s = p.slices[p.currentSlice]; + // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz" + let rowVec = [1,0,0], colVec = [0,1,0]; + if (s.image_orientation) { + const parts = s.image_orientation.split('\\').map(Number); + if (parts.length === 6) { + rowVec = [parts[0], parts[1], parts[2]]; + colVec = [parts[3], parts[4], parts[5]]; + } + } + + // Compute CENTER of slice (not corner) + const psRow = s.pixel_spacing_row || 0.5; + const psCol = s.pixel_spacing_col || 0.5; + const halfWidth = (s.cols / 2) * psCol; + const halfHeight = (s.rows / 2) * psRow; + + const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0]; + const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1]; + const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2]; + + return { + pos_x: s.pos_x, + pos_y: s.pos_y, + pos_z: s.pos_z, + center_x: centerX, + center_y: centerY, + center_z: centerZ, + rows: s.rows, + cols: s.cols, + psRow: psRow, + psCol: psCol, + rowVec: rowVec, + colVec: colVec + }; + }; + + const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; + + const sagPanel = panels.find(p => p.orientation === 'SAG'); + const axPanel = panels.find(p => p.orientation === 'AX'); + const corPanel = panels.find(p => p.orientation === 'COR'); + + const sagData = getData(sagPanel); + const axData = getData(axPanel); + const corData = getData(corPanel); + + panels.forEach((p, idx) => { + if (!p.slices.length || !p.orientation) return; + + const div = document.getElementById('panel-' + idx); + const img = div.querySelector('.panel-content img'); + const hLine = div.querySelector('.crosshair-h'); + const vLine = div.querySelector('.crosshair-v'); + + if (!img.naturalWidth) { + hLine.style.display = 'none'; + vLine.style.display = 'none'; + return; + } + + const myData = getData(p); + const rect = img.getBoundingClientRect(); + // Divide out zoom since crosshairs are inside the transformed wrapper + const zoom = zoomLevels[zoomState[p.orientation].level]; + const scaleX = rect.width / img.naturalWidth / zoom; + const scaleY = rect.height / img.naturalHeight / zoom; + + // Build target point from CENTER of other slices + // SAG through-plane = X, AX through-plane = Z, COR through-plane = Y + let targetX = myData.center_x, targetY = myData.center_y, targetZ = myData.center_z; + if (sagData && p.orientation !== 'SAG') targetX = sagData.center_x; + if (axData && p.orientation !== 'AX') targetZ = axData.center_z; + if (corData && p.orientation !== 'COR') targetY = corData.center_y; + + // Offset from corner to target + const offset = [targetX - myData.pos_x, targetY - myData.pos_y, targetZ - myData.pos_z]; + + // Project onto row/col directions + const vPixel = dot(offset, myData.rowVec) / myData.psCol; + const hPixel = dot(offset, myData.colVec) / myData.psRow; + + if (hPixel >= 0 && hPixel <= myData.rows) { + hLine.style.top = (hPixel * scaleY) + 'px'; + hLine.style.display = 'block'; + } else { + hLine.style.display = 'none'; + } + + if (vPixel >= 0 && vPixel <= myData.cols) { + vLine.style.left = (vPixel * scaleX) + 'px'; + vLine.style.display = 'block'; + } else { + vLine.style.display = 'none'; + } + }); +} + +function goToSlice(panelIdx, sliceIdx) { + const panel = panels[panelIdx]; + if (!panel.slices.length) return; + panel.currentSlice = sliceIdx; + + const div = document.getElementById('panel-' + panelIdx); + const img = div.querySelector('.panel-content img'); + img.onload = () => detectImageBrightness(img, panelIdx); + img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId); + + // Clear rectangle when changing slice + div.querySelector('.rect-overlay').style.display = 'none'; + + div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx)); + + // Update scrubber position + updateScrubber(panelIdx, sliceIdx); + + updateOverlay(panelIdx); + + // Update crosshairs in 3D mode + if (is3DMode) { + setTimeout(update3DCrosshairs, 50); + } + + if (document.getElementById('syncScroll').checked && !is3DMode) { + const loc = panel.slices[sliceIdx].slice_location; + panels.forEach((p, i) => { + if (i !== panelIdx && p.slices.length) { + const closest = p.slices.reduce((prev, curr, idx) => + Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0); + if (p.currentSlice !== closest) { + p.currentSlice = closest; + const pDiv = document.getElementById('panel-' + i); + pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId); + pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest)); + updateOverlay(i); + } + } + }); + } +} + +// Track hovered panel for keyboard zoom +document.addEventListener('mousemove', (e) => { + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + hoveredPanel = idx; + } + } + }); +}, { passive: true }); + +document.addEventListener('wheel', e => { + if (!panels.length) return; + + // Find which panel the mouse is over + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + + if (targetPanel < 0) return; + hoveredPanel = targetPanel; + + // Track cursor relative to wrapper (for zoom-to-cursor) + // Account for current zoom since getBoundingClientRect returns transformed bounds + const div = document.getElementById('panel-' + targetPanel); + const wrapper = div.querySelector('.img-wrapper'); + const wrapperRect = wrapper.getBoundingClientRect(); + const orientation = getPanelOrientation(targetPanel); + const currentZoom = zoomLevels[zoomState[orientation].level]; + cursorX = (e.clientX - wrapperRect.left) / currentZoom; + cursorY = (e.clientY - wrapperRect.top) / currentZoom; + + // Shift+wheel = zoom + if (e.shiftKey) { + e.preventDefault(); + const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) { + zoomIn(targetPanel); + } else if (delta > 0) { + zoomOut(targetPanel); + } + return; + } + + // Regular wheel = scroll slices + const delta = e.deltaY > 0 ? 1 : -1; + const p = panels[targetPanel]; + if (!p.slices.length) return; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx); +}, { passive: false }); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (document.getElementById('tourOverlay').classList.contains('show')) { + endTour(); + } else if (document.getElementById('helpModal').classList.contains('show')) { + toggleHelp(); + } else { + clearRect(); + } + return; + } + if (!panels.length) return; + + // +/- for zoom (affects hovered panel's orientation group) + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + zoomIn(hoveredPanel); + return; + } + if (e.key === '-' || e.key === '_') { + e.preventDefault(); + zoomOut(hoveredPanel); + return; + } + + // Arrow keys for slice navigation + let delta = 0; + if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1; + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1; + if (delta === 0) return; + e.preventDefault(); + const p = panels[0]; + const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); + if (newIdx !== p.currentSlice) goToSlice(0, newIdx); +}); + +// Cancel drawing if mouse leaves window +document.addEventListener('mouseup', (e) => { + isDrawing = false; + if (isPanning) { + isPanning = false; + // Restore transition + document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = ''); + document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning')); + } +}); + +// Shift+click pan +document.addEventListener('mousedown', (e) => { + if (e.button !== 0 || !e.shiftKey) return; + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + const state = zoomState[orientation]; + if (state.level === 0) return; // no pan at 1x zoom + + isPanning = true; + panOrientation = orientation; + panStartMouseX = e.clientX; + panStartMouseY = e.clientY; + panStartPanX = state.panX; + panStartPanY = state.panY; + // Disable transition during pan for smooth movement + panels.forEach((p, idx) => { + if (getPanelOrientation(idx) === orientation) { + const div = document.getElementById('panel-' + idx); + if (div) div.querySelector('.img-wrapper').style.transition = 'none'; + } + }); + document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning')); +}); + +document.addEventListener('mousemove', (e) => { + if (!isPanning || !panOrientation) return; + + const state = zoomState[panOrientation]; + const zoom = zoomLevels[state.level]; + // With transform: scale(zoom) translate(panX, panY), translate values are scaled + // Divide by zoom for 1:1 screen-to-image movement + const dx = (e.clientX - panStartMouseX) / zoom; + const dy = (e.clientY - panStartMouseY) / zoom; + state.panX = panStartPanX + dx; + state.panY = panStartPanY + dy; + applyZoom(panOrientation); +}); + +// Double-click to reset zoom +document.addEventListener('dblclick', (e) => { + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById('panel-' + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const orientation = getPanelOrientation(targetPanel); + resetZoom(orientation); +}); + +// Ctrl+click for Window/Level adjustment +document.addEventListener("mousedown", (e) => { + if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift + e.preventDefault(); + + // Find hovered panel + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const panel = panels[targetPanel]; + if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return; + + isAdjustingWL = true; + isDrawing = false; // Prevent rect drawing + document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none"); + wlPanel = targetPanel; + wlStartX = e.clientX; + wlStartY = e.clientY; + wlStartWc = wlState[panel.seriesId].wc; + wlStartWw = wlState[panel.seriesId].ww; + document.body.style.cursor = "crosshair"; + + // Show hint + const hint = document.getElementById('wlHint'); + hint.style.left = (e.clientX + 15) + 'px'; + hint.style.top = (e.clientY - 10) + 'px'; + hint.classList.add('show'); +}); + +document.addEventListener("mousemove", (e) => { + if (!isAdjustingWL || wlPanel < 0) return; + + const panel = panels[wlPanel]; + if (!panel || !panel.seriesId) return; + const state = wlState[panel.seriesId]; + if (!state) return; + + // Horizontal = width, Vertical = center + const dx = e.clientX - wlStartX; + const dy = e.clientY - wlStartY; + + state.ww = Math.max(1, wlStartWw + dx * 2); + state.wc = wlStartWc - dy * 2; // invert: drag up = brighter + state.adjusted = true; + + // Update overlay C/W values in real-time + const div = document.getElementById("panel-" + wlPanel); + const wcEl = div.querySelector(".overlay-wc"); + const wwEl = div.querySelector(".overlay-ww"); + if (wcEl) wcEl.textContent = Math.round(state.wc); + if (wwEl) wwEl.textContent = Math.round(state.ww); + + // Debounce image reload + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + wlDebounceTimer = setTimeout(() => { + const img = div.querySelector(".panel-content img"); + img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); + }, 150); +}); + +document.addEventListener("mouseup", (e) => { + if (isAdjustingWL) { + isAdjustingWL = false; + document.body.style.cursor = ""; + document.getElementById('wlHint').classList.remove('show'); + if (wlDebounceTimer) clearTimeout(wlDebounceTimer); + if (wlPanel >= 0) { + reloadPanelImages(wlPanel); + } + wlPanel = -1; + } +}); + +// Track right-click for double-click detection +let lastRightClickTime = 0; +let lastRightClickPanel = -1; + +// Double right-click to reset Window/Level +document.addEventListener("mousedown", (e) => { + if (e.button !== 2) return; + + let targetPanel = -1; + panels.forEach((p, idx) => { + const div = document.getElementById("panel-" + idx); + if (div) { + const rect = div.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right && + e.clientY >= rect.top && e.clientY <= rect.bottom) { + targetPanel = idx; + } + } + }); + if (targetPanel < 0) return; + + const now = Date.now(); + if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) { + // Double right-click detected - reset W/L + resetWL(targetPanel); + lastRightClickTime = 0; + lastRightClickPanel = -1; + e.preventDefault(); + return; + } + lastRightClickTime = now; + lastRightClickPanel = targetPanel; +}); + +// Update crosshairs on window resize +// Prevent context menu on panels for right-click W/L adjustment +document.addEventListener("contextmenu", (e) => { + if (!e.target.closest("#panels")) return; + e.preventDefault(); +}); + +window.addEventListener('resize', () => { + if (is3DMode) update3DCrosshairs(); +}); + +// W/L Preset functions +function applyWLPreset(el) { + const panelIdx = parseInt(el.dataset.panel); + const wcAttr = el.dataset.wc; + const wwAttr = el.dataset.ww; + const panel = panels[panelIdx]; + if (!panel || !panel.seriesId) return; + + // Update wlState - null means reset to original + if (wcAttr === 'null' || wwAttr === 'null') { + wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc; + wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw; + wlState[panel.seriesId].adjusted = false; + } else { + wlState[panel.seriesId].wc = parseInt(wcAttr); + wlState[panel.seriesId].ww = parseInt(wwAttr); + wlState[panel.seriesId].adjusted = true; + } + + // Update active preset + const container = el.closest('.thumbnails'); + container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active')); + el.classList.add('active'); + + // Reload image + reloadPanelImages(panelIdx); +} + +function setupScrubber(panelIdx) { + const panel = document.getElementById('panel-' + panelIdx); + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const track = scrubber.querySelector('.scrubber-track'); + let isDragging = false; + + const updateFromPosition = (e) => { + const rect = track.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const pct = x / rect.width; + const sliceCount = panels[panelIdx].slices.length; + const sliceIdx = Math.round(pct * (sliceCount - 1)); + goToSlice(panelIdx, sliceIdx); + }; + + track.addEventListener('mousedown', (e) => { + isDragging = true; + updateFromPosition(e); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (isDragging) updateFromPosition(e); + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); +} + +function updateScrubber(panelIdx, sliceIdx) { + const panel = document.getElementById('panel-' + panelIdx); + if (!panel) return; + const scrubber = panel.querySelector('.slice-scrubber'); + if (!scrubber) return; + + const sliceCount = panels[panelIdx].slices.length; + const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0; + + const fill = scrubber.querySelector('.scrubber-fill'); + const handle = scrubber.querySelector('.scrubber-handle'); + const current = scrubber.querySelector('.scrubber-current'); + + if (fill) fill.style.width = pct + '%'; + if (handle) handle.style.left = pct + '%'; + if (current) current.textContent = sliceIdx + 1; +} + +init(); diff --git a/status.sh b/status.sh new file mode 100644 index 0000000..81d2086 --- /dev/null +++ b/status.sh @@ -0,0 +1,40 @@ +#!/bin/bash +cd /tank/inou + +echo "=== Inou Status ===" +echo "" + +# Services +echo "Services:" +if pgrep -f "bin/api$" > /dev/null; then + echo " API: running (PID $(pgrep -f 'bin/api$'))" +else + echo " API: stopped" +fi + +if pgrep -f "bin/viewer$" > /dev/null; then + echo " Viewer: running (PID $(pgrep -f 'bin/viewer$'))" +else + echo " Viewer: stopped" +fi + +if pgrep -f "bin/portal$" > /dev/null; then + echo " Portal: running (PID $(pgrep -f 'bin/portal$'))" +else + echo " Portal: stopped" +fi + +echo "" +echo "Endpoints:" +echo " Portal: https://inou.com" +echo " Viewer: https://inou.com:8767" +echo " API: https://inou.com/api/* (internal :8082)" + +echo "" +echo "FIPS 140-3 Build Status:" + +if [[ -x "bin/fips-check" ]]; then + bin/fips-check bin/api bin/portal bin/viewer bin/import-genome bin/lab-scrape bin/lab-import 2>/dev/null | sed 's/^/ /' +else + echo " (fips-check not found - run make deploy)" +fi diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..effd205 --- /dev/null +++ b/stop.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Stop Inou services +echo "=== Inou Stop ===" + +pkill -f "bin/api$" && echo "API: stopped" || echo "API: not running" +pkill -f "bin/viewer$" && echo "Viewer: stopped" || echo "Viewer: not running" +pkill -f "bin/portal$" && echo "Portal: stopped" || echo "Portal: not running" diff --git a/templates/add_dossier.tmpl b/templates/add_dossier.tmpl new file mode 100644 index 0000000..10828bf --- /dev/null +++ b/templates/add_dossier.tmpl @@ -0,0 +1,109 @@ +{{define "add_dossier"}} +
+ +
+
+

{{if .EditMode}}{{.T.edit_dossier}}{{else}}{{.T.add_dossier}}{{end}}

+

{{if .EditMode}}Update dossier information{{else}}Create a dossier for a family member{{end}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+ +
+ + + +
+ +
+ +
+ + +
+
+ + {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} +
+ + +
+ {{end}} + +
+ + +
+ + {{if or (not .EditMode) (and .EditMode (not .IsSelf))}} +
+ +
+
+ +
+ {{end}} + + {{if and .ConfirmDuplicate (not .EditMode)}} +
+ +
+ {{end}} +
+ {{.T.cancel}} + +
+
+
+
+ + {{template "footer"}} +
+ +{{end}} \ No newline at end of file diff --git a/templates/api.tmpl b/templates/api.tmpl new file mode 100644 index 0000000..d020f11 --- /dev/null +++ b/templates/api.tmpl @@ -0,0 +1,143 @@ +{{define "api"}} + + +
+

API

+

Access your health dossier data programmatically — or let AI do it for you.

+ + {{if .Dossier}} +
+

{{.T.api_token}}

+ {{if .APIToken}} +

{{.T.api_token_use}}

+
+ + +
+

{{.T.api_token_warning}}

+
+ +
+ {{else}} +

{{.T.api_token_none}}

+
+ +
+ {{end}} +
+ + {{end}} + +

{{.T.api_authentication}}

+

{{.T.api_auth_instructions}}

+
Authorization: Bearer YOUR_API_TOKEN
+ +

Endpoints

+ + + +
+ GET /api/v1/dossiers +

List all dossiers accessible to this account (your own + any shared with you).

+
+ + + +
+ GET /api/v1/dossiers/{id}/entries?category=imaging +

List all imaging studies in a dossier. Returns study ID, date, description, and series count.

+
+ +
+ GET /api/v1/entries/{study_id}/children +

List series in a study. Optional: ?filter=SAG or ?filter=T1 to filter by description.

+
+ +
+ GET /api/v1/entries/{series_id}/children +

List slices with position data (mm coordinates, orientation, pixel spacing).

+
+ +
+ GET /api/v1/entries/{slice_id}?detail=full +

Get slice image as PNG. Optional: &ww=WIDTH&wc=CENTER for windowing.

+
+ + + +
+ GET /api/v1/dossiers/{id}/entries?category=genome +

List genome variant categories: medication, cardiovascular, metabolism, fertility, traits, longevity.

+
+ +
+ GET /api/v1/dossiers/{id}/genome?search=MTHFR +

Query genome variants. Optional filters: &category=medication, &rsids=rs1234,rs5678, &min_magnitude=2

+
+ + + +
+ GET /api/v1/dossiers/{id}/labs/tests +

List all available lab test names for a dossier.

+
+ +
+ GET /api/v1/dossiers/{id}/labs/results?names=TSH,T4 +

Get lab results. Required: &names= (comma-separated). Optional: &from=2024-01-01, &to=2024-12-31, &latest=true

+
+ +
+ Text Format: Add &format=text to any endpoint for AI-friendly plain text output instead of JSON. +
+ +

Example

+
# List your dossiers
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+  https://inou.com/api/v1/dossiers
+
+# List imaging studies
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+  https://inou.com/api/v1/dossiers/DOSSIER_ID/entries?category=imaging
+
+# Query genome variants
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+  https://inou.com/api/v1/dossiers/DOSSIER_ID/genome?search=MTHFR
+ +
+{{end}} diff --git a/templates/audit.tmpl b/templates/audit.tmpl new file mode 100644 index 0000000..df09388 --- /dev/null +++ b/templates/audit.tmpl @@ -0,0 +1,50 @@ +{{define "audit"}} +
+
+
+

{{.T.audit_log}}

+

{{.T.audit_log_intro}} {{.TargetDossier.Name}}

+
+ ← {{.T.back_to_dossier}} +
+ +
+
+
+
+ {{.T.audit_log}} + {{len .AuditList}} entries +
+
+ +
+ {{range .AuditList}} +
+
+ {{.ActorName}} + {{.Action}} +
+
+ {{.Details}} + +
+
+ {{else}} +
+ No activity recorded yet +
+ {{end}} +
+
+
+ + +{{end}} diff --git a/templates/base.tmpl b/templates/base.tmpl new file mode 100644 index 0000000..ccba68f --- /dev/null +++ b/templates/base.tmpl @@ -0,0 +1,122 @@ + + + + + + inou{{if .Title}} - {{.Title}}{{end}} + + + + + + + {{if .Embed}}{{end}} + + + {{if not .Embed}} + + {{end}} + + {{if eq .Page "landing"}}{{template "landing" .}} + {{else if eq .Page "landing_nl"}}{{template "landing_nl" .}} + {{else if eq .Page "landing_ru"}}{{template "landing_ru" .}} + {{else if eq .Page "landing_de"}}{{template "landing_de" .}} + {{else if eq .Page "landing_fr"}}{{template "landing_fr" .}} + {{else if eq .Page "landing_es"}}{{template "landing_es" .}} + {{else if eq .Page "landing_pt"}}{{template "landing_pt" .}} + {{else if eq .Page "landing_it"}}{{template "landing_it" .}} + {{else if eq .Page "landing_sv"}}{{template "landing_sv" .}} + {{else if eq .Page "landing_no"}}{{template "landing_no" .}} + {{else if eq .Page "landing_da"}}{{template "landing_da" .}} + {{else if eq .Page "landing_fi"}}{{template "landing_fi" .}} + {{else if eq .Page "landing_ja"}}{{template "landing_ja" .}} + {{else if eq .Page "landing_ko"}}{{template "landing_ko" .}} + {{else if eq .Page "landing_zh"}}{{template "landing_zh" .}} + {{else if eq .Page "verify"}}{{template "verify" .}} + {{else if eq .Page "onboard"}}{{template "onboard" .}} + {{else if eq .Page "minor_error"}}{{template "minor_error" .}} + {{else if eq .Page "dashboard"}}{{template "dashboard" .}} + {{else if eq .Page "dossier"}}{{template "dossier" .}} + {{else if eq .Page "add_dossier"}}{{template "add_dossier" .}} + {{else if eq .Page "share"}}{{template "share" .}} + {{else if eq .Page "upload"}}{{template "upload" .}} + {{else if eq .Page "audit"}}{{template "audit" .}} + {{else if eq .Page "connect"}}{{template "connect" .}} + {{else if eq .Page "connect_nl"}}{{template "connect_nl" .}} + {{else if eq .Page "connect_ru"}}{{template "connect_ru" .}} + {{else if eq .Page "invite"}}{{template "invite" .}} + {{else if eq .Page "login"}}{{template "login" .}} + {{else if eq .Page "privacy"}}{{template "privacy" .}} + {{else if eq .Page "security"}}{{template "security" .}} + {{else if eq .Page "dpa"}}{{template "dpa" .}} + {{else if eq .Page "styleguide"}}{{template "styleguide" .}} + {{else if eq .Page "pricing"}}{{template "pricing" .}} + {{else if eq .Page "faq"}}{{template "faq" .}} + {{else if eq .Page "prompts"}}{{template "prompts" .}} + {{else if eq .Page "permissions"}}{{template "permissions" .}} + {{else if eq .Page "edit_access"}}{{template "edit_access" .}} + {{end}} + + + + diff --git a/templates/connect.tmpl b/templates/connect.tmpl new file mode 100644 index 0000000..3d2e6fb --- /dev/null +++ b/templates/connect.tmpl @@ -0,0 +1,286 @@ +{{define "connect"}} +
+ +
+
+

Connect AI to Your Data

+

Choose your AI assistant and follow the setup instructions.

+
+ {{if and .Dossier .Dossier.DossierID}}← Back{{else}}← Home{{end}} +
+ + {{if not (and .Dossier .Dossier.DossierID)}} + + {{end}} + +
+
+ + + + +
+ + +
+

Claude Desktop with MCP provides the richest experience — native tool access, no URL fetching, full API capabilities.

+ +
+
+ 1 +

Install Claude Desktop

+
+

Download and install from claude.ai/download

+
+ +
+
+ 2 +

Install Inou Extension

+
+

Download inou.mcpb and install:

+
    +
  • Mac: Double-click the file
  • +
  • Windows: In Claude Desktop, go to File → Import Extension and select the downloaded file
  • +
+

When prompted, enter your API token:

+ {{if and .Dossier .Dossier.DossierID}} + {{if .APIToken}} +
+
{{.APIToken}}
+ +
+ {{else}} +
+ +
+ {{end}} + {{else}} +
+
YOUR_API_TOKEN
+
+ {{end}} +

Note: You'll see a permissions warning — this is normal for any extension not yet in Claude's official directory. inou only makes HTTPS calls to inou.com; it does not access local files.

+
+ +
+
+ 3 +

Enable & Allow Permissions

+
+

Go to Settings → Extensions and enable the Inou extension if it's not already on.

+

When first using the extension, Claude will ask permission for each tool. Select "Allow for this chat" and check "Don't ask again for inou-health" to grant all permissions at once — otherwise you'll be prompted 11 times.

+
+ +
+
+ 4 +

Test

+
+

Open Claude Desktop and paste:

+
+
List my available dossiers using the inou bridge.
+ +
+

You should see your dossier(s). You're all set!

+
+ +
+ Manual installation (advanced) +
+
+
+ 1 +

Download Bridge

+
+

Download the bridge for your system:

+ +

Save to ~/bin/inou_bridge (Mac) or %USERPROFILE%\inou\inou_bridge.exe (Windows). Make executable on Mac: chmod +x ~/bin/inou_bridge

+
+
+
+ 2 +

Configure Claude

+
+

Edit Claude config (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) and add to mcpServers:

+
+
"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account={{if .APIToken}}{{.APIToken}}{{else}}YOUR_API_TOKEN{{end}}"]
+}
+ +
+

Replace BRIDGE_PATH with the actual path. Restart Claude Desktop.

+
+
+
+
+ + +
+

Grok can access your health data through our API using HTTP requests. No installation needed.

+ + {{if and .Dossier .Dossier.DossierID}} + {{if .TempToken}} +
+
+ 1 +

Paste this into Grok

+
+
+
Access my health data using the Inou API.
+
+Fetch https://inou.com/api/v1/dossiers?token={{.TempToken}}
+
+Show me the list of dossiers with their details and wait for my instructions.
+
+API docs: https://inou.com/api/docs
+
+IMPORTANT: This is real medical data. NEVER hallucinate. Only describe what you see.
+ +
+

Token expires at {{.TempTokenExpires}}. Refresh page for a new token.

+
+ {{else}} +
+
+ 1 +

Generate API Token

+
+

You need an API token to connect Grok to your data.

+
+ +
+
+ {{end}} + {{else}} + + {{end}} + +
+
+ +

What Grok can do

+
+

Once connected, ask Grok to:

+
    +
  • List all your imaging studies, genome data, and lab results
  • +
  • Show series within a specific study
  • +
  • Fetch and analyze individual slices
  • +
  • Compare images across different sequences (T1, T2, FLAIR)
  • +
  • Navigate to specific anatomical regions
  • +
  • Query genome variants by gene, category, or rsid
  • +
  • Review medication responses and health risks
  • +
  • Track lab values over time
  • +
+
+ +

See the full API documentation for all available endpoints.

+
+ + +
+

Not recommended for medical imaging due to elevated hallucination risk in our testing.

+ +
+
+ +

Why not ChatGPT?

+
+

Medical imaging requires absolute accuracy. In our testing, ChatGPT fabricated information even when correct data was clearly provided. We cannot recommend it for analyzing health data where errors have real consequences.

+
+ +
+
+ +

Recommended alternatives

+
+

Use Claude Desktop for the best experience with native tool access, or Grok for web-based access with no installation.

+
+
+ + +
+

Other AI assistants can access your data through our web API, though capabilities vary.

+ +
+
+ +

Gemini

+
+

Gemini's web browsing is currently restricted and may not be able to fetch inou.com URLs directly. Workarounds:

+
    +
  • Copy API responses manually and paste them into Gemini
  • +
  • Use Google AI Studio with function calling
  • +
  • Consider using Claude Desktop or Grok instead
  • +
+
+ +
+
+ +

Build Your Own

+
+

Our API is simple REST + JSON. See the API documentation for endpoints and authentication.

+
+
+
+ + {{template "footer"}} + +
+ + +{{end}} diff --git a/templates/connect_nl.tmpl b/templates/connect_nl.tmpl new file mode 100644 index 0000000..c7998fc --- /dev/null +++ b/templates/connect_nl.tmpl @@ -0,0 +1,243 @@ +{{define "connect_nl"}} +
+ +
+
+

Verbind AI met je gegevens

+

Kies je AI-assistent en volg de installatie-instructies.

+
+ {{if and .Dossier .Dossier.DossierID}}← Terug{{else}}← Home{{end}} +
+ + {{if not (and .Dossier .Dossier.DossierID)}} + + {{end}} + +
+
+ + + + +
+ + +
+

Claude Desktop met MCP biedt de beste ervaring — native tool-toegang, geen URL-fetching, volledige API-mogelijkheden.

+ +
+
+ 1 +

Installeer Claude Desktop

+
+

Download en installeer vanaf claude.ai/download

+
+ +
+
+ 2 +

Installeer de inou-extensie

+
+

Download inou.mcpb en installeer:

+
    +
  • Mac: Dubbelklik op het bestand
  • +
  • Windows: Ga in Claude Desktop naar File → Import Extension en selecteer het gedownloade bestand
  • +
+

Voer je account-token in wanneer daarom gevraagd wordt:

+
+
{{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}}
+ +
+

Let op: Je ziet een machtigingswaarschuwing — dit is normaal voor extensies die nog niet in Claude's officiële directory staan. inou maakt alleen HTTPS-verbindingen met inou.com; het benadert geen lokale bestanden.

+
+ +
+
+ 3 +

Inschakelen & machtigingen toestaan

+
+

Ga naar Settings → Extensions en schakel de inou-extensie in als dat nog niet gedaan is.

+

Bij het eerste gebruik vraagt Claude toestemming voor elke tool. Selecteer "Allow for this chat" en vink "Don't ask again for inou-health" aan om alle machtigingen in één keer toe te staan — anders word je 11 keer gevraagd.

+
+ +
+
+ 4 +

Test

+
+

Open Claude Desktop en plak:

+
+
Toon mijn beschikbare dossiers via de inou-bridge.
+ +
+

Je zou je dossier(s) moeten zien. Klaar!

+
+ +
+ Handmatige installatie (geavanceerd) +
+
+
+ 1 +

Download bridge

+
+

Download de bridge voor jouw systeem:

+ +

Sla op naar ~/bin/inou_bridge (Mac) of %USERPROFILE%\inou\inou_bridge.exe (Windows). Maak uitvoerbaar op Mac: chmod +x ~/bin/inou_bridge

+
+
+
+ 2 +

Configureer Claude

+
+

Bewerk Claude config (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) en voeg toe aan mcpServers:

+
+
"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}}"]
+}
+ +
+

Vervang BRIDGE_PATH door het daadwerkelijke pad. Herstart Claude Desktop.

+
+
+
+
+ + +
+

Grok kan via het web je gezondheidsgegevens benaderen via onze API. Geen installatie nodig.

+ +
+

Snel starten

+

Kopieer dit en plak het in Grok:

+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}JOUW_ACCOUNT_TOKEN{{end}} to list my dossiers.
+If there is more than one dossier, ask me which one to work with before proceeding.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+ +
+
+ +
+
+ +

Wat Grok kan doen

+
+

Eenmaal verbonden kun je Grok vragen om:

+
    +
  • Al je beeldvormingsstudies, genoomgegevens en labresultaten te tonen
  • +
  • Series binnen een specifieke studie te laten zien
  • +
  • Individuele slices op te halen en te analyseren
  • +
  • Beelden te vergelijken tussen verschillende sequenties (T1, T2, FLAIR)
  • +
  • Naar specifieke anatomische regio's te navigeren
  • +
  • Genoomvarianten op te zoeken op gen, categorie of rsid
  • +
  • Medicatiereacties en gezondheidsrisico's te bekijken
  • +
  • Labwaarden in de tijd te volgen
  • +
+
+ +

Zie de volledige API-documentatie voor alle beschikbare endpoints.

+
+ + +
+

Niet aanbevolen voor medische beeldvorming vanwege verhoogd risico op hallucinaties in onze tests.

+ +
+
+ +

Waarom niet ChatGPT?

+
+

Medische beeldvorming vereist absolute nauwkeurigheid. In onze tests verzon ChatGPT informatie, zelfs wanneer correcte gegevens duidelijk werden verstrekt. We kunnen het niet aanbevelen voor het analyseren van gezondheidsgegevens waar fouten echte gevolgen hebben.

+
+ +
+
+ +

Aanbevolen alternatieven

+
+

Gebruik Claude Desktop voor de beste ervaring met native tool-toegang, of Grok voor webtoegang zonder installatie.

+
+
+ + +
+

Andere AI-assistenten kunnen je gegevens benaderen via onze web-API, hoewel mogelijkheden variëren.

+ +
+
+ +

Gemini

+
+

Gemini's webbrowsing is momenteel beperkt en kan mogelijk geen inou.com-URL's direct ophalen. Workarounds:

+
    +
  • Kopieer API-responses handmatig en plak ze in Gemini
  • +
  • Gebruik Google AI Studio met function calling
  • +
  • Overweeg Claude Desktop of Grok in plaats daarvan
  • +
+
+ +
+
+ +

Bouw je eigen

+
+

Onze API is eenvoudige REST + JSON. Zie de API-documentatie voor endpoints en authenticatie.

+
+
+
+ + {{template "footer"}} + +
+ + +{{end}} diff --git a/templates/connect_ru.tmpl b/templates/connect_ru.tmpl new file mode 100644 index 0000000..4aa72be --- /dev/null +++ b/templates/connect_ru.tmpl @@ -0,0 +1,243 @@ +{{define "connect_ru"}} +
+ +
+
+

Подключите ИИ к вашим данным

+

Выберите вашего ИИ-ассистента и следуйте инструкциям по установке.

+
+ {{if and .Dossier .Dossier.DossierID}}← Назад{{else}}← Главная{{end}} +
+ + {{if not (and .Dossier .Dossier.DossierID)}} + + {{end}} + +
+
+ + + + +
+ + +
+

Claude Desktop с MCP обеспечивает лучший опыт — нативный доступ к инструментам, без загрузки URL, полные возможности API.

+ +
+
+ 1 +

Установите Claude Desktop

+
+

Скачайте и установите с claude.ai/download

+
+ +
+
+ 2 +

Установите расширение inou

+
+

Скачайте inou.mcpb и установите:

+
    +
  • Mac: Дважды щёлкните по файлу
  • +
  • Windows: В Claude Desktop перейдите в File → Import Extension и выберите скачанный файл
  • +
+

При запросе введите токен вашей учётной записи:

+
+
{{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}}
+ +
+

Примечание: Вы увидите предупреждение о разрешениях — это нормально для расширений, ещё не включённых в официальный каталог Claude. inou делает только HTTPS-запросы к inou.com; локальные файлы не затрагиваются.

+
+ +
+
+ 3 +

Включите и разрешите доступ

+
+

Перейдите в Settings → Extensions и включите расширение inou, если оно ещё не включено.

+

При первом использовании Claude запросит разрешение для каждого инструмента. Выберите "Allow for this chat" и отметьте "Don't ask again for inou-health", чтобы дать все разрешения сразу — иначе вас спросят 11 раз.

+
+ +
+
+ 4 +

Тест

+
+

Откройте Claude Desktop и вставьте:

+
+
Покажи мои доступные досье через мост inou.
+ +
+

Вы должны увидеть ваше(и) досье. Готово!

+
+ +
+ Ручная установка (продвинутый) +
+
+
+ 1 +

Скачайте мост

+
+

Скачайте мост для вашей системы:

+ +

Сохраните в ~/bin/inou_bridge (Mac) или %USERPROFILE%\inou\inou_bridge.exe (Windows). Сделайте исполняемым на Mac: chmod +x ~/bin/inou_bridge

+
+
+
+ 2 +

Настройте Claude

+
+

Отредактируйте конфигурацию Claude (Mac: ~/Library/Application Support/Claude/claude_desktop_config.json, Windows: %APPDATA%\Claude\claude_desktop_config.json) и добавьте в mcpServers:

+
+
"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}}"]
+}
+ +
+

Замените BRIDGE_PATH на фактический путь. Перезапустите Claude Desktop.

+
+
+
+
+ + +
+

Grok может получить доступ к вашим медицинским данным напрямую через наш API. Установка не требуется.

+ +
+

Быстрый старт

+

Скопируйте это и вставьте в Grok:

+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token={{if and .Dossier .Dossier.DossierID}}{{.Dossier.DossierID}}{{else}}ВАШ_ТОКЕН_УЧЁТНОЙ_ЗАПИСИ{{end}} to list my dossiers.
+If there is more than one dossier, ask me which one to work with before proceeding.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+ +
+
+ +
+
+ +

Что может делать Grok

+
+

После подключения попросите Grok:

+
    +
  • Показать все ваши исследования визуализации, геномные данные и результаты анализов
  • +
  • Показать серии в конкретном исследовании
  • +
  • Загрузить и проанализировать отдельные срезы
  • +
  • Сравнить изображения между разными последовательностями (T1, T2, FLAIR)
  • +
  • Перейти к определённым анатомическим областям
  • +
  • Найти геномные варианты по гену, категории или rsid
  • +
  • Просмотреть реакции на лекарства и риски для здоровья
  • +
  • Отслеживать лабораторные показатели во времени
  • +
+
+ +

См. полную документацию API для всех доступных эндпоинтов.

+
+ + +
+

Не рекомендуется для медицинской визуализации из-за повышенного риска галлюцинаций в наших тестах.

+ +
+
+ +

Почему не ChatGPT?

+
+

Медицинская визуализация требует абсолютной точности. В наших тестах ChatGPT выдумывал информацию, даже когда правильные данные были чётко предоставлены. Мы не можем рекомендовать его для анализа медицинских данных, где ошибки имеют реальные последствия.

+
+ +
+
+ +

Рекомендуемые альтернативы

+
+

Используйте Claude Desktop для лучшего опыта с нативным доступом к инструментам, или Grok для веб-доступа без установки.

+
+
+ + +
+

Другие ИИ-ассистенты могут получить доступ к вашим данным через наш веб-API, хотя возможности различаются.

+ +
+
+ +

Gemini

+
+

Веб-браузинг Gemini в настоящее время ограничен и может не загружать URL-адреса inou.com напрямую. Обходные пути:

+
    +
  • Скопируйте ответы API вручную и вставьте их в Gemini
  • +
  • Используйте Google AI Studio с вызовом функций
  • +
  • Рассмотрите вместо этого Claude Desktop или Grok
  • +
+
+ +
+
+ +

Создайте своё

+
+

Наш API — простой REST + JSON. См. документацию API для эндпоинтов и аутентификации.

+
+
+
+ + {{template "footer"}} + +
+ + +{{end}} diff --git a/templates/dashboard.tmpl b/templates/dashboard.tmpl new file mode 100644 index 0000000..fa61703 --- /dev/null +++ b/templates/dashboard.tmpl @@ -0,0 +1,67 @@ +{{define "dashboard"}} +
+

{{.T.dossiers}}

+

{{.T.dossiers_intro}}

+ +
+ + + + + {{range .AccessibleDossiers}} + + {{end}} + + + + + + {{.T.add_dossier}} + +
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/dossier.tmpl b/templates/dossier.tmpl new file mode 100644 index 0000000..0fb6ee5 --- /dev/null +++ b/templates/dossier.tmpl @@ -0,0 +1,752 @@ +{{define "dossier"}} + +
+
+
+

{{.TargetDossier.Name}}

+ {{if .ShowDetails}} +

+ {{if .TargetDossier.DateOfBirth}}{{.T.born}}: {{printf "%.10s" .TargetDossier.DateOfBirth}}{{end}} + {{if .TargetDossier.Sex}} · {{sexT .TargetDossier.Sex .Lang}}{{end}} +

+ {{end}} +
+ ← {{.T.back_to_dossiers}} +
+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} + + +
+
+
+
+ {{.T.section_imaging}} + {{if .Studies}} + + {{else}} + {{.T.no_imaging}} + {{end}} +
+ {{if .HasImaging}} + {{.T.open_viewer}} + {{end}} +
+ + {{if .Studies}} +
+ {{range $i, $s := .Studies}} + {{if eq $s.SeriesCount 1}} +
+
+ {{$s.Description}} +
+
+ + +
+
+ {{else}} + +
+ {{range $s.Series}}{{if gt .SliceCount 0}} +
+ {{if .Description}}{{.Description}}{{else}}{{.Modality}}{{end}} + + +
+ {{end}}{{end}} +
+ {{end}} + {{end}} + {{if gt .StudyCount 5}} +
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_labs}} + {{if .Labs}} + {{len .Labs}} results + {{else}} + {{.T.no_lab_data}} + {{end}} +
+
+ {{if .Labs}} +
+ {{range .Labs}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_records}} + {{len .Documents}} documents +
+
+ {{if .Documents}} +
+ {{range .Documents}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Type}}{{.Type}}{{end}} + {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Procedures & Surgery + {{len .Procedures}} procedures +
+
+ {{if .Procedures}} +
+ {{range .Procedures}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Clinical Assessments + {{len .Assessments}} assessments +
+
+ {{if .Assessments}} +
+ {{range .Assessments}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_genetics}} + Loading... +
+ +
+
+
+ + + + + +
+
+
+
+ {{.T.section_uploads}} + + {{if .Uploads}}{{else}}{{.T.no_files}}{{end}} + +
+ {{if .CanEdit}}{{.T.manage}}{{else}}{{.T.manage}}{{end}} +
+
+ + +
+
+
+
+ {{.T.section_medications}} + {{len .Medications}} medications +
+
+ {{if .Medications}} +
+ {{range .Medications}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Symptoms + {{len .Symptoms}} symptoms +
+
+ {{if .Symptoms}} +
+ {{range .Symptoms}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Hospitalizations + {{len .Hospitalizations}} hospitalizations +
+
+ {{if .Hospitalizations}} +
+ {{range .Hospitalizations}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ Therapies + {{len .Therapies}} therapies +
+
+ {{if .Therapies}} +
+ {{range .Therapies}} +
+
+ {{.Value}} + {{if .Summary}}{{.Summary}}{{end}} +
+
+ {{if .Date}}{{.Date}}{{end}} +
+
+ {{end}} +
+ {{end}} +
+ + +
+
+
+
+ {{.T.section_vitals}} + {{.T.vitals_desc}} +
+ {{.T.coming_soon}} +
+
+ + +
+
+
+
+ {{.T.section_privacy}} + {{len .AccessList}} {{.T.people_with_access_count}} +
+
+ +
+ {{range .AccessList}} +
+
+ {{.Name}}{{if .IsSelf}} ({{$.T.you}}){{else if .IsPending}} ({{$.T.pending}}){{end}} + {{.Relation}}{{if .CanEdit}} · {{$.T.can_edit}}{{end}} +
+ {{if and $.CanManageAccess (not .IsSelf)}} +
+ Edit +
+ + +
+
+ {{end}} +
+ {{end}} + {{if not .AccessList}} +
+ {{.T.no_access_yet}} +
+ {{end}} + + +
+ {{.T.share_access}} + {{if .CanManageAccess}}{{.T.manage_permissions}}{{end}} + {{.T.view_audit_log}} + {{if or (eq .Dossier.DossierID .TargetDossier.DossierID) .CanManageAccess}}{{.T.export_data}}{{end}} +
+
+
+ + {{template "footer"}} +
+ + +{{end}} diff --git a/templates/dpa.tmpl b/templates/dpa.tmpl new file mode 100644 index 0000000..aa45c94 --- /dev/null +++ b/templates/dpa.tmpl @@ -0,0 +1,248 @@ +{{define "dpa"}} + + +
+ +
+

Data Processing Agreement

+

This agreement describes how inou processes your health data. It applies to all users and any third-party services that access your data through our platform.

+
+ +
+

Definitions

+ +

Data Controller.

+

You. You decide what data to upload, who can access it, and when to delete it.

+ +

Data Processor.

+

inou. We store, encrypt, and transmit your data according to your instructions.

+ +

Sub-processors.

+

Third-party services you explicitly connect to your account, such as AI assistants. We do not use sub-processors for storage or core functionality.

+
+ +
+

Data we process

+ +

Health data.

+

Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload.

+ +

Account data.

+

Name, email address, date of birth, and sex. Used for account management and medical context.

+ +

Technical data.

+

IP addresses and session identifiers. Used exclusively for security and access control.

+
+ +
+

How we process it

+ +

Storage.

+

All health data is encrypted using FIPS 140-3 validated cryptography before storage. Data resides on dedicated infrastructure in the United States that we own and operate.

+ +

Transmission.

+

All data in transit is protected by TLS 1.3 encryption. When you connect third-party services, data travels through an encrypted bridge directly to your session.

+ +

Access.

+

Only you and accounts you explicitly authorize can access your data. Staff access requires your explicit request, is restricted to senior personnel, and is logged.

+
+ +
+

Processing restrictions

+ +

We process your data solely to provide the service. Specifically, we do not:

+
    +
  • Use your data for AI model training
  • +
  • Sell, rent, or share your data with third parties
  • +
  • Analyze your data for advertising or profiling
  • +
  • Access your data without your explicit request
  • +
  • Retain your data after account deletion
  • +
+
+ +
+

Third-party connections

+ +

When you connect an AI assistant or other service to inou:

+
    +
  • You explicitly authorize each connection
  • +
  • Data is transmitted only for your active session
  • +
  • We do not store copies of transmitted data
  • +
  • You can revoke access at any time
  • +
  • Each third party operates under their own privacy policy
  • +
+

We recommend reviewing the privacy policy of any service you connect.

+
+ +
+

Security measures

+ +

Encryption.

+

FIPS 140-3 validated encryption at rest. TLS 1.3 encryption in transit. Application-layer encryption before database storage.

+ +

Infrastructure.

+

Dedicated hardware. No shared cloud environments. Redundant storage with RAID-Z2. Uninterruptible power with generator backup.

+ +

Access control.

+

Role-based access control. Mandatory authentication. All access logged and auditable.

+ +

Monitoring.

+

Continuous automated monitoring. Intrusion detection. Regular security assessments.

+
+ +
+

Data retention

+ +

We retain your data for as long as your account is active. When you delete your account:

+
    +
  • All personal data is permanently destroyed
  • +
  • All health data is permanently destroyed
  • +
  • Deletion is immediate and irreversible
  • +
  • Backups are overwritten within 30 days
  • +
+

We do not offer recovery of deleted data.

+
+ +
+

Your rights

+ +

Access.

+

See and export everything we store — data you've entered, account details, access logs, and audit history.

+ +

Rectification.

+

Correct any inaccurate data directly or by request.

+ +

Erasure.

+

Delete your account and all associated data instantly.

+ +

Portability.

+

Download data you've entered in standard formats. Your uploaded files are already yours.

+ +

Objection.

+

Revoke any permission at any time. We comply immediately.

+
+ +
+

Compliance

+ +

This agreement is designed to comply with:

+
    +
  • GDPR (European Union General Data Protection Regulation)
  • +
  • FADP (Swiss Federal Act on Data Protection)
  • +
  • HIPAA (US Health Insurance Portability and Accountability Act)
  • +
+

We apply the highest standard regardless of your jurisdiction.

+
+ +
+

Contact

+

Questions about data processing: privacy@inou.com

+

This agreement was last updated on January 21, 2026.

+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/edit_access.tmpl b/templates/edit_access.tmpl new file mode 100644 index 0000000..7c66053 --- /dev/null +++ b/templates/edit_access.tmpl @@ -0,0 +1,89 @@ +{{define "edit_access"}} +
+
+
+
+
+

Edit access

+

{{.GranteeName}}'s access to {{.TargetDossier.Name}}

+
+ {{.T.back}} +
+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} + +
+ + + +
+

Permissions

+
+ + + + +
+
+ + +
+

Data access

+

Select which types of data this person can access. Unchecked categories will be hidden.

+ +
+ {{range .Categories}} + + {{end}} +
+
+ + + {{if .EntryGrants}} +
+

Specific items shared

+ {{range .EntryGrants}} +
+
+ {{.Description}} + {{.CategoryName}} +
+ +
+ {{end}} +
+ {{end}} + +
+ {{.T.cancel}} + +
+
+ + +
+
+ + +
+
+
+
+
+{{end}} diff --git a/templates/faq.tmpl b/templates/faq.tmpl new file mode 100644 index 0000000..4678daa --- /dev/null +++ b/templates/faq.tmpl @@ -0,0 +1,1097 @@ +{{define "faq"}} + + +
+
+
+

Frequently Asked Questions

+

Everything you need to know about inou health

+
+
+ + +
+
+
+
+ Pricing & Plans + 7 questions +
+
+
+ +
+
+

Monitor (Free)

+
    +
  • Track vitals, period/fertility, exercise, symptoms, and food
  • +
  • Text and voice entry
  • +
  • Up to 4 dossiers (family members)
  • +
  • 100MB storage
  • +
  • MCP integration with limited AI insights
  • +
  • Perfect for basic health tracking
  • +
+

Optimize ($12/month or $120/year)

+
    +
  • Everything in Monitor, plus:
  • +
  • Photo uploads with OCR
  • +
  • Supplements & medications tracking
  • +
  • Family history
  • +
  • Lab results tracking
  • +
  • Consumer genome analysis (~160 curated variants covering pharmacogenomics, disease risks, methylation, athletic performance, nutrition, personality traits, and physical traits)
  • +
  • Full AI insights, trend analysis, predictions, and health correlations
  • +
  • 1GB storage
  • +
  • Up to 4 dossiers
  • +
+

Research ($35/month or $350/year)

+
    +
  • Everything in Optimize, plus:
  • +
  • Medical imaging (MRI, CT, X-ray scans)
  • +
  • Complete genome analysis (all 5,000+ variants from SNPedia)
  • +
  • Clinical genome sequencing support
  • +
  • Browse and search any genetic variant
  • +
  • 100GB storage (imaging files are large)
  • +
  • Up to 4 dossiers
  • +
+
+
+ + +
+
+

Annual plans are priced at 10 months - you get 2 months free:

+
    +
  • Optimize: $120/year instead of $144 (save $24)
  • +
  • Research: $350/year instead of $420 (save $70)
  • +
+
+
+ + +
+
+

inou is in active development. If you sign up now:

+
    +
  • No charges until July 1, 2026 - use any paid tier completely free
  • +
  • No auto-renewal on July 1, 2026 - we'll ask if you want to continue
  • +
  • No credit card required during early access - just sign up and start using it
  • +
  • Choose to continue with a paid plan or stay on the free Monitor tier after July 1st
  • +
+

This gives you 6+ months to try inou with full access to Optimize or Research features before deciding if you want to pay.

+
+
+ + +
+
+

Yes, you can change your plan at any time:

+
    +
  • Upgrade: Takes effect immediately, you get access to new features right away
  • +
  • Downgrade: Takes effect at the end of your current billing period
  • +
  • Data preservation: Your data is never deleted when you downgrade - features just become read-only until you upgrade again
  • +
+

For example, if you downgrade from Optimize to Monitor, your lab results and genome data remain stored, but you'll lose AI analysis features until you upgrade again.

+
+
+ + +
+
+
    +
  • Monitor (100MB): You'll receive a notification when you reach 80% and 95% of your limit. At 100%, you can't add new data until you upgrade or delete old entries.
  • +
  • Optimize (1GB): Same notification system. 1GB covers approximately 1,000 lab PDFs or 50 consumer genome files.
  • +
  • Research (100GB): Designed for medical imaging. 100GB covers roughly 200-400 MRI/CT studies depending on series count.
  • +
+

We don't charge overage fees. If you need more storage, you'll need to upgrade to the next tier or manage your existing data.

+
+
+ + +
+
+

Yes! All tiers include up to 4 dossiers. A "dossier" is an individual health profile. This means you can:

+
    +
  • Track your own health plus 3 family members (spouse, children, parents)
  • +
  • Manage your child's medical records
  • +
  • Help an elderly parent track their medications and appointments
  • +
  • Keep separate profiles for complex multi-person health situations
  • +
+

Each dossier has its own data, permissions, and privacy settings. Family members can have their own login access or you can manage everything from your account.

+
+
+
+
+ + +
+
+
+
+ Features & Capabilities + 7 questions +
+
+
+ +
+
+

All tiers:

+
    +
  • Vitals: blood pressure, heart rate, weight, temperature, oxygen saturation
  • +
  • Period/fertility: cycle dates, flow, symptoms, basal body temperature, cervical mucus
  • +
  • Exercise: type, duration, intensity, routes, performance metrics
  • +
  • Symptoms: headaches, pain, fatigue, digestive issues, mood, sleep quality
  • +
  • Food: meal logging via text, voice, or photo
  • +
+

Optimize & Research add:

+
    +
  • Supplements & medications: dosage, timing, refills, interactions
  • +
  • Family history: genetic conditions, disease patterns
  • +
  • Lab results: blood tests, panels, biomarkers with trend tracking
  • +
  • Consumer genome: 23andMe, AncestryDNA, or similar test results
  • +
+

Research tier adds:

+
    +
  • Medical imaging: MRI, CT, X-ray scans (DICOM format)
  • +
  • Clinical genome sequencing: whole exome, whole genome, gene panels
  • +
+
+
+ + +
+
+

This is what makes inou different.

+

A single MRI can contain thousands of images across multiple sequences (T1, T2, FLAIR, etc.). Uploading all of them to an AI chat is impractical - you'd hit context limits and waste time selecting slices manually.

+

inou solves this:

+
    +
  • Upload your entire scan once (we handle DICOM natively)
  • +
  • Your AI sees the study metadata - what sequences exist, how many slices, anatomical positions
  • +
  • When analyzing, the AI requests exactly the slices it needs
  • +
  • It can pull specific images on demand: "show me axial T2 slice 45" or "compare sagittal FLAIR at L4-L5"
  • +
+

The AI intelligently navigates your imaging library without you having to manually find and upload individual images. Ask about a herniated disc, and it fetches the relevant spine sequences. Ask about a brain lesion, and it pulls the right FLAIR slices.

+

Example: "Look at my May 2024 lumbar MRI and tell me if there's any change compared to my January 2023 scan" - the AI fetches and compares the relevant slices from both studies automatically.

+
+
+ + +
+
+

For Optimize ($12/mo):

+

Upload your raw data file from 23andMe, AncestryDNA, or similar consumer tests. inou analyzes ~160 carefully curated, high-confidence genetic variants:

+
    +
  • Pharmacogenomics (40 variants): Which medications work best for your genetics (statins, blood thinners, antidepressants, pain meds)
  • +
  • Disease risk (30 variants): BRCA1/2, APOE (Alzheimer's), Lynch syndrome, familial hypercholesterolemia, clotting disorders
  • +
  • Methylation & detox (10 variants): MTHFR, CBS, MTR - affects folate metabolism, B12 needs, homocysteine
  • +
  • Athletic performance (20 variants): Muscle fiber type, VO2max potential, injury risk, recovery speed
  • +
  • Nutrition (20 variants): Lactose intolerance, caffeine metabolism, alcohol flush, vitamin needs
  • +
  • Personality & traits (20 variants): COMT (stress response), sleep chronotype, pain sensitivity
  • +
  • Physical traits (20 variants): Eye color, hair traits, earwax type, bitter taste
  • +
+

For Research ($35/mo):

+

Everything above PLUS access to all 5,000+ variants analyzed by SNPedia, including:

+
    +
  • Rare disease variants
  • +
  • Uncertain/early research findings
  • +
  • Polygenic risk scores for 50+ conditions
  • +
  • Carrier status for 200+ recessive conditions
  • +
  • Search any rsID or gene
  • +
  • Clinical genome sequencing data (WES/WGS)
  • +
+

Privacy control: You choose whether to show protective variants (good news), risk variants (bad news), or both. Some people want comprehensive information; others prefer not to see risks they can't control.

+
+
+ + +
+
+

inou connects AI assistants like Claude and Grok directly to your health data. Instead of manually copying and pasting information, your AI can:

+
    +
  • Read your complete medical history
  • +
  • Analyze trends across vitals, labs, symptoms
  • +
  • Correlate genome data with medication responses
  • +
  • Answer questions using YOUR specific health data
  • +
+

How it works:

+
    +
  1. Connect your AI to inou (Claude via MCP bridge, Grok via API)
  2. +
  3. Ask health questions naturally: "Why am I having headaches?" or "Is this medication safe for my genome?"
  4. +
  5. AI sees your relevant data and gives personalized answers
  6. +
  7. Data never leaves inou permanently - AI queries it in real-time
  8. +
+

Monitor tier: MCP works, but AI has limited data (no labs/genome), so insights are basic

+

Optimize/Research: Full AI capabilities with complete health context

+
+
+ + +
+
+

Currently supported:

+
    +
  • Lab results: PDF upload with OCR
  • +
  • Genome: 23andMe, AncestryDNA raw data files
  • +
  • Medical imaging: DICOM files from radiology
  • +
  • Photos: Medication bottles, food, health documents
  • +
+

Coming soon:

+
    +
  • Apple Health / HealthKit integration
  • +
  • Google Fit integration
  • +
  • Wearable devices (Garmin, Oura, Whoop)
  • +
  • MyChart / Epic integration
  • +
  • Laboratory portal direct imports
  • +
+

You can also enter data manually via text or voice for anything not yet automated.

+
+
+ + +
+
+

No. inou is a tool for organizing your health data and enabling AI to help you understand it. It is NOT:

+
    +
  • A diagnostic tool
  • +
  • A replacement for medical advice
  • +
  • A treatment recommendation system
  • +
  • A prescription service
  • +
+

inou helps you:

+
    +
  • Track your health comprehensively
  • +
  • Understand patterns and trends
  • +
  • Communicate better with your doctor (export reports for appointments)
  • +
  • Research your conditions using AI with your personal context
  • +
  • Manage medications and symptoms
  • +
+

Always consult your healthcare provider for medical decisions. Think of inou as your health data infrastructure - it makes you a more informed patient, but your doctor makes the clinical calls.

+
+
+
+
+ + +
+
+
+
+ Free Period & Early Access + 5 questions +
+
+
+ +
+
+

We're in active development and want early users to:

+
    +
  1. Test features and give feedback while we refine the product
  2. +
  3. Build their health data without worrying about costs
  4. +
  5. Experience full value before committing to a subscription
  6. +
  7. Help us improve by reporting bugs and suggesting features
  8. +
+

Early adopters are incredibly valuable. This free period is our way of saying thank you for being part of the journey.

+
+
+ + +
+
+

We'll contact you before July 1st to ask if you want to continue:

+
    +
  • Continue with paid tier: Subscribe at the current pricing (prices locked for early users)
  • +
  • Downgrade to Monitor (Free): Keep using basic features forever at no cost
  • +
  • Export and leave: Download all your data and cancel
  • +
+

Important: Your subscription will NOT auto-renew. We will never charge you without explicit confirmation. No surprises, no sneaky billing.

+
+
+ + +
+
+

For early users who sign up during the free period:

+
    +
  • Pricing locked: If you subscribe after July 1st, you'll pay 2026 prices even if we raise them later
  • +
  • Grandfathered forever: As long as you maintain continuous subscription, your rate never increases
  • +
  • Example: If you subscribe at $12/mo in July 2026 and we raise prices to $15/mo in 2027, you still pay $12/mo
  • +
+

New users after July 2026 will pay whatever the current pricing is at that time.

+
+
+ + +
+
+

No - build one comprehensive dossier with as much information as possible. The more data your AI has access to, the better insights it can provide.

+

You can create multiple dossiers under different email addresses, but there's no benefit to splitting your health data. Keep everything in one place for the best AI experience.

+

Use separate dossiers for family members, not for yourself.

+
+
+
+
+ + +
+
+
+
+ Payment & Billing + 5 questions +
+
+
+ +
+
+
    +
  • Credit cards (Visa, Mastercard, American Express, Discover)
  • +
  • Debit cards
  • +
  • Digital wallets (Apple Pay, Google Pay)
  • +
  • ACH bank transfers (annual plans only)
  • +
+

Coming soon: PayPal, Venmo

+
+
+ + +
+
+

No. Payment processing is handled by Stripe, a certified PCI Service Provider Level 1 (the highest security standard). We never see or store your credit card details. Stripe handles all payment security.

+
+
+ + +
+
+

Monthly plans:

+
    +
  • First charge: July 1, 2026 (or later if you sign up after that date)
  • +
  • Recurring: Same day each month (if you subscribe on July 15th, you're billed the 15th of each month)
  • +
  • Prorated: If you upgrade mid-cycle, you're charged the prorated difference immediately
  • +
+

Annual plans:

+
    +
  • First charge: July 1, 2026 (or later)
  • +
  • Recurring: Same date each year
  • +
  • No mid-year charges unless you upgrade tiers
  • +
+
+
+ + +
+
+

30-day money-back guarantee:

+

If you subscribe after July 1, 2026 and aren't satisfied, request a full refund within 30 days. No questions asked.

+

Free period users:

+

Since you used the service free for months before subscribing, refunds aren't available after the 30-day guarantee expires. You can always cancel to avoid future charges.

+
+
+ + +
+
+
    +
  1. Day 1: Automatic retry
  2. +
  3. Day 3: Email notification + retry
  4. +
  5. Day 7: Final retry + account locked (read-only access)
  6. +
  7. Day 14: Account suspended (no access until payment resolves)
  8. +
  9. Day 30: Account scheduled for deletion
  10. +
+

Your data is never deleted before 30 days, and we'll send multiple notifications. Update your payment method anytime to restore access immediately.

+
+
+
+
+ + +
+
+
+
+ Cancellation & Data + 4 questions +
+
+
+ +
+
+
    +
  1. Go to Account Settings
  2. +
  3. Click "Subscription" tab
  4. +
  5. Click "Cancel Subscription"
  6. +
  7. Confirm cancellation
  8. +
+

Takes effect:

+
    +
  • End of current billing period (you keep access until then)
  • +
  • Immediate downgrade to Monitor (Free) tier
  • +
  • No further charges
  • +
+

Your data:

+
    +
  • Never deleted
  • +
  • Remains stored and accessible in read-only mode
  • +
  • Full access restored if you resubscribe
  • +
+
+
+ + +
+
+

Yes, anytime. Just:

+
    +
  1. Go to Account Settings
  2. +
  3. Click "Upgrade"
  4. +
  5. Choose your plan and enter payment
  6. +
+

Your data is still there - you'll have immediate access to everything again.

+
+
+ + +
+
+

Account deletion is permanent and immediate:

+
    +
  1. Go to Account Settings
  2. +
  3. Click "Delete Account"
  4. +
  5. Confirm deletion (requires typing "DELETE" to confirm)
  6. +
  7. All data is permanently destroyed within 24 hours
  8. +
+

Before deleting:

+
    +
  • Export your data (we provide standard formats)
  • +
  • Download any reports or documents you want to keep
  • +
  • Consider canceling instead (keeps your data for future use)
  • +
+

After deletion:

+
    +
  • Cannot be undone
  • +
  • Cannot recover any data
  • +
  • Backups purged within 30 days
  • +
+
+
+ + +
+
+

Yes, anytime. Export formats:

+
    +
  • Structured data: JSON, CSV
  • +
  • Labs: PDF copies of original uploads
  • +
  • Genome: Original raw data file
  • +
  • Imaging: DICOM files
  • +
  • Reports: PDF summaries with charts and trends
  • +
+

Export includes everything: vitals, symptoms, medications, labs, genome, imaging, notes - your complete health record.

+

Use cases:

+
    +
  • Switching to another service
  • +
  • Sharing with healthcare providers
  • +
  • Personal backup
  • +
  • Research or analysis
  • +
+

You own your data. We just store it for you.

+
+
+
+
+ + +
+
+
+
+ Privacy & Security + 9 questions +
+
+
+ +
+
+

United States-based servers using enterprise-grade infrastructure:

+
    +
  • Not on Big Tech clouds (no Google, Amazon, Microsoft)
  • +
  • Independent data centers with physical security
  • +
  • Redundant backups across multiple locations
  • +
  • HIPAA-compliant infrastructure
  • +
+

If you access inou from outside the US, your data crosses international borders. We apply the same security and privacy protections regardless of your location.

+
+
+ + +
+
+

FIPS 140-3 encryption (US government standard):

+
    +
  • At rest: All files encrypted using FIPS 140-3 validated cryptography
  • +
  • In transit: TLS 1.3 encryption for all connections
  • +
  • Backups: Encrypted with separate keys
  • +
  • Key management: Hardware security modules (HSMs)
  • +
+

This is the same encryption standard used by banks, hospitals, and government agencies.

+
+
+ + +
+
+

Only you. We never:

+
    +
  • Share data with advertisers
  • +
  • Sell data to third parties
  • +
  • Use data to train AI models
  • +
  • Mine data for research without explicit consent
  • +
  • Provide data to partners or affiliates
  • +
+

Law enforcement:

+

We comply with lawful requests (court orders, subpoenas) but nothing else. If served with a valid legal demand, we must provide requested data. We will notify you unless legally prohibited.

+

Your AI:

+

When you connect AI via MCP, your data is transmitted through an encrypted bridge to your AI session. The AI processes it in real-time but doesn't store it permanently. Check your AI provider's privacy policy for their data handling practices.

+
+
+ + +
+
+

No, except:

+
    +
  • You explicitly request support that requires data access
  • +
  • Legal obligations (court order)
  • +
  • Critical security incident investigation
  • +
+

When access is granted:

+
    +
  • Restricted to senior staff only
  • +
  • Logged in audit trail (visible to you in Account Settings)
  • +
  • Time-limited (access expires after 24 hours)
  • +
  • You're notified when access occurs
  • +
+

Random employees, contractors, or developers never have access to your health data.

+
+
+ + +
+
+

Never. Your data is:

+
    +
  • Not used to train machine learning models
  • +
  • Not used to improve AI assistants
  • +
  • Not used for research or development
  • +
  • Not anonymized and aggregated for analysis
  • +
+

If we ever want to use anonymized, aggregated data for research, we will:

+
    +
  1. Ask for explicit opt-in consent
  2. +
  3. Explain exactly what we're studying
  4. +
  5. Provide the ability to opt out anytime
  6. +
  7. Never share identifiable data
  8. +
+
+
+ + +
+
+

None. We don't use:

+
    +
  • Google Analytics
  • +
  • Meta pixels
  • +
  • Tracking scripts
  • +
  • Third-party cookies
  • +
  • Advertising networks
  • +
+

What we do track:

+
    +
  • One cookie for login session
  • +
  • IP addresses for security logs only
  • +
  • Error logs for debugging (no personal data)
  • +
+

We have no idea what you click, where you came from, or where you go next.

+
+
+ + +
+
+

Yes. We follow HIPAA standards:

+
    +
  • Business Associate Agreements available for covered entities
  • +
  • Administrative, physical, and technical safeguards
  • +
  • Breach notification procedures
  • +
  • Audit controls and access logs
  • +
  • Encrypted storage and transmission
  • +
+

We also comply with:

+
    +
  • GDPR (European data protection)
  • +
  • FADP (Swiss data protection)
  • +
  • CCPA (California consumer privacy)
  • +
+

Regardless of where you live, you get our highest level of privacy protection.

+
+
+ + +
+
+

Users under 18:

+
    +
  • Cannot create accounts independently
  • +
  • Require parent/guardian authorization
  • +
  • Parent/guardian maintains full control
  • +
  • Can be revoked anytime
  • +
+

Parents/guardians can:

+
    +
  • Create dossiers for children
  • +
  • Manage all data and access
  • +
  • Control sharing and AI integration
  • +
  • Delete child's data anytime
  • +
+

Minors cannot share their data with third parties or connect AI without parental consent.

+
+
+
+
+ + +
+
+
+
+ Technical & Support + 6 questions +
+
+
+ +
+
+

Web browser (all tiers):

+
    +
  • Chrome, Firefox, Safari, Edge
  • +
  • Desktop and mobile browsers
  • +
  • Responsive design for any screen size
  • +
+

Mobile apps (coming Q2 2026):

+
    +
  • iOS (iPhone, iPad)
  • +
  • Android
  • +
  • Native apps with full feature parity
  • +
+

Desktop apps (planned):

+
    +
  • macOS
  • +
  • Windows
  • +
+
+
+ + +
+
+

Currently supported:

+
    +
  • Claude (Anthropic) - via MCP bridge (.mcpb file)
  • +
  • Grok (xAI) - via direct API
  • +
+

We're actively adding more integrations. See inou.com/connect for the latest list.

+
+
+ + +
+
+

Report it! We want to know:

+
    +
  • Email: support@inou.com
  • +
  • In-app: Help → Report Bug
  • +
  • Include screenshots if possible
  • +
+

Early users who report bugs help us build a better product. We fix critical issues within 24 hours and minor issues within a week.

+
+
+ + +
+
+

Yes:

+
    +
  • Email support: support@inou.com (24-48 hour response)
  • +
  • This FAQ page
  • +
  • Setup guides at inou.com/connect
  • +
+
+
+ + +
+
+

Absolutely! Email features@inou.com or use the in-app feedback tool. We maintain a public roadmap and regularly implement user suggestions.

+

Early users have significant influence on product direction - if enough people want a feature, we prioritize it.

+
+
+ + +
+
+

We commit to:

+
    +
  • 90 days advance notice
  • +
  • Export tools for all data
  • +
  • Recommendations for alternative services
  • +
  • Option to self-host your data
  • +
+

If acquired, your data either:

+
    +
  • Transfers under the same privacy terms, OR
  • +
  • You're given the option to export and delete
  • +
+

We will never sell user data as part of an acquisition. It's your data, not our asset.

+
+
+
+
+ + +
+
+
+
+ Getting Started + 5 questions +
+
+
+ +
+
+
    +
  1. Go to inou.com
  2. +
  3. Enter your email address
  4. +
  5. Click the verification link sent to your inbox
  6. +
  7. Create your first dossier
  8. +
  9. Start tracking!
  10. +
+

No passwords to remember - we use secure email verification.

+

No credit card required during the free period.

+
+
+ + +
+
+

Quick wins:

+
    +
  1. Current medications - Get AI drug interaction checking
  2. +
  3. Recent lab results - Upload last bloodwork PDF
  4. +
  5. Vitals baseline - Blood pressure, weight, resting heart rate
  6. +
  7. Symptoms if any - Helps establish patterns
  8. +
+

Over time:

+
    +
  1. Upload genome data if you have it (23andMe, Ancestry)
  2. +
  3. Track period/exercise/food as relevant
  4. +
  5. Add family history for context
  6. +
  7. Upload medical imaging for complex conditions
  8. +
+

Start simple - you can always add more later.

+
+
+ + +
+
+

Instantly. Upload takes about 30 seconds (file is ~20MB), then analysis completes in seconds. Results appear immediately - no waiting, no email notification needed.

+
+
+ + +
+
+

Yes, but it depends on format:

+
    +
  • PDFs: Upload directly (labs, imaging reports, doctor notes)
  • +
  • Paper records: Take photos, upload via mobile app
  • +
  • CDs from radiology: DICOM files work natively
  • +
  • Portals (MyChart, etc.): Manual download, then upload
  • +
+

We're working on direct integrations with Epic, Cerner, and other EHR systems.

+
+
+ + +
+
+

Claude Desktop:

+
    +
  1. Download the inou MCP bridge from your dashboard
  2. +
  3. Double-click the .mcpb file to install
  4. +
  5. Restart Claude Desktop
  6. +
  7. Ask Claude health questions - it now has access to your dossier!
  8. +
+

Grok:

+

Grok connects directly via API. Go to your dashboard, generate an API token, and add it to Grok's settings.

+

Detailed setup guides: inou.com/connect

+
+
+
+
+ + +
+
+

Still Have Questions?

+

We're a small team building something we believe in. If you have questions, ideas, or feedback - we want to hear it.

+

+ Email us: support@inou.com +

+
+
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/footer.tmpl b/templates/footer.tmpl new file mode 100644 index 0000000..62c0d1e --- /dev/null +++ b/templates/footer.tmpl @@ -0,0 +1,10 @@ +{{define "footer"}} + +{{end}} diff --git a/templates/input.tmpl b/templates/input.tmpl new file mode 100644 index 0000000..6e29e49 --- /dev/null +++ b/templates/input.tmpl @@ -0,0 +1,230 @@ +{{define "input"}} + + + + + + + + Add Health Data — inou + + + + + + + +
+ +
+

What's on your mind?

+

Add symptoms, notes, or scan documents

+
+ + +
+ + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +

Tap to start speaking

+
+

+

+
+ +
+
+ + +
+
+ +
+ +

Scan medication barcodes, lab results, or medical documents

+
+ + + + + + + + + + + + + + + +
+
+
+ + +
+ +
+ + + +
+ + + + +{{end}} diff --git a/templates/install_public.tmpl b/templates/install_public.tmpl new file mode 100644 index 0000000..3d6d85a --- /dev/null +++ b/templates/install_public.tmpl @@ -0,0 +1,235 @@ +{{define "install_public"}} + + +
+
+
+

Connect AI to Your Data

+

Choose your AI assistant and follow the setup instructions.

+
+ ← Home +
+ + + +
+ + + + +
+ + +
+

Claude Desktop with MCP provides the richest experience — native tool access, no URL fetching, full API capabilities.

+ +
+ 1 +

Install Claude Desktop

+

Download and install from claude.ai/download

+
+ +
+ 2 +

Install Desktop Commander

+

Open Claude Desktop and paste:

+
+
Please install Desktop Commander MCP server so you can help me with file operations.
+ +
+

Claude will guide you through the installation. Restart Claude when done.

+
+ +
+ 3 +

Install Inou Bridge

+

After restarting, paste this in Claude:

+
+
Please set up the Inou medical imaging bridge:
+
+1. Detect my OS and architecture
+
+2. Download the correct bridge:
+   - Mac Apple Silicon: https://inou.com/download/inou_bridge_darwin_arm64
+   - Mac Intel: https://inou.com/download/inou_bridge_darwin_amd64
+   - Windows 64-bit: https://inou.com/download/inou_bridge_win_amd64.exe
+
+3. Save it to:
+   - Mac: ~/bin/inou_bridge (create ~/bin if needed, make executable)
+   - Windows: %USERPROFILE%\inou\inou_bridge.exe (create folder if needed)
+
+4. Edit Claude config:
+   - Mac: ~/Library/Application Support/Claude/claude_desktop_config.json
+   - Windows: %APPDATA%\Claude\claude_desktop_config.json
+
+5. Add to mcpServers (keep any existing entries like desktop-commander):
+
+"inou": {
+  "command": "BRIDGE_PATH",
+  "args": ["--server=https://inou.com", "--account=YOUR_ACCOUNT_TOKEN"]
+}
+
+Replace BRIDGE_PATH with the actual path where you saved the bridge.
+Replace YOUR_ACCOUNT_TOKEN with your token from inou.com/dashboard.
+
+Tell me when done.
+ +
+
+ +
+ 4 +

Restart & Test

+

Quit Claude Desktop completely, reopen, then paste:

+
+
List my available dossiers using the inou bridge, then show imaging studies for the first one.
+ +
+

You should see your dossier(s) and any imaging studies.

+
+
+ + +
+

Grok can browse the web and access your health data directly through our API. No installation needed.

+ +
+

Quick Start

+

Copy this and paste it into Grok:

+
+
Fetch https://inou.com/api/docs to see the API documentation.
+Then fetch https://inou.com/api/dossiers?token=YOUR_ACCOUNT_TOKEN to list my dossiers.
+Help me explore my health data - imaging studies, genome variants, and lab results.
+
+IMPORTANT:
+- Replace YOUR_ACCOUNT_TOKEN with your token from inou.com/dashboard.
+- Allow 3 seconds for API responses before assuming failure.
+- When sharing image links, use the exact image_url from responses.
+- This is real medical data. NEVER hallucinate. Only describe what you actually see.
+ +
+
+ +
+ +

What Grok can do

+

Once connected, ask Grok to:

+
    +
  • List all your imaging studies, genome data, and lab results
  • +
  • Show series within a specific study
  • +
  • Fetch and analyze individual slices
  • +
  • Compare images across different sequences (T1, T2, FLAIR)
  • +
  • Navigate to specific anatomical regions
  • +
  • Query genome variants by gene, category, or rsid
  • +
  • Review medication responses and health risks
  • +
  • Track lab values over time
  • +
+
+ +

See the full API documentation for all available endpoints.

+
+ + +
+

Not recommended for medical imaging due to elevated hallucination risk in our testing.

+ +
+ +

Why not ChatGPT?

+

Medical imaging requires absolute accuracy. In our testing, ChatGPT fabricated information even when correct data was clearly provided. We cannot recommend it for analyzing health data where errors have real consequences.

+
+ +
+ +

Recommended alternatives

+

Use Claude Desktop for the best experience with native tool access, or Grok for web-based access with no installation.

+
+
+ + +
+

Other AI assistants can access your data through our web API, though capabilities vary.

+ +
+ +

Gemini

+

Gemini's web browsing is currently restricted and may not be able to fetch inou.com URLs directly. Workarounds:

+
    +
  • Copy API responses manually and paste them into Gemini
  • +
  • Use Google AI Studio with function calling
  • +
  • Consider using Claude Desktop or Grok instead
  • +
+
+ +
+ +

Build Your Own

+

Our API is simple REST + JSON. See the API documentation for endpoints and authentication.

+
+
+
+ + +{{end}} diff --git a/templates/invite.tmpl b/templates/invite.tmpl new file mode 100644 index 0000000..be0acdc --- /dev/null +++ b/templates/invite.tmpl @@ -0,0 +1,69 @@ +{{define "invite"}} + +
+ +
+

Invite a friend

+

Know someone who could benefit from inou? Send them an invitation.

+
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{if .Success}} +
{{.Success}}
+ {{end}} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+

What happens next?

+

We'll send them a personal email from you with an invitation to join inou. That's it.

+
    +
  • We won't email them again
  • +
  • We won't store their email if they don't sign up
  • +
  • We won't share their email with anyone
  • +
  • We'll send you a copy of the email, so you see exactly what we sent
  • +
+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/landing.tmpl b/templates/landing.tmpl new file mode 100644 index 0000000..c4909c4 --- /dev/null +++ b/templates/landing.tmpl @@ -0,0 +1,423 @@ +{{define "landing"}} + + +
+ +
+
+
inou organizes and shares your health dossier with your AI — securely and privately.
+
Your health, understood.
+
+ {{if .Dossier}}Invite a friend{{else}}Sign in{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+
+

You need AI for your health

+ +
+

Your health data lives in a dozen different places — with your cardiologist, your neurologist, your lab, your watch, your apps, your 23andMe. And only you know the rest: what you eat, what you drink, what supplements you take. Your exercise routine. Your symptoms. Your goals — whether you're trying to get pregnant, training for a marathon, or just trying to feel less exhausted.

+ +

Whether you're healthy and want to stay that way, navigating a difficult diagnosis, or caring for a family member who can't advocate for themselves — no single doctor sees the full picture. No system connects it.

+ +

But you have access to all of it. You just don't have the expertise to make sense of it all.

+ +

Your AI does. inou gives it the full picture.

+
+
+
+
+
+

The challenge

+
+
Your MRI has 4,000 slices.
+
It was read in 10 minutes.
+
+ +
+
Your genome has millions of variants.
+
All you learned was your eye color and where your ancestors came from.
+
+ +
+
Your blood work has dozens of markers.
+
Your doctor said "everything looks fine."
+
+ +
+
Your watch tracked 10,000 hours of sleep.
+
Your trainer doesn't know it exists.
+
+ +
+
You've tried a hundred different supplements.
+
Nobody asked which ones.
+
+ +
+ The connections are there.
+ They are just too complex for any one person to grasp. +
+ +
+ Nobody knows how your body processes Warfarin — not even you. + But the answer might already be hiding in your 23andMe. + That 'unremarkable' on your MRI — did anyone look closely at all 4,000 slices? + Your thyroid is 'in range' — but nobody connected it to your fatigue, your weight, always being cold. +
+ +
+ Nobody is connecting your afternoon caffeine to your sleep scores. + Your iron levels to your workout fatigue. + Your genetics to your brain fog. +
+ +
+ Your AI doesn't forget. + Doesn't rush. + Finds what was missed. + Doesn't specialize — sees the complete you. +
+ +
inou lets your AI take it all into account — every slice, every marker, every variant — connect it all and finally give you answers no one else could.
+
+
+ + +
+
+

Why we built this

+ +

You've collected years of health data. Scans from the hospital. Blood work from the lab. Results from your doctor's portal. Data from your watch. Maybe even your DNA.

+ +

And then there's everything only you know — your weight, your blood pressure, your training schedule, the supplements you take, the symptoms you've been meaning to mention.

+ +

It's all there — but scattered across systems that don't talk to each other, held by specialists who only see their piece, or locked in your own head.

+ +

Your cardiologist doesn't know what your neurologist found. Your trainer hasn't seen your blood work. Your doctor has no idea what supplements you are taking. And none of them have time to sit with you and connect the dots.

+ +

AI finally can. It can pull together what no single expert sees — and actually explain it to you.

+ +

But this data doesn't fit in a chat window. And the last thing you want is your medical history on someone else's servers, training their models.

+ +

inou brings it all together — labs, imaging, genetics, vitals, medications, supplements — encrypted, private, and shared with absolutely no one. Your AI connects securely. Your data stays yours.

+ +
Your health, understood.
+
+
+ +
+ +
+
+ {{.T.never_training}} + {{.T.never_training_desc}} +
+
+ {{.T.never_shared}} + {{.T.never_shared_desc}} +
+
+ {{.T.encrypted}} + {{.T.encrypted_desc}} +
+
+ {{.T.delete}} + {{.T.delete_desc}} +
+
+
+ + {{template "footer"}} + +
+{{end}} + + diff --git a/templates/landing_da.tmpl b/templates/landing_da.tmpl new file mode 100644 index 0000000..d1e806b --- /dev/null +++ b/templates/landing_da.tmpl @@ -0,0 +1,121 @@ +{{define "landing_da"}} + +
+
+
+
inou organiserer og deler dit sundhedsdossier med din AI — sikkert og privat.
+
Dit helbred, forstået.
+
{{if .Dossier}}Inviter en ven{{else}}Log ind{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Du har brug for AI til dit helbred

+
+

Dine sundhedsdata er spredt på dusinvis af steder — hos din kardiolog, din neurolog, laboratoriet, dit smartur, dine apps, dit 23andMe. Og kun du kender resten: hvad du spiser, hvad du drikker, hvilke kosttilskud du tager. Din træningsrutine. Dine symptomer. Dine mål — uanset om du prøver at blive gravid, træner til et maraton, eller bare prøver at føle dig mindre træt.

+

Uanset om du er sund og vil blive ved med det, navigerer en svær diagnose, eller passer på et familiemedlem der ikke kan tale for sig selv — ingen enkelt læge ser hele billedet. Ingen system forbinder det hele.

+

Men du har adgang til alt. Du mangler bare ekspertisen til at forstå det.

+

Din AI har den. inou giver den hele billedet.

+
+
+
+
+
+

Udfordringen

+
Din MR-scanning har 4.000 snit.
Den blev læst på 10 minutter.
+
Dit genom har millioner af varianter.
Du fik kun at vide din øjenfarve og hvor dine forfædre kom fra.
+
Dine blodprøver har dusinvis af markører.
Din læge sagde "alt ser fint ud."
+
Dit ur har registreret 10.000 timers søvn.
Din træner ved ikke at det eksisterer.
+
Du har prøvet hundrede forskellige kosttilskud.
Ingen spurgte hvilke.
+
Forbindelserne er der.
De er bare for komplekse for én person.
+
+ Ingen ved hvordan din krop behandler Warfarin — ikke engang dig. + Men svaret gemmer sig måske allerede i dit 23andMe. + Det "uden bemærkninger" på din MR — kiggede nogen virkelig grundigt på alle 4.000 snit? + Din skjoldbruskkirtel er "inden for normalområdet" — men ingen forbandt det med din træthed, din vægt, at du altid fryser. +
+
+ Ingen forbinder din eftermiddagskaffe med din søvnkvalitet. + Dine jernværdier med din træthed under træning. + Din genetik med din hjernetåge. +
+
+ Din AI glemmer ikke. + Har ikke travlt. + Finder det der blev overset. + Specialiserer sig ikke — ser dig som helhed. +
+
inou lader din AI tage alt i betragtning — hvert snit, hver markør, hver variant — forbinder det hele og giver dig endelig svar som ingen andre kunne give.
+
+
+
+
+

Hvorfor vi byggede dette

+

Du har samlet års sundhedsdata. Undersøgelser fra hospitalet. Prøver fra laboratoriet. Resultater fra patientportalen. Data fra dit ur. Måske endda dit DNA.

+

Og så er der alt det som kun du ved — din vægt, dit blodtryk, dit træningsprogram, de tilskud du tager, de symptomer du altid glemmer at nævne.

+

Det er alt sammen der — men spredt i systemer der ikke taler sammen, hos specialister der kun ser deres del, eller låst inde i dit eget hoved.

+

Din kardiolog ved ikke hvad din neurolog fandt. Din træner har ikke set dine blodprøver. Din læge aner ikke hvilke tilskud du tager. Og ingen af dem har tid til at sætte sig ned med dig og forbinde prikkerne.

+

AI kan endelig gøre det. Den kan samle det som ingen enkelt ekspert ser — og forklare det for dig oven i købet.

+

Men disse data passer ikke i et chatvindue. Og det sidste du vil er din sygehistorie på andres servere, til at træne deres modeller.

+

inou samler alt — lab, billeddiagnostik, genetik, vitale tegn, medicin, tilskud — krypteret, privat, og delt med absolut ingen. Din AI forbinder sikkert. Dine data forbliver dine.

+
Dit helbred, forstået.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_de.tmpl b/templates/landing_de.tmpl new file mode 100644 index 0000000..430682d --- /dev/null +++ b/templates/landing_de.tmpl @@ -0,0 +1,121 @@ +{{define "landing_de"}} + +
+
+
+
inou organisiert und teilt Ihre Gesundheitsakte mit Ihrer KI — sicher und privat.
+
Ihre Gesundheit, verstanden.
+
{{if .Dossier}}Freund einladen{{else}}Anmelden{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Sie brauchen KI für Ihre Gesundheit

+
+

Ihre Gesundheitsdaten sind über Dutzende von Orten verstreut — bei Ihrem Kardiologen, Ihrem Neurologen, im Labor, in Ihrer Smartwatch, Ihren Apps, Ihrem 23andMe. Und nur Sie kennen den Rest: was Sie essen, was Sie trinken, welche Nahrungsergänzungsmittel Sie nehmen. Ihr Trainingsplan. Ihre Symptome. Ihre Ziele — ob Sie schwanger werden möchten, für einen Marathon trainieren oder einfach weniger müde sein wollen.

+

Ob Sie gesund sind und es bleiben wollen, mit einer schwierigen Diagnose kämpfen oder sich um ein Familienmitglied kümmern, das sich nicht selbst vertreten kann — kein einzelner Arzt sieht das vollständige Bild. Kein System verbindet alles.

+

Aber Sie haben Zugang zu allem. Ihnen fehlt nur die Expertise, um alles zu verstehen.

+

Ihre KI hat sie. inou gibt ihr das vollständige Bild.

+
+
+
+
+
+

Die Herausforderung

+
Ihr MRT hat 4.000 Schichten.
Es wurde in 10 Minuten ausgewertet.
+
Ihr Genom hat Millionen von Varianten.
Sie haben nur Ihre Augenfarbe und Ihre Herkunft erfahren.
+
Ihr Blutbild hat Dutzende von Markern.
Ihr Arzt sagte "alles sieht gut aus."
+
Ihre Uhr hat 10.000 Stunden Schlaf aufgezeichnet.
Ihr Trainer weiß nicht, dass sie existiert.
+
Sie haben hundert verschiedene Nahrungsergänzungsmittel ausprobiert.
Niemand hat gefragt, welche.
+
Die Verbindungen sind da.
Sie sind nur zu komplex für eine einzelne Person.
+
+ Niemand weiß, wie Ihr Körper Warfarin verarbeitet — nicht einmal Sie. + Aber die Antwort könnte bereits in Ihrem 23andMe versteckt sein. + Dieses "unauffällig" in Ihrem MRT — hat jemand wirklich alle 4.000 Schichten genau angesehen? + Ihre Schilddrüse ist "im Normbereich" — aber niemand hat sie mit Ihrer Müdigkeit, Ihrem Gewicht, dass Ihnen immer kalt ist, verbunden. +
+
+ Niemand verbindet Ihren Nachmittagskaffee mit Ihrer Schlafqualität. + Ihren Eisenspiegel mit Ihrer Trainingsmüdigkeit. + Ihre Genetik mit Ihrem Gehirnnebel. +
+
+ Ihre KI vergisst nicht. + Hetzt nicht. + Findet, was übersehen wurde. + Spezialisiert sich nicht — sieht Sie als Ganzes. +
+
inou lässt Ihre KI alles berücksichtigen — jede Schicht, jeden Marker, jede Variante — verbindet alles und gibt Ihnen endlich Antworten, die niemand sonst geben konnte.
+
+
+
+
+

Warum wir das gebaut haben

+

Sie haben jahrelang Gesundheitsdaten gesammelt. Scans aus dem Krankenhaus. Blutwerte aus dem Labor. Ergebnisse aus dem Patientenportal. Daten von Ihrer Uhr. Vielleicht sogar Ihre DNA.

+

Und dann gibt es alles, was nur Sie wissen — Ihr Gewicht, Ihr Blutdruck, Ihr Trainingsplan, die Nahrungsergänzungsmittel, die Sie nehmen, die Symptome, die Sie immer vergessen zu erwähnen.

+

Es ist alles da — aber verstreut über Systeme, die nicht miteinander kommunizieren, bei Spezialisten, die nur ihren Teil sehen, oder in Ihrem eigenen Kopf eingeschlossen.

+

Ihr Kardiologe weiß nicht, was Ihr Neurologe gefunden hat. Ihr Trainer hat Ihre Blutwerte nicht gesehen. Ihr Arzt hat keine Ahnung, welche Nahrungsergänzungsmittel Sie nehmen. Und keiner von ihnen hat Zeit, sich mit Ihnen hinzusetzen und die Punkte zu verbinden.

+

KI kann das endlich. Sie kann zusammenführen, was kein einzelner Experte sieht — und es Ihnen auch noch erklären.

+

Aber diese Daten passen nicht in ein Chat-Fenster. Und das Letzte, was Sie wollen, ist Ihre Krankengeschichte auf fremden Servern, die deren Modelle trainiert.

+

inou bringt alles zusammen — Labor, Bildgebung, Genetik, Vitalwerte, Medikamente, Nahrungsergänzungsmittel — verschlüsselt, privat und mit niemandem geteilt. Ihre KI verbindet sich sicher. Ihre Daten bleiben Ihre.

+
Ihre Gesundheit, verstanden.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_es.tmpl b/templates/landing_es.tmpl new file mode 100644 index 0000000..525d4d6 --- /dev/null +++ b/templates/landing_es.tmpl @@ -0,0 +1,121 @@ +{{define "landing_es"}} + +
+
+
+
inou organiza y comparte tu expediente de salud con tu IA — de forma segura y privada.
+
Tu salud, comprendida.
+
{{if .Dossier}}Invitar a un amigo{{else}}Iniciar sesión{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Necesitas IA para tu salud

+
+

Tus datos de salud están dispersos en docenas de lugares — con tu cardiólogo, tu neurólogo, el laboratorio, tu reloj inteligente, tus apps, tu 23andMe. Y solo tú conoces el resto: qué comes, qué bebes, qué suplementos tomas. Tu rutina de ejercicio. Tus síntomas. Tus objetivos — ya sea que estés intentando quedar embarazada, entrenando para un maratón, o simplemente tratando de sentirte menos cansado.

+

Ya sea que estés sano y quieras seguir así, navegando un diagnóstico difícil, o cuidando a un familiar que no puede defenderse solo — ningún médico ve el panorama completo. Ningún sistema lo conecta.

+

Pero tú tienes acceso a todo. Solo te falta la experiencia para entenderlo todo.

+

Tu IA la tiene. inou le da el panorama completo.

+
+
+
+
+
+

El desafío

+
Tu resonancia tiene 4.000 cortes.
Se leyó en 10 minutos.
+
Tu genoma tiene millones de variantes.
Solo aprendiste el color de tus ojos y de dónde vienen tus ancestros.
+
Tu análisis de sangre tiene docenas de marcadores.
Tu médico dijo "todo se ve bien."
+
Tu reloj registró 10.000 horas de sueño.
Tu entrenador no sabe que existe.
+
Has probado cien suplementos diferentes.
Nadie preguntó cuáles.
+
Las conexiones están ahí.
Son demasiado complejas para una sola persona.
+
+ Nadie sabe cómo tu cuerpo procesa la Warfarina — ni siquiera tú. + Pero la respuesta podría estar escondida en tu 23andMe. + Ese "sin hallazgos" en tu resonancia — ¿alguien miró cuidadosamente los 4.000 cortes? + Tu tiroides está "dentro del rango" — pero nadie lo conectó con tu fatiga, tu peso, que siempre tienes frío. +
+
+ Nadie conecta tu café de la tarde con tu calidad de sueño. + Tus niveles de hierro con tu fatiga en el entrenamiento. + Tu genética con tu niebla mental. +
+
+ Tu IA no olvida. + No se apresura. + Encuentra lo que se pasó por alto. + No se especializa — te ve completo. +
+
inou permite que tu IA tome todo en cuenta — cada corte, cada marcador, cada variante — conecta todo y finalmente te da respuestas que nadie más podía dar.
+
+
+
+
+

Por qué construimos esto

+

Has recopilado años de datos de salud. Estudios del hospital. Análisis del laboratorio. Resultados del portal del médico. Datos de tu reloj. Quizás incluso tu ADN.

+

Y luego está todo lo que solo tú sabes — tu peso, tu presión arterial, tu programa de entrenamiento, los suplementos que tomas, los síntomas que siempre olvidas mencionar.

+

Todo está ahí — pero disperso en sistemas que no se comunican, con especialistas que solo ven su parte, o encerrado en tu propia cabeza.

+

Tu cardiólogo no sabe lo que encontró tu neurólogo. Tu entrenador no ha visto tus análisis de sangre. Tu médico no tiene idea de qué suplementos tomas. Y ninguno de ellos tiene tiempo para sentarse contigo y conectar los puntos.

+

La IA finalmente puede. Puede unir lo que ningún experto solo ve — y además explicártelo.

+

Pero estos datos no caben en una ventana de chat. Y lo último que quieres es tu historial médico en los servidores de alguien más, entrenando sus modelos.

+

inou lo une todo — laboratorio, imágenes, genética, signos vitales, medicamentos, suplementos — encriptado, privado, y sin compartir con absolutamente nadie. Tu IA se conecta de forma segura. Tus datos siguen siendo tuyos.

+
Tu salud, comprendida.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_fi.tmpl b/templates/landing_fi.tmpl new file mode 100644 index 0000000..78fbcc1 --- /dev/null +++ b/templates/landing_fi.tmpl @@ -0,0 +1,121 @@ +{{define "landing_fi"}} + +
+
+
+
inou järjestää ja jakaa terveystietosi tekoälysi kanssa — turvallisesti ja yksityisesti.
+
Terveytesi, ymmärrettynä.
+
{{if .Dossier}}Kutsu ystävä{{else}}Kirjaudu{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Tarvitset tekoälyä terveytesi tueksi

+
+

Terveystietosi ovat hajallaan kymmenissä paikoissa — kardiologillasi, neurologillasi, laboratoriossa, älykellossasi, sovelluksissasi, 23andMe:ssä. Ja vain sinä tiedät loput: mitä syöt, mitä juot, mitä lisäravinteita otat. Harjoitteluohjelmasi. Oireesi. Tavoitteesi — yrititpä tulla raskaaksi, harjoitella maratoniin tai vain yrität tuntea itsesi vähemmän väsyneeksi.

+

Olitpa terve ja haluat pysyä sellaisena, navigoit vaikeaa diagnoosia tai hoidat perheenjäsentä joka ei pysty puolustamaan itseään — yksikään lääkäri ei näe kokonaiskuvaa. Mikään järjestelmä ei yhdistä kaikkea.

+

Mutta sinulla on pääsy kaikkeen. Sinulta puuttuu vain asiantuntemus ymmärtääksesi kaiken.

+

Tekoälylläsi se on. inou antaa sille kokonaiskuvan.

+
+
+
+
+
+

Haaste

+
MRI-kuvauksessasi on 4 000 leikettä.
Se luettiin 10 minuutissa.
+
Genomissasi on miljoonia variantteja.
Sait tietää vain silmiesi värin ja mistä esi-isäsi tulivat.
+
Verikokeissasi on kymmeniä merkkiaineita.
Lääkärisi sanoi "kaikki näyttää hyvältä."
+
Kellosi on tallentanut 10 000 tuntia unta.
Valmentajasi ei tiedä sen olemassaolosta.
+
Olet kokeillut sataa eri lisäravinnetta.
Kukaan ei kysynyt mitä.
+
Yhteydet ovat siellä.
Ne ovat vain liian monimutkaisia yhdelle ihmiselle.
+
+ Kukaan ei tiedä miten kehosi käsittelee Warfariinia — et sinäkään. + Mutta vastaus saattaa piillä 23andMe:ssäsi. + Se "ei poikkeavaa" MRI:ssäsi — katsottiinko todella kaikki 4 000 leikettä huolellisesti? + Kilpirauhasesi on "viitearvoissa" — mutta kukaan ei yhdistänyt sitä väsymykseesi, painoosi, siihen että sinulla on aina kylmä. +
+
+ Kukaan ei yhdistä iltapäiväkahviasi unen laatuusi. + Rautatasojasi harjoitusväsymykseesi. + Genetiikkaasi aivosumuusi. +
+
+ Tekoälysi ei unohda. + Ei kiirehdi. + Löytää sen mikä jäi huomaamatta. + Ei erikoistu — näkee sinut kokonaisuutena. +
+
inou antaa tekoälysi ottaa kaiken huomioon — jokaisen leikkeen, jokaisen merkkiaineen, jokaisen variantin — yhdistää kaiken ja antaa sinulle vihdoin vastauksia joita kukaan muu ei voinut antaa.
+
+
+
+
+

Miksi rakensimme tämän

+

Olet kerännyt vuosien terveystietoja. Tutkimuksia sairaalasta. Kokeita laboratoriosta. Tuloksia potilasportaalista. Dataa kellostasi. Ehkä jopa DNA:si.

+

Ja sitten on kaikki mitä vain sinä tiedät — painosi, verenpaineesi, harjoitusohjelmasi, ottamasi lisäravinteet, oireet jotka unohdat aina mainita.

+

Kaikki on siellä — mutta hajallaan järjestelmissä jotka eivät keskustele keskenään, erikoislääkäreillä jotka näkevät vain oman osansa, tai lukittuna omaan päähäsi.

+

Kardiologisi ei tiedä mitä neurologisi löysi. Valmentajasi ei ole nähnyt verikokeittasi. Lääkärisi ei tiedä mitä lisäravinteita otat. Eikä kenelläkään heistä ole aikaa istua alas kanssasi ja yhdistää pisteitä.

+

Tekoäly vihdoin pystyy. Se voi koota yhteen sen mitä yksikään yksittäinen asiantuntija ei näe — ja selittää sen sinulle.

+

Mutta nämä tiedot eivät mahdu chat-ikkunaan. Ja viimeinen asia mitä haluat on sairaushistoriasi jonkun muun palvelimilla, kouluttamassa heidän mallejaan.

+

inou kokoaa kaiken yhteen — laboratorio, kuvantaminen, genetiikka, vitaalit, lääkkeet, lisäravinteet — salattuna, yksityisenä, eikä jaeta kenellekään. Tekoälysi yhdistää turvallisesti. Tietosi pysyvät sinun.

+
Terveytesi, ymmärrettynä.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_fr.tmpl b/templates/landing_fr.tmpl new file mode 100644 index 0000000..87740d1 --- /dev/null +++ b/templates/landing_fr.tmpl @@ -0,0 +1,121 @@ +{{define "landing_fr"}} + +
+
+
+
inou organise et partage votre dossier santé avec votre IA — en toute sécurité et confidentialité.
+
Votre santé, comprise.
+
{{if .Dossier}}Inviter un ami{{else}}Se connecter{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Vous avez besoin de l'IA pour votre santé

+
+

Vos données de santé sont dispersées dans des dizaines d'endroits — chez votre cardiologue, votre neurologue, le laboratoire, votre montre connectée, vos applications, votre 23andMe. Et vous seul connaissez le reste : ce que vous mangez, ce que vous buvez, quels compléments vous prenez. Votre programme d'entraînement. Vos symptômes. Vos objectifs — que vous essayiez de tomber enceinte, de vous préparer pour un marathon, ou simplement de vous sentir moins fatigué.

+

Que vous soyez en bonne santé et vouliez le rester, que vous naviguiez un diagnostic difficile, ou que vous vous occupiez d'un proche qui ne peut pas se défendre seul — aucun médecin ne voit le tableau complet. Aucun système ne connecte tout.

+

Mais vous avez accès à tout. Il vous manque juste l'expertise pour tout comprendre.

+

Votre IA l'a. inou lui donne le tableau complet.

+
+
+
+
+
+

Le défi

+
Votre IRM contient 4 000 coupes.
Elle a été lue en 10 minutes.
+
Votre génome contient des millions de variants.
Vous n'avez appris que la couleur de vos yeux et l'origine de vos ancêtres.
+
Votre bilan sanguin contient des dizaines de marqueurs.
Votre médecin a dit "tout va bien."
+
Votre montre a enregistré 10 000 heures de sommeil.
Votre coach ne sait pas qu'elle existe.
+
Vous avez essayé une centaine de compléments différents.
Personne n'a demandé lesquels.
+
Les connexions sont là.
Elles sont juste trop complexes pour une seule personne.
+
+ Personne ne sait comment votre corps métabolise la Warfarine — pas même vous. + Mais la réponse se cache peut-être déjà dans votre 23andMe. + Ce "sans particularité" sur votre IRM — quelqu'un a-t-il vraiment regardé les 4 000 coupes attentivement ? + Votre thyroïde est "dans les normes" — mais personne n'a fait le lien avec votre fatigue, votre poids, le fait que vous avez toujours froid. +
+
+ Personne ne relie votre café de l'après-midi à votre qualité de sommeil. + Votre taux de fer à votre fatigue à l'entraînement. + Votre génétique à votre brouillard mental. +
+
+ Votre IA n'oublie pas. + Ne se précipite pas. + Trouve ce qui a été manqué. + Ne se spécialise pas — vous voit dans votre globalité. +
+
inou permet à votre IA de tout prendre en compte — chaque coupe, chaque marqueur, chaque variant — de tout connecter et de vous donner enfin des réponses que personne d'autre ne pouvait donner.
+
+
+
+
+

Pourquoi nous avons créé ça

+

Vous avez collecté des années de données de santé. Des examens de l'hôpital. Des analyses du laboratoire. Des résultats du portail patient. Des données de votre montre. Peut-être même votre ADN.

+

Et puis il y a tout ce que vous seul savez — votre poids, votre tension, votre programme d'entraînement, les compléments que vous prenez, les symptômes que vous oubliez toujours de mentionner.

+

Tout est là — mais dispersé dans des systèmes qui ne communiquent pas entre eux, chez des spécialistes qui ne voient que leur partie, ou enfermé dans votre propre tête.

+

Votre cardiologue ne sait pas ce que votre neurologue a trouvé. Votre coach n'a pas vu vos analyses sanguines. Votre médecin n'a aucune idée des compléments que vous prenez. Et aucun d'entre eux n'a le temps de s'asseoir avec vous pour relier les points.

+

L'IA peut enfin le faire. Elle peut rassembler ce qu'aucun expert seul ne voit — et vous l'expliquer en plus.

+

Mais ces données ne tiennent pas dans une fenêtre de chat. Et la dernière chose que vous voulez, c'est votre historique médical sur les serveurs de quelqu'un d'autre, entraînant leurs modèles.

+

inou rassemble tout — analyses, imagerie, génétique, constantes, médicaments, compléments — chiffré, privé, et partagé avec absolument personne. Votre IA se connecte en toute sécurité. Vos données restent les vôtres.

+
Votre santé, comprise.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_it.tmpl b/templates/landing_it.tmpl new file mode 100644 index 0000000..91da4c0 --- /dev/null +++ b/templates/landing_it.tmpl @@ -0,0 +1,121 @@ +{{define "landing_it"}} + +
+
+
+
inou organizza e condivide il tuo dossier sanitario con la tua IA — in modo sicuro e privato.
+
La tua salute, compresa.
+
{{if .Dossier}}Invita un amico{{else}}Accedi{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Hai bisogno dell'IA per la tua salute

+
+

I tuoi dati sanitari sono sparsi in decine di posti — dal cardiologo, dal neurologo, nel laboratorio, nel tuo smartwatch, nelle tue app, nel tuo 23andMe. E solo tu conosci il resto: cosa mangi, cosa bevi, quali integratori prendi. La tua routine di allenamento. I tuoi sintomi. I tuoi obiettivi — che tu stia cercando di rimanere incinta, allenarti per una maratona, o semplicemente sentirti meno stanco.

+

Che tu sia in salute e voglia restarlo, stia affrontando una diagnosi difficile, o ti stia prendendo cura di un familiare che non può difendersi da solo — nessun medico vede il quadro completo. Nessun sistema connette tutto.

+

Ma tu hai accesso a tutto. Ti manca solo l'esperienza per capire tutto.

+

La tua IA ce l'ha. inou le dà il quadro completo.

+
+
+
+
+
+

La sfida

+
La tua risonanza ha 4.000 sezioni.
È stata letta in 10 minuti.
+
Il tuo genoma ha milioni di varianti.
Hai scoperto solo il colore dei tuoi occhi e da dove vengono i tuoi antenati.
+
Le tue analisi del sangue hanno decine di marcatori.
Il tuo medico ha detto "tutto bene."
+
Il tuo orologio ha registrato 10.000 ore di sonno.
Il tuo trainer non sa che esiste.
+
Hai provato cento integratori diversi.
Nessuno ha chiesto quali.
+
Le connessioni ci sono.
Sono troppo complesse per una sola persona.
+
+ Nessuno sa come il tuo corpo metabolizza il Warfarin — nemmeno tu. + Ma la risposta potrebbe essere nascosta nel tuo 23andMe. + Quel "nella norma" nella tua risonanza — qualcuno ha davvero guardato tutte le 4.000 sezioni con attenzione? + La tua tiroide è "nei range" — ma nessuno l'ha collegata alla tua stanchezza, al tuo peso, al fatto che hai sempre freddo. +
+
+ Nessuno collega il tuo caffè pomeridiano alla qualità del tuo sonno. + I tuoi livelli di ferro alla tua stanchezza in allenamento. + La tua genetica alla tua nebbia mentale. +
+
+ La tua IA non dimentica. + Non ha fretta. + Trova quello che è stato trascurato. + Non si specializza — ti vede nella tua interezza. +
+
inou permette alla tua IA di considerare tutto — ogni sezione, ogni marcatore, ogni variante — connette tutto e finalmente ti dà risposte che nessun altro poteva dare.
+
+
+
+
+

Perché abbiamo costruito questo

+

Hai raccolto anni di dati sanitari. Esami dall'ospedale. Analisi dal laboratorio. Risultati dal portale del medico. Dati dal tuo orologio. Forse anche il tuo DNA.

+

E poi c'è tutto quello che solo tu sai — il tuo peso, la tua pressione, la tua routine di allenamento, gli integratori che prendi, i sintomi che dimentichi sempre di menzionare.

+

È tutto lì — ma sparso in sistemi che non comunicano, con specialisti che vedono solo la loro parte, o chiuso nella tua testa.

+

Il tuo cardiologo non sa cosa ha trovato il tuo neurologo. Il tuo trainer non ha visto le tue analisi del sangue. Il tuo medico non ha idea di quali integratori prendi. E nessuno di loro ha tempo di sedersi con te e collegare i punti.

+

L'IA finalmente può. Può unire quello che nessun esperto da solo vede — e spiegartelo anche.

+

Ma questi dati non entrano in una finestra di chat. E l'ultima cosa che vuoi è la tua storia clinica sui server di qualcun altro, ad addestrare i loro modelli.

+

inou unisce tutto — laboratorio, imaging, genetica, parametri vitali, farmaci, integratori — crittografato, privato, e non condiviso con assolutamente nessuno. La tua IA si connette in sicurezza. I tuoi dati restano tuoi.

+
La tua salute, compresa.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_ja.tmpl b/templates/landing_ja.tmpl new file mode 100644 index 0000000..70c4b72 --- /dev/null +++ b/templates/landing_ja.tmpl @@ -0,0 +1,121 @@ +{{define "landing_ja"}} + +
+
+
+
inouはあなたの健康記録を整理し、AIと安全かつプライベートに共有します。
+
あなたの健康を、理解する。
+
{{if .Dossier}}友達を招待{{else}}ログイン{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

健康管理にAIが必要な理由

+
+

あなたの健康データは何十もの場所に散らばっています — 循環器科、神経科、検査機関、スマートウォッチ、アプリ、23andMe。そして残りを知っているのはあなただけ:何を食べ、何を飲み、どんなサプリを取っているか。運動習慣。症状。目標 — 妊娠を目指しているのか、マラソンのトレーニング中なのか、単に疲れを減らしたいだけなのか。

+

健康を維持したい人も、難しい診断と向き合っている人も、自分で主張できない家族の世話をしている人も — どの医師も全体像を見ていません。すべてをつなぐシステムはありません。

+

でも、あなたはすべてにアクセスできます。足りないのは、それを理解する専門知識だけです。

+

あなたのAIにはそれがあります。inouは全体像を与えます。

+
+
+
+
+
+

課題

+
あなたのMRIには4,000枚のスライスがあります。
10分で読影されました。
+
あなたのゲノムには数百万の変異があります。
わかったのは目の色と祖先のルーツだけでした。
+
あなたの血液検査には数十のマーカーがあります。
医師は「問題ありません」と言いました。
+
あなたの時計は10,000時間の睡眠を記録しています。
トレーナーはその存在を知りません。
+
あなたは100種類のサプリメントを試しました。
誰も何を試したか聞きませんでした。
+
つながりはそこにあります。
ただ、一人の人間には複雑すぎるのです。
+
+ あなたの体がワーファリンをどう代謝するか、誰も知りません — あなた自身も。 + でも答えは23andMeに隠れているかもしれません。 + MRIの「異常なし」— 誰かが4,000枚すべてを本当に注意深く見ましたか? + 甲状腺は「基準値内」— でも誰もそれをあなたの疲労、体重、いつも寒いことと結びつけていません。 +
+
+ 誰も午後のコーヒーと睡眠の質を結びつけていません。 + 鉄分レベルとトレーニング時の疲労を。 + 遺伝子とブレインフォグを。 +
+
+ あなたのAIは忘れません。 + 急ぎません。 + 見落とされたものを見つけます。 + 専門分野を持たず — あなたを全体として見ます。 +
+
inouはあなたのAIにすべてを考慮させます — すべてのスライス、すべてのマーカー、すべての変異 — すべてをつなぎ、他の誰も答えられなかった答えをついに提供します。
+
+
+
+
+

私たちがこれを作った理由

+

あなたは何年もの健康データを蓄積してきました。病院の検査。検査機関の結果。患者ポータルの記録。時計のデータ。DNAかもしれません。

+

そして、あなただけが知っていることがあります — 体重、血圧、トレーニングプログラム、飲んでいるサプリ、いつも言い忘れる症状。

+

すべてそこにあります — でも、互いに通信しないシステム、自分の専門分野しか見ない専門医、またはあなた自身の頭の中に閉じ込められています。

+

循環器科医は神経科医が何を見つけたか知りません。トレーナーは血液検査を見ていません。かかりつけ医はどんなサプリを飲んでいるか知りません。そして誰も、あなたと一緒に座って点をつなぐ時間がありません。

+

AIならついにできます。どの専門家単独では見えないものをまとめ — さらにそれを説明してくれます。

+

でも、このデータはチャットウィンドウに収まりません。そして最も避けたいのは、あなたの医療履歴が他人のサーバーに置かれ、そのモデルを訓練することです。

+

inouはすべてをまとめます — 検査、画像、遺伝子、バイタル、薬、サプリ — 暗号化され、プライベートで、誰とも共有されません。あなたのAIは安全に接続します。データはあなたのものです。

+
あなたの健康を、理解する。
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_ko.tmpl b/templates/landing_ko.tmpl new file mode 100644 index 0000000..b49930b --- /dev/null +++ b/templates/landing_ko.tmpl @@ -0,0 +1,121 @@ +{{define "landing_ko"}} + +
+
+
+
inou는 당신의 건강 기록을 정리하고 AI와 안전하고 비공개로 공유합니다.
+
당신의 건강, 이해되다.
+
{{if .Dossier}}친구 초대{{else}}로그인{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

건강을 위해 AI가 필요한 이유

+
+

당신의 건강 데이터는 수십 곳에 흩어져 있습니다 — 심장내과, 신경과, 검사실, 스마트워치, 앱, 23andMe. 그리고 나머지는 당신만 알고 있습니다: 무엇을 먹고, 무엇을 마시고, 어떤 보충제를 복용하는지. 운동 루틴. 증상. 목표 — 임신을 원하든, 마라톤 훈련 중이든, 단순히 덜 피곤해지고 싶든.

+

건강하고 그 상태를 유지하고 싶든, 어려운 진단을 받았든, 스스로를 대변할 수 없는 가족을 돌보든 — 어떤 의사도 전체 그림을 보지 못합니다. 모든 것을 연결하는 시스템은 없습니다.

+

하지만 당신은 모든 것에 접근할 수 있습니다. 부족한 것은 이해하는 전문 지식뿐입니다.

+

당신의 AI는 그것을 가지고 있습니다. inou는 전체 그림을 제공합니다.

+
+
+
+
+
+

도전

+
당신의 MRI에는 4,000개의 슬라이스가 있습니다.
10분 만에 판독되었습니다.
+
당신의 게놈에는 수백만 개의 변이가 있습니다.
알게 된 것은 눈 색깔과 조상의 출신지뿐이었습니다.
+
당신의 혈액 검사에는 수십 개의 마커가 있습니다.
의사는 "다 괜찮아 보입니다"라고 했습니다.
+
당신의 시계는 10,000시간의 수면을 기록했습니다.
트레이너는 그 존재를 모릅니다.
+
당신은 백 가지 다른 보충제를 시도했습니다.
아무도 어떤 것인지 묻지 않았습니다.
+
연결고리는 거기 있습니다.
한 사람이 다루기엔 너무 복잡할 뿐입니다.
+
+ 당신의 몸이 와파린을 어떻게 처리하는지 아무도 모릅니다 — 당신 자신도. + 하지만 답은 23andMe에 숨겨져 있을 수 있습니다. + MRI의 그 "이상 없음" — 누군가 정말로 4,000개 슬라이스를 모두 주의 깊게 봤을까요? + 갑상선은 "정상 범위" — 하지만 아무도 그것을 피로, 체중, 항상 추운 것과 연결하지 않았습니다. +
+
+ 아무도 오후 커피와 수면의 질을 연결하지 않습니다. + 철분 수치와 운동 시 피로를. + 유전자와 브레인 포그를. +
+
+ 당신의 AI는 잊지 않습니다. + 서두르지 않습니다. + 놓친 것을 찾습니다. + 전문화되지 않습니다 — 당신을 전체로 봅니다. +
+
inou는 당신의 AI가 모든 것을 고려하게 합니다 — 모든 슬라이스, 모든 마커, 모든 변이 — 모든 것을 연결하고 마침내 다른 누구도 줄 수 없었던 답을 제공합니다.
+
+
+
+
+

우리가 이것을 만든 이유

+

당신은 수년간의 건강 데이터를 수집해 왔습니다. 병원 검사. 검사실 결과. 환자 포털의 기록. 시계의 데이터. 어쩌면 DNA까지.

+

그리고 당신만 아는 것들이 있습니다 — 체중, 혈압, 운동 프로그램, 복용하는 보충제, 항상 말하기를 잊는 증상들.

+

모든 것이 거기 있습니다 — 하지만 서로 소통하지 않는 시스템들, 자기 분야만 보는 전문의들, 또는 당신 머릿속에 갇혀 있습니다.

+

심장내과 의사는 신경과 의사가 무엇을 발견했는지 모릅니다. 트레이너는 혈액 검사를 보지 못했습니다. 주치의는 어떤 보충제를 복용하는지 모릅니다. 그리고 그들 중 누구도 당신과 함께 앉아서 점들을 연결할 시간이 없습니다.

+

AI는 마침내 할 수 있습니다. 어떤 전문가 혼자서도 볼 수 없는 것을 모으고 — 게다가 설명해 줍니다.

+

하지만 이 데이터는 채팅 창에 들어가지 않습니다. 그리고 가장 원하지 않는 것은 당신의 의료 기록이 다른 사람의 서버에서 그들의 모델을 훈련시키는 것입니다.

+

inou는 모든 것을 모읍니다 — 검사, 영상, 유전자, 활력징후, 약물, 보충제 — 암호화되고, 비공개이며, 누구와도 공유되지 않습니다. 당신의 AI는 안전하게 연결됩니다. 데이터는 당신의 것입니다.

+
당신의 건강, 이해되다.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_nl.tmpl b/templates/landing_nl.tmpl new file mode 100644 index 0000000..8a74f50 --- /dev/null +++ b/templates/landing_nl.tmpl @@ -0,0 +1,343 @@ +{{define "landing_nl"}} + + +
+ +
+
+
inou organiseert en deelt je gezondheidsdossier met je AI — veilig en privé.
+
Je gezondheid, begrepen.
+
+ {{if .Dossier}}Vriend uitnodigen{{else}}Inloggen{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+
+

Je hebt AI nodig voor je gezondheid

+ +
+

Je gezondheidsgegevens liggen verspreid over tientallen plekken — bij je cardioloog, je neuroloog, het lab, je smartwatch, je apps, je 23andMe. En dan is er nog alles wat alleen jij weet: wat je eet, wat je drinkt, welke supplementen je slikt. Je trainingsschema. Je klachten. Je doelen — of je nu zwanger wilt worden, traint voor een marathon, of gewoon minder moe wilt zijn.

+ +

Of je nu gezond bent en dat wilt blijven, worstelt met een lastige diagnose, of zorgt voor een familielid dat niet voor zichzelf kan opkomen — geen enkele arts ziet het complete plaatje. Geen enkel systeem verbindt het.

+ +

Maar jij hebt toegang tot alles. Je mist alleen de expertise om er iets van te maken.

+ +

Je AI wel. inou geeft het het complete plaatje.

+
+
+
+ +
+
+

De uitdaging

+
+
Je MRI heeft 4.000 beelden.
+
Die werd in 10 minuten beoordeeld.
+
+ +
+
Je genoom heeft miljoenen varianten.
+
Je leerde alleen je oogkleur en waar je voorouders vandaan kwamen.
+
+ +
+
Je bloedonderzoek heeft tientallen markers.
+
Je arts zei "alles ziet er goed uit."
+
+ +
+
Je horloge heeft 10.000 uur slaap bijgehouden.
+
Je trainer weet niet dat het bestaat.
+
+ +
+
Je hebt honderd verschillende supplementen geprobeerd.
+
Niemand vroeg welke.
+
+ +
+ De verbanden zijn er.
+ Ze zijn alleen te complex voor één persoon om te overzien. +
+ +
+ Niemand weet hoe jouw lichaam Warfarine verwerkt — jijzelf ook niet. + Maar het antwoord zit misschien al in je 23andMe. + Die 'geen bijzonderheden' op je MRI — heeft iemand echt alle 4.000 beelden bekeken? + Je schildklier is 'binnen de norm' — maar niemand legde het verband met je vermoeidheid, je gewicht, dat je het altijd koud hebt. +
+ +
+ Niemand verbindt je middagkoffie aan je slaapkwaliteit. + Je ijzergehalte aan je sportvermoeidheid. + Je genetica aan je brain fog. +
+ +
+ Je AI vergeet niet. + Haast niet. + Vindt wat gemist werd. + Specialiseert niet — ziet de complete jij. +
+ +
inou laat je AI alles meewegen — elk beeld, elke marker, elke variant — verbindt alles en geeft je eindelijk antwoorden die niemand anders kon geven.
+
+
+ +
+
+

Waarom we dit bouwden

+ +

Je hebt jarenlang gezondheidsgegevens verzameld. Scans van het ziekenhuis. Bloedonderzoek van het lab. Uitslagen uit het patiëntenportaal. Data van je horloge. Misschien zelfs je DNA.

+ +

En dan is er nog alles wat alleen jij weet — je gewicht, je bloeddruk, je trainingsschema, de supplementen die je slikt, de klachten die je steeds vergeet te noemen.

+ +

Het is er allemaal — maar verspreid over systemen die niet met elkaar praten, bij specialisten die alleen hun eigen stukje zien, of opgesloten in je eigen hoofd.

+ +

Je cardioloog weet niet wat je neuroloog vond. Je trainer heeft je bloedonderzoek niet gezien. Je huisarts heeft geen idee welke supplementen je slikt. En geen van hen heeft tijd om met je te zitten en de puzzel te leggen.

+ +

AI kan dat eindelijk. Het kan samenbrengen wat geen enkele expert ziet — en het je ook nog uitleggen.

+ +

Maar deze data past niet in een chatvenster. En het laatste wat je wilt is je medische geschiedenis op andermans servers, gebruikt om hun modellen te trainen.

+ +

inou brengt alles samen — labs, beeldvorming, genetica, vitals, medicatie, supplementen — versleuteld, privé, en met niemand gedeeld. Je AI verbindt veilig. Je data blijft van jou.

+ +
Je gezondheid, begrepen.
+
+
+ +
+ +
+
+ {{.T.never_training}} + {{.T.never_training_desc}} +
+
+ {{.T.never_shared}} + {{.T.never_shared_desc}} +
+
+ {{.T.encrypted}} + {{.T.encrypted_desc}} +
+
+ {{.T.delete}} + {{.T.delete_desc}} +
+
+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/landing_no.tmpl b/templates/landing_no.tmpl new file mode 100644 index 0000000..b989ceb --- /dev/null +++ b/templates/landing_no.tmpl @@ -0,0 +1,121 @@ +{{define "landing_no"}} + +
+
+
+
inou organiserer og deler helsedossieren din med din AI — sikkert og privat.
+
Din helse, forstått.
+
{{if .Dossier}}Inviter en venn{{else}}Logg inn{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Du trenger AI for helsen din

+
+

Helsedataene dine er spredt på dusinvis av steder — hos kardiologen din, nevrologen din, labben, smartklokken din, appene dine, 23andMe. Og bare du kjenner resten: hva du spiser, hva du drikker, hvilke kosttilskudd du tar. Treningsrutinen din. Symptomene dine. Målene dine — enten du prøver å bli gravid, trener til et maraton, eller bare prøver å føle deg mindre sliten.

+

Enten du er frisk og vil forbli det, navigerer en vanskelig diagnose, eller tar vare på et familiemedlem som ikke kan tale for seg selv — ingen enkelt lege ser hele bildet. Ingen system kobler alt sammen.

+

Men du har tilgang til alt. Du mangler bare ekspertisen til å forstå det.

+

Din AI har den. inou gir den hele bildet.

+
+
+
+
+
+

Utfordringen

+
MR-en din har 4 000 snitt.
Den ble lest på 10 minutter.
+
Genomet ditt har millioner av varianter.
Du fikk bare vite øyenfargen din og hvor forfedrene dine kom fra.
+
Blodprøvene dine har dusinvis av markører.
Legen din sa "alt ser bra ut."
+
Klokken din har registrert 10 000 timer søvn.
Treneren din vet ikke at den finnes.
+
Du har prøvd hundre forskjellige kosttilskudd.
Ingen spurte hvilke.
+
Koblingene er der.
De er bare for komplekse for én person.
+
+ Ingen vet hvordan kroppen din prosesserer Warfarin — ikke engang du. + Men svaret kan ligge gjemt i 23andMe. + Det "uten anmerkning" på MR-en din — så noen virkelig nøye på alle 4 000 snittene? + Skjoldbruskkjertelen din er "innenfor normalverdier" — men ingen koblet det til trettheten din, vekten din, at du alltid fryser. +
+
+ Ingen kobler ettermiddagskaffen din til søvnkvaliteten din. + Jernverdiene dine til trettheten din under trening. + Genetikken din til hjernetåken din. +
+
+ Din AI glemmer ikke. + Stresser ikke. + Finner det som ble oversett. + Spesialiserer seg ikke — ser deg som helhet. +
+
inou lar din AI ta hensyn til alt — hvert snitt, hver markør, hver variant — kobler alt sammen og gir deg endelig svar som ingen andre kunne gi.
+
+
+
+
+

Hvorfor vi bygde dette

+

Du har samlet år med helsedata. Undersøkelser fra sykehuset. Prøver fra labben. Resultater fra pasientportalen. Data fra klokken din. Kanskje til og med DNA-et ditt.

+

Og så er det alt som bare du vet — vekten din, blodtrykket ditt, treningsprogrammet ditt, tilskuddene du tar, symptomene du alltid glemmer å nevne.

+

Alt er der — men spredt i systemer som ikke snakker sammen, hos spesialister som bare ser sin del, eller låst inne i ditt eget hode.

+

Kardiologen din vet ikke hva nevrologen din fant. Treneren din har ikke sett blodprøvene dine. Legen din aner ikke hvilke tilskudd du tar. Og ingen av dem har tid til å sette seg ned med deg og koble prikkene.

+

AI kan endelig gjøre det. Den kan samle det som ingen enkelt ekspert ser — og forklare det for deg i tillegg.

+

Men disse dataene får ikke plass i et chatvindu. Og det siste du vil er sykehistorikken din på andres servere, som trener modellene deres.

+

inou samler alt — lab, bildediagnostikk, genetikk, vitale tegn, medisiner, tilskudd — kryptert, privat, og delt med absolutt ingen. Din AI kobler seg til sikkert. Dataene dine forblir dine.

+
Din helse, forstått.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_pt.tmpl b/templates/landing_pt.tmpl new file mode 100644 index 0000000..6eb4923 --- /dev/null +++ b/templates/landing_pt.tmpl @@ -0,0 +1,121 @@ +{{define "landing_pt"}} + +
+
+
+
inou organiza e compartilha seu dossiê de saúde com sua IA — de forma segura e privada.
+
Sua saúde, compreendida.
+
{{if .Dossier}}Convidar um amigo{{else}}Entrar{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Você precisa de IA para sua saúde

+
+

Seus dados de saúde estão espalhados em dezenas de lugares — com seu cardiologista, seu neurologista, o laboratório, seu relógio inteligente, seus apps, seu 23andMe. E só você sabe o resto: o que você come, o que bebe, quais suplementos toma. Sua rotina de exercícios. Seus sintomas. Seus objetivos — seja tentando engravidar, treinando para uma maratona, ou apenas tentando se sentir menos cansado.

+

Seja você saudável e querendo continuar assim, navegando um diagnóstico difícil, ou cuidando de um familiar que não pode se defender sozinho — nenhum médico vê o quadro completo. Nenhum sistema conecta tudo.

+

Mas você tem acesso a tudo. Só falta a expertise para entender tudo.

+

Sua IA tem. inou dá a ela o quadro completo.

+
+
+
+
+
+

O desafio

+
Sua ressonância tem 4.000 cortes.
Foi lida em 10 minutos.
+
Seu genoma tem milhões de variantes.
Você só descobriu a cor dos seus olhos e de onde vieram seus ancestrais.
+
Seu exame de sangue tem dezenas de marcadores.
Seu médico disse "está tudo bem."
+
Seu relógio registrou 10.000 horas de sono.
Seu treinador não sabe que ele existe.
+
Você já tentou cem suplementos diferentes.
Ninguém perguntou quais.
+
As conexões estão lá.
São complexas demais para uma pessoa só.
+
+ Ninguém sabe como seu corpo processa a Varfarina — nem você. + Mas a resposta pode estar escondida no seu 23andMe. + Aquele "sem alterações" na sua ressonância — alguém realmente olhou os 4.000 cortes com atenção? + Sua tireoide está "dentro do normal" — mas ninguém conectou com sua fadiga, seu peso, você sempre sentir frio. +
+
+ Ninguém conecta seu café da tarde com sua qualidade de sono. + Seus níveis de ferro com sua fadiga no treino. + Sua genética com sua névoa mental. +
+
+ Sua IA não esquece. + Não tem pressa. + Encontra o que foi perdido. + Não se especializa — vê você por inteiro. +
+
inou permite que sua IA considere tudo — cada corte, cada marcador, cada variante — conecta tudo e finalmente te dá respostas que ninguém mais conseguia dar.
+
+
+
+
+

Por que construímos isso

+

Você coletou anos de dados de saúde. Exames do hospital. Análises do laboratório. Resultados do portal do médico. Dados do seu relógio. Talvez até seu DNA.

+

E depois tem tudo que só você sabe — seu peso, sua pressão, sua rotina de treino, os suplementos que você toma, os sintomas que você sempre esquece de mencionar.

+

Está tudo lá — mas espalhado em sistemas que não conversam, com especialistas que só veem sua parte, ou preso na sua própria cabeça.

+

Seu cardiologista não sabe o que seu neurologista encontrou. Seu treinador não viu seus exames de sangue. Seu médico não faz ideia de quais suplementos você toma. E nenhum deles tem tempo para sentar com você e conectar os pontos.

+

A IA finalmente pode. Ela pode unir o que nenhum especialista sozinho vê — e ainda explicar para você.

+

Mas esses dados não cabem numa janela de chat. E a última coisa que você quer é seu histórico médico nos servidores de outra pessoa, treinando os modelos deles.

+

inou une tudo — laboratório, imagens, genética, sinais vitais, medicamentos, suplementos — criptografado, privado, e sem compartilhar com absolutamente ninguém. Sua IA se conecta com segurança. Seus dados continuam sendo seus.

+
Sua saúde, compreendida.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_ru.tmpl b/templates/landing_ru.tmpl new file mode 100644 index 0000000..5258d43 --- /dev/null +++ b/templates/landing_ru.tmpl @@ -0,0 +1,343 @@ +{{define "landing_ru"}} + + +
+ +
+
+
inou организует и передаёт ваше медицинское досье вашему ИИ — безопасно и конфиденциально.
+
Ваше здоровье — понятно.
+
+ {{if .Dossier}}Пригласить друга{{else}}Войти{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+
+

Вам нужен ИИ для вашего здоровья

+ +
+

Ваши медицинские данные разбросаны по десяткам мест — у кардиолога, невролога, в лаборатории, в умных часах, приложениях, в 23andMe. И только вы знаете остальное: что вы едите, что пьёте, какие добавки принимаете. Ваш режим тренировок. Ваши симптомы. Ваши цели — хотите ли вы забеременеть, готовитесь к марафону или просто хотите меньше уставать.

+ +

Здоровы ли вы и хотите таким остаться, справляетесь со сложным диагнозом или заботитесь о близком, который не может сам за себя постоять — ни один врач не видит полную картину. Ни одна система не связывает всё воедино.

+ +

Но у вас есть доступ ко всему. Вам просто не хватает экспертизы, чтобы во всём разобраться.

+ +

У вашего ИИ она есть. inou даёт ему полную картину.

+
+
+
+ +
+
+

Проблема

+
+
В вашем МРТ 4 000 снимков.
+
Его прочитали за 10 минут.
+
+ +
+
В вашем геноме миллионы вариантов.
+
Вы узнали только цвет глаз и откуда ваши предки.
+
+ +
+
В вашем анализе крови десятки показателей.
+
Врач сказал «всё в норме».
+
+ +
+
Ваши часы отследили 10 000 часов сна.
+
Ваш тренер не знает, что они существуют.
+
+ +
+
Вы перепробовали сотню разных добавок.
+
Никто не спросил какие.
+
+ +
+ Связи есть.
+ Они просто слишком сложны для одного человека. +
+ +
+ Никто не знает, как ваш организм усваивает Варфарин — даже вы сами. + Но ответ, возможно, уже есть в вашем 23andMe. + Та «норма» в вашем МРТ — кто-нибудь внимательно посмотрел все 4 000 снимков? + Ваша щитовидка «в пределах нормы» — но никто не связал это с усталостью, весом, тем что вам всегда холодно. +
+ +
+ Никто не связывает ваш послеобеденный кофе с качеством сна. + Уровень железа с усталостью на тренировках. + Вашу генетику с туманом в голове. +
+ +
+ Ваш ИИ не забывает. + Не торопится. + Находит упущенное. + Не специализируется — видит вас целиком. +
+ +
inou позволяет вашему ИИ учесть всё — каждый снимок, каждый показатель, каждый вариант — связать всё воедино и наконец дать ответы, которые никто другой дать не мог.
+
+
+ +
+
+

Почему мы это создали

+ +

Вы годами собирали медицинские данные. Снимки из больницы. Анализы из лаборатории. Результаты с портала врача. Данные с часов. Может быть, даже ДНК.

+ +

И ещё всё то, что знаете только вы — ваш вес, давление, график тренировок, добавки, симптомы, о которых всё забываете упомянуть.

+ +

Всё это есть — но разбросано по системам, которые не общаются друг с другом, у специалистов, которые видят только свой кусочек, или заперто у вас в голове.

+ +

Ваш кардиолог не знает, что нашёл невролог. Ваш тренер не видел анализы крови. Ваш врач понятия не имеет, какие добавки вы принимаете. И ни у кого из них нет времени сесть с вами и собрать пазл.

+ +

ИИ наконец может. Он способен собрать воедино то, чего не видит ни один эксперт — и ещё и объяснить вам.

+ +

Но эти данные не помещаются в окно чата. И меньше всего вам нужно, чтобы ваша медицинская история оказалась на чужих серверах, обучая чужие модели.

+ +

inou собирает всё вместе — анализы, снимки, генетику, показатели, лекарства, добавки — зашифровано, конфиденциально, ни с кем не делится. Ваш ИИ подключается безопасно. Ваши данные остаются вашими.

+ +
Ваше здоровье — понятно.
+
+
+ +
+ +
+
+ {{.T.never_training}} + {{.T.never_training_desc}} +
+
+ {{.T.never_shared}} + {{.T.never_shared_desc}} +
+
+ {{.T.encrypted}} + {{.T.encrypted_desc}} +
+
+ {{.T.delete}} + {{.T.delete_desc}} +
+
+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/landing_sv.tmpl b/templates/landing_sv.tmpl new file mode 100644 index 0000000..ddf5908 --- /dev/null +++ b/templates/landing_sv.tmpl @@ -0,0 +1,121 @@ +{{define "landing_sv"}} + +
+
+
+
inou organiserar och delar din hälsodossier med din AI — säkert och privat.
+
Din hälsa, förstådd.
+
{{if .Dossier}}Bjud in en vän{{else}}Logga in{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

Du behöver AI för din hälsa

+
+

Dina hälsodata finns utspridda på dussintals ställen — hos din kardiolog, din neurolog, labbet, din smartklocka, dina appar, ditt 23andMe. Och bara du vet resten: vad du äter, vad du dricker, vilka kosttillskott du tar. Din träningsrutin. Dina symtom. Dina mål — oavsett om du försöker bli gravid, tränar för ett maraton, eller bara försöker känna dig mindre trött.

+

Oavsett om du är frisk och vill förbli det, navigerar en svår diagnos, eller tar hand om en familjemedlem som inte kan föra sin egen talan — ingen läkare ser hela bilden. Inget system kopplar ihop allt.

+

Men du har tillgång till allt. Du saknar bara expertisen att förstå allt.

+

Din AI har den. inou ger den hela bilden.

+
+
+
+
+
+

Utmaningen

+
Din MR har 4 000 snitt.
Den lästes på 10 minuter.
+
Ditt genom har miljontals varianter.
Du fick bara veta din ögonfärg och var dina förfäder kom ifrån.
+
Ditt blodprov har dussintals markörer.
Din läkare sa "allt ser bra ut."
+
Din klocka har registrerat 10 000 timmars sömn.
Din tränare vet inte att den finns.
+
Du har provat hundra olika kosttillskott.
Ingen frågade vilka.
+
Kopplingarna finns där.
De är bara för komplexa för en enda person.
+
+ Ingen vet hur din kropp metaboliserar Warfarin — inte ens du. + Men svaret kan redan vara gömt i ditt 23andMe. + Det där "utan anmärkning" på din MR — tittade någon verkligen noggrant på alla 4 000 snitt? + Din sköldkörtel är "inom normalvärden" — men ingen kopplade det till din trötthet, din vikt, att du alltid fryser. +
+
+ Ingen kopplar ditt eftermiddagskaffe till din sömnkvalitet. + Dina järnnivåer till din trötthet vid träning. + Din genetik till din hjärndimma. +
+
+ Din AI glömmer inte. + Stressar inte. + Hittar det som missades. + Specialiserar sig inte — ser dig som helhet. +
+
inou låter din AI ta hänsyn till allt — varje snitt, varje markör, varje variant — kopplar ihop allt och ger dig äntligen svar som ingen annan kunde ge.
+
+
+
+
+

Varför vi byggde detta

+

Du har samlat år av hälsodata. Undersökningar från sjukhuset. Prover från labbet. Resultat från patientportalen. Data från din klocka. Kanske till och med ditt DNA.

+

Och sedan finns allt som bara du vet — din vikt, ditt blodtryck, din träningsrutin, kosttillskotten du tar, symtomen du alltid glömmer nämna.

+

Allt finns där — men utspritt i system som inte pratar med varandra, hos specialister som bara ser sin del, eller låst i ditt eget huvud.

+

Din kardiolog vet inte vad din neurolog hittade. Din tränare har inte sett dina blodprover. Din läkare har ingen aning om vilka kosttillskott du tar. Och ingen av dem har tid att sitta ner med dig och koppla ihop punkterna.

+

AI kan äntligen göra det. Den kan samla det som ingen enskild expert ser — och dessutom förklara det för dig.

+

Men dessa data får inte plats i ett chattfönster. Och det sista du vill är din sjukdomshistorik på någon annans servrar, för att träna deras modeller.

+

inou samlar allt — labb, bilddiagnostik, genetik, vitalparametrar, mediciner, kosttillskott — krypterat, privat, och inte delat med absolut någon. Din AI ansluter säkert. Dina data förblir dina.

+
Din hälsa, förstådd.
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+ +
+{{end}} diff --git a/templates/landing_zh.tmpl b/templates/landing_zh.tmpl new file mode 100644 index 0000000..238ad5a --- /dev/null +++ b/templates/landing_zh.tmpl @@ -0,0 +1,121 @@ +{{define "landing_zh"}} + +
+
+
+
inou整理并与您的AI安全私密地共享您的健康档案。
+
您的健康,被理解。
+
{{if .Dossier}}邀请朋友{{else}}登录{{end}}{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+

您需要AI来管理健康

+
+

您的健康数据分散在数十个地方——心内科、神经科、检验科、智能手表、应用程序、23andMe。只有您知道其余的:吃什么、喝什么、服用什么补充剂。您的运动习惯。您的症状。您的目标——无论是想怀孕、为马拉松训练,还是只想少些疲惫。

+

无论您是健康并想保持健康,正在应对困难的诊断,还是在照顾无法为自己发声的家人——没有一位医生能看到全貌。没有系统能连接一切。

+

但您可以获取所有信息。您只是缺乏理解它的专业知识。

+

您的AI有这个能力。inou给它完整的画面。

+
+
+
+
+
+

挑战

+
您的MRI有4000张切片。
10分钟内被阅读完毕。
+
您的基因组有数百万个变异。
您只知道了眼睛颜色和祖先来源。
+
您的血液检查有数十个指标。
医生说一切看起来都好。
+
您的手表记录了10000小时的睡眠。
您的教练不知道它的存在。
+
您尝试过一百种不同的补充剂。
没人问过是哪些。
+
关联就在那里。
只是对一个人来说太复杂了。
+
+ 没人知道您的身体如何代谢华法林——包括您自己。 + 但答案可能就藏在您的23andMe里。 + MRI上那个未见异常——真的有人仔细看过所有4000张切片吗? + 您的甲状腺在正常范围内——但没人把它与您的疲劳、体重、总是怕冷联系起来。 +
+
+ 没人把您的下午咖啡与睡眠质量联系起来。 + 您的铁含量与训练疲劳。 + 您的基因与脑雾。 +
+
+ 您的AI不会忘记。 + 不会匆忙。 + 找到被遗漏的。 + 不专科——整体地看待您。 +
+
inou让您的AI考虑一切——每张切片、每个指标、每个变异——将所有连接起来,终于给您其他人无法给出的答案。
+
+
+
+
+

我们为什么创建这个

+

您收集了多年的健康数据。医院的检查。检验科的结果。患者门户的记录。手表的数据。也许还有您的DNA。

+

还有只有您知道的一切——您的体重、血压、训练计划、服用的补充剂、总是忘记提到的症状。

+

一切都在那里——但分散在互不沟通的系统中、只看自己领域的专科医生那里,或者锁在您自己的脑海中。

+

心内科医生不知道神经科医生发现了什么。您的教练没看过您的血液检查。您的医生不知道您服用什么补充剂。他们中没有一个人有时间坐下来与您一起连接这些点。

+

AI终于可以做到。它可以汇集任何单一专家都看不到的——还能向您解释。

+

但这些数据放不进聊天窗口。您最不想要的是您的病历在别人的服务器上,训练他们的模型。

+

inou将一切汇集——检验、影像、基因、生命体征、药物、补充剂——加密、私密,不与任何人共享。您的AI安全连接。数据仍然是您的。

+
您的健康,被理解。
+
+
+
+ +
+
{{.T.never_training}}{{.T.never_training_desc}}
+
{{.T.never_shared}}{{.T.never_shared_desc}}
+
{{.T.encrypted}}{{.T.encrypted_desc}}
+
{{.T.delete}}{{.T.delete_desc}}
+
+
+
+ + inou health +
+
+{{end}} diff --git a/templates/login.tmpl b/templates/login.tmpl new file mode 100644 index 0000000..e264416 --- /dev/null +++ b/templates/login.tmpl @@ -0,0 +1,32 @@ +{{define "login"}} +
+ +
+
+
inou health

Sign in

+

Enter your email to sign in

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ +
+ + +
+ +
+ +

+ We'll send you a verification code.
No password needed. +

+
+
+ + {{template "footer"}} + +
+ +{{end}} diff --git a/templates/minor_error.tmpl b/templates/minor_error.tmpl new file mode 100644 index 0000000..e8e167f --- /dev/null +++ b/templates/minor_error.tmpl @@ -0,0 +1,14 @@ +{{define "minor_error"}} +
+

{{.T.must_be_18}}

+ +
+

{{.T.minor_explanation}}

+

{{.T.minor_next_steps}}

+
+ + ← {{.T.use_different_dob}} + + {{template "footer"}} +
+{{end}} diff --git a/templates/onboard.tmpl b/templates/onboard.tmpl new file mode 100644 index 0000000..5dcd805 --- /dev/null +++ b/templates/onboard.tmpl @@ -0,0 +1,60 @@ +{{define "onboard"}} +
+ +
+
+
inou health
+

{{.T.create_dossier}}

+

{{.T.tell_us}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+ + +
+
+ + + +
+
+ +
+ + +
+
+ +
+
+
+ + {{template "footer"}} + +
+ +{{end}} \ No newline at end of file diff --git a/templates/permissions.tmpl b/templates/permissions.tmpl new file mode 100644 index 0000000..c67ad06 --- /dev/null +++ b/templates/permissions.tmpl @@ -0,0 +1,150 @@ +{{define "permissions"}} +
+ +
+
+
+
+

{{.T.permissions_title}}

+

{{.T.permissions_subtitle}}

+
+ ← {{.T.back}} +
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + + {{if .Success}} +
{{.Success}}
+ {{end}} + + +
+

{{.T.current_access}}

+ +
+ {{if .Grantees}} + {{range .Grantees}} +
+
+ {{.Name}} + {{.Role}} + {{.Ops}} +
+
+ Edit +
+ + + +
+
+
+ {{end}} + {{else}} +

{{.T.no_grantees}}

+ {{end}} +
+
+ + +
+

{{.T.grant_access}}

+ +
+ + +
+ + + {{.T.person_email_hint}} +
+ +
+ + +
+ +
+ + +
+ + + +
+ {{.T.cancel}} + +
+
+
+ + +
+

{{.T.role_descriptions}}

+
+ {{range .Roles}} +
+
+ {{.Name}} + {{.Ops}} +
+

{{.Description}}

+
+ {{end}} +
+ +
+

{{.T.ops_legend}}

+

+ r = {{.T.op_read_desc}}  ·  + w = {{.T.op_write_desc}}  ·  + d = {{.T.op_delete_desc}}  ·  + m = {{.T.op_manage_desc}} +

+
+
+
+
+
+ + +{{end}} diff --git a/templates/pricing.tmpl b/templates/pricing.tmpl new file mode 100644 index 0000000..7638361 --- /dev/null +++ b/templates/pricing.tmpl @@ -0,0 +1,274 @@ +{{define "pricing"}} + + +
+
+

Pricing

+

Choose the plan that fits your health journey

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Monitor
+
Free
+
+
Optimize
+
$12/mo
+
+
Research
+
$35/mo
+
Health Data
Vitals (BP, HR, weight, temp)
Symptoms & conditions
Medications
Exercise & activity
Family history
Lab results
Consumer genome (23andMe)
Medical imaging (MRI, CT, X-ray)
Clinical genome sequencing
AI Features
MCP integration (Claude, ChatGPT)
Personalized AI answersLimited
Health trend analysis
Storage & Access
Multi-dossier support (family)
FIPS 140-3 encryption
Data export
+
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/privacy.tmpl b/templates/privacy.tmpl new file mode 100644 index 0000000..6d17423 --- /dev/null +++ b/templates/privacy.tmpl @@ -0,0 +1,232 @@ +{{define "privacy"}} + + +
+ +
+

Your data. Your rules.

+

We built inou because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought.

+
+ +
+

What we collect

+ +

Account information.

+

Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old.

+ +

Medical files you upload.

+

DICOM images (MRI, CT, X-ray), lab results, genetic data, and any other health information you choose to share — photos, measurements, symptoms, or anything else you want to track or discuss with your AI.

+ +

Security logs.

+

IP addresses, for security purposes only. We do not collect physical addresses or phone numbers. Payment is handled by third-party processors — we never see your card details.

+
+ +
+

How we use it

+

Your data is used solely to store and display your medical information. We do not perform AI analysis — you connect your own AI tools to access your data. We do not use your data to train AI models or for any purpose beyond providing the service.

+
+ +
+

What we promise

+ +

We never share your data.

+

Not with advertisers. Not with partners. Not with anyone. We will comply with lawful requests from authorities (such as court orders or subpoenas), but nothing else. In the event of a company acquisition, your data would not be sold — it would either transfer under the same privacy terms or be deleted.

+ +

We never train AI on your data.

+

Your scans, your labs, your DNA — none of it feeds any model. Period.

+ +

We never sell your data.

+

There is no business model that involves your information. You are the customer, not the product.

+ +

We never track you.

+

No Google Analytics. No Meta pixels. No tracking scripts. We have no idea what you click, where you came from, or where you go next.

+ +

We never look at your data.

+

Access requires your explicit request, is restricted to senior staff, and is logged in both your audit trail and ours.

+ +

One cookie.

+

We use one cookie to keep you logged in. Your language preference is stored in your account. No tracking, no analytics, no third parties.

+
+ +
+

How we protect it

+ +

HIPAA-grade security.

+

HIPAA is the US law that governs how medical records must be protected. We follow those same standards.

+ +

FIPS 140-3 encryption.

+

FIPS 140-3 is the US government standard for cryptographic security. Your files are encrypted using FIPS 140-3 validated cryptography — tested, audited, and certified by independent labs.

+ +

Independent infrastructure.

+

We don't run on Big Tech clouds. No Google. No Amazon. No Microsoft. Data is stored on servers in the United States. If you access inou from outside the US, your data crosses international borders. We apply the same security and privacy protections regardless of your location.

+
+ +
+

What you control

+ +

See everything.

+

Request a full export of everything we store — in a format you can actually use.

+ +

Fix anything.

+

Found a mistake? You can correct it yourself, or ask us to help.

+ +

Delete everything.

+

One click. All your data — files, metadata, everything — permanently destroyed. No questions, no delays, no recovery. Backups exist solely to protect the service as a whole in case of disaster — we do not offer restores of individual accounts or deleted data.

+ +

Take it with you.

+

Want to move to another service? We'll export your data in standard formats. You're never locked in.

+ +

Change your mind.

+

Gave us permission for something? Revoke it anytime. We stop immediately.

+
+ +
+

About your AI

+ +

When you connect your AI to inou, your data travels through an encrypted bridge directly to your AI session.

+ +

What we control: keeping your data encrypted, secure, and private on our side.

+ +

What we can't control: what happens once your AI processes it. Each AI provider has their own privacy policy. We encourage you to read it.

+ +

We chose this architecture so your data is never copied, never stored by the AI, and never used for training — but ultimately, your choice of AI is your choice.

+
+ +
+

Children's privacy

+

inou is not available to users under 18 years of age — unless authorized by a parent or guardian. Minors cannot create accounts independently. A parent or guardian must set up access and remains responsible for the account. Parents or guardians retain full control and can revoke access at any time. Minors cannot share their information with third parties.

+
+ +
+

The legal stuff

+

We comply with FADP (Swiss data protection), GDPR (European data protection), and HIPAA (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.

+

We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.

+

Regardless of your jurisdiction, you may request access to your data, correction of inaccuracies, or complete deletion of your account. We will respond within 30 days.

+

Questions, concerns, or requests: privacy@inou.com

+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/prompts.tmpl b/templates/prompts.tmpl new file mode 100644 index 0000000..555208b --- /dev/null +++ b/templates/prompts.tmpl @@ -0,0 +1,760 @@ +{{define "prompts"}} +
+

{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in

+

Track daily measurements and observations

+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} + + {{if .Prompts}} +
+ +
+
+ +
+ +
+ {{range .Entries}} +
+ +
+ {{.Question}} + {{.TimeFormatted}} +
+
+ {{range .Fields}} +
+ {{if eq .Type "number"}} + {{.Value}} + {{if .Unit}}{{.Unit}}{{end}} + {{else if eq .Type "checkbox"}} + {{if .Value}}✓{{else}}—{{end}} + {{if .Label}}{{.Label}}{{end}} + {{else}} + {{.Value}} + {{end}} +
+ {{end}} +
+ + {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}} +
+ {{end}} + {{range .Prompts}} + {{$prompt := .}} +
+ + +
+ Stop tracking? + Yes + No +
+ + +
+
+ {{.Question}} + {{.LastResponseRaw}} +
+ + {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}} +
+ + + +
+ {{end}} +
+
+ {{else}} +
+

All caught up! No items due right now.

+ View all items +
+ {{end}} + + + + {{template "footer"}} +
+ + + + +{{end}} diff --git a/templates/security.tmpl b/templates/security.tmpl new file mode 100644 index 0000000..330437f --- /dev/null +++ b/templates/security.tmpl @@ -0,0 +1,180 @@ +{{define "security"}} + + +
+ +
+

How we protect your health dossier.

+

Security isn't a feature we added. It's how we built inou from day one.

+
+ +
+

Your data never shares a server.

+

Most services run on shared cloud infrastructure — your files sitting next to thousands of strangers. Not here. inou runs on dedicated, single-tenant hardware. Your data lives on machines that exist solely for this purpose.

+
+ +
+

Encryption you can trust.

+

FIPS 140-3 is the US government standard for cryptographic security — the same bar the military uses. Your files are encrypted in flight with TLS 1.3, encrypted again at the application layer before they touch the database, and stay encrypted at rest. Three layers deep.

+
+ +
+

Power doesn't go out.

+

Servers run on uninterruptible power, backed by a natural gas generator. Not a battery that buys you fifteen minutes — a generator with fuel supply independent of the grid. If the power company fails, we don't.

+
+ +
+

Drives fail. Data doesn't.

+

Storage runs on ZFS with RAID-Z2 — enterprise technology that survives the simultaneous failure of any two drives without losing a byte. Backups happen automatically. (Our founder spent two decades building backup systems for a living. We take this seriously.)

+
+ +
+

The internet has a backup too.

+

Primary connectivity is dedicated fiber. If that fails, satellite kicks in. Terrestrial and space-based redundancy — because your access matters.

+
+ +
+

We watch. We act.

+

Continuous uptime monitoring, automated alerting, 24/7. If something blinks wrong, we know — and our systems respond before you'd ever notice.

+
+ +
+

We keep attackers out.

+

Firewall rules block malicious traffic at the edge. Tarpits slow down scanners and bots, wasting their time instead of ours. Role-based access control ensures every request is authenticated and authorized — no exceptions.

+
+ +
+

Built with intention.

+

Most software is assembled from open source libraries — code written by strangers, maintained by volunteers, used by millions. When a vulnerability is discovered, every application using that code is at risk.

+

We made a different choice. inou is built entirely from proprietary code. We wrote every line ourselves. No third-party frameworks, no borrowed components, no dependency trees stretching into code we've never reviewed.

+

This means we know exactly what's running and exactly what's exposed. A minimal risk surface — not because we added security on top, but because we designed it that way from the beginning.

+
+ +
+

Physical security.

+

Hardware is housed in secured, access-controlled enclosures. Entry restricted to authorized inou personnel only. Climate-controlled, generator-backed, sited above regional flood levels.

+
+ + {{template "footer"}} + +
+{{end}} diff --git a/templates/share.tmpl b/templates/share.tmpl new file mode 100644 index 0000000..7b464f3 --- /dev/null +++ b/templates/share.tmpl @@ -0,0 +1,60 @@ +{{define "share"}} +
+ +
+
+

{{.T.share_access}}

+

{{.T.share_access_intro}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ {{.T.cancel}} + +
+
+
+
+ + {{template "footer"}} +
+{{end}} diff --git a/templates/styleguide.tmpl b/templates/styleguide.tmpl new file mode 100644 index 0000000..64b8a8b --- /dev/null +++ b/templates/styleguide.tmpl @@ -0,0 +1,496 @@ +{{define "styleguide"}} + +
+ +

Style Guide

+

Design system components for inou

+ + +
+
+
+
+

Text Blocks

+
+
+
+

Your data. Your rules.

+

+ We built inou because health data is personal. Not personal like "preferences" — personal like your body, your history, your family. So we made privacy the foundation, not an afterthought. +

+

What we collect

+

Account information.

+

+ Name, email address, date of birth, and sex. Date of birth and sex help provide accurate medical context — an MRI interpretation differs significantly between a 6-year-old and a 16-year-old. +

+
+
+ + +
+
+
+
+

Typography Scale

+
+
+
+
Page Title
2.5rem / 700
+
Section Title
1.4rem / 600
+
Subsection Title
1.1rem / 600
+
LABEL / CATEGORY
0.75rem / 600 / caps
+
Intro text — larger, lighter
1.15rem / 300
+
Body light — long-form
1rem / 300
+
Body regular — UI labels
1rem / 400
+
Mono: 1,234,567.89
SF Mono
+
+
+ + +
+
+
+
+

Colors

+
+
+
+
Accent
#B45309
+
Text
#1C1917
+
Text Muted
#78716C
+
Background
#F8F7F6
+
Success
#059669
+
Danger
#DC2626
+
+
+ + +
+
+
+
+

Buttons

+
+
+
+ + + + + +
+
+ + +
+
+
+
+

Badges

+
+
+
+ default + care + COMING SOON + processing +
+
+ + +
+
+
+
+

Messages

+
+
+
+
Error message — something went wrong.
+
Info message — here's some useful information.
+
Success message — operation completed.
+
+
+ + +
+
+
+
+

Form Elements

+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+

SETTINGS

+ Preferences +
+
+
+
+
+
Primary AI Assistant
+
Used for "Ask AI" prompts and analysis
+
+
+ + + +
+
+
+
+
Units
+
Measurement system for vitals
+
+
+ +
+
+
+
+ + +
+
+
+
+

Profile Cards

+
+
+
+
+
+

Johan Jongsma

+

you

+

Born: 1985-03-15 · Male

+
📷 3 studies🧪 12 labs🧬 genome
+ +
+
+

Sophia

+

my role: Parent · care

+

Born: 2017-01-01 · Female

+
📷 16 studies🧪 0 labs
+ +
+ +Add dossier +
+
+
+ + +
+
+
+
+

IMAGING

+ 16 studies · 4113 slices +
+ Open viewer +
+
+ +
+
AX T1
24 slices
+
AX T2 FLAIR
24 slices
+
SAG T1
20 slices
+
+
XR CHEST AP ONLY
5/6/2022
+
+
+ + +
+
+
+
+

LABS

+ 4 panels · 23 results +
+
+
+ +
+
Hemoglobin
14.2 g/dL12.0–16.0
+
White Blood Cells
7.8 K/µL4.5–11.0
+
Platelets
142 K/µL150–400
+
+
+
+ + +
+
+
+
+

GENETICS

+ 23andMe · 847 variants analyzed +
+
+
+ +
+
+
+
+
CYP2C19rs4244285
+
G;Aintermediate
+
+
Intermediate metabolizer for clopidogrel (Plavix). May need dose adjustment or alternative medication.
+
+
+
+
Show all 47 variants in Medication Response →
+
+ + +
+
+ + +
+
+
+
+

VITALS

+ Self-reported measurements +
+ + Add +
+
+ +
+
+
+
+
+
+
+
+
+
Today, 8:30 AM37.2 °C
+
Yesterday, 8:15 AM36.8 °C
+
Dec 24, 7:45 AM37.0 °C
+
+
+ + +
+
+ + +
+
+
+
+

NOTES

+ Photos, observations, symptoms +
+ + Add +
+
+ +
+
+
+
+
🦵
+
Dec 20, 3:45 PM
+
+
+
🦵
+
Dec 22, 10:20 AM
+
+
+
🦵
+
Dec 26, 9:15 AM
+
+
+
+
+
Add photo
+
+
+
+
+ Dec 20, 3:45 PM + Jim fell on his knee at soccer practice. Swelling visible, applied ice. +
+
+ Dec 22, 10:20 AM + Swelling reduced. Still some bruising. Can walk without pain. +
+
+ Dec 26, 9:15 AM + Almost fully healed. Light bruise remaining. +
+
+
+ + +
+
+
+ +
+
+ + +
+
+
+
+

SUPPLEMENTS

+ Daily stack +
+ + Add +
+
+
+
+ Vitamin D3 + 1 capsule + · 5000 IU +
+
morning, with food
+
+
+
+ Omega-3 Fish Oil + 2 capsules + · 2000 mg EPA/DHA +
+
morning, with food
+
+
+
+ Magnesium Glycinate + 2 capsules + · 400 mg +
+
evening
+
+
+
+ Liquid B12 + 5 ml + · 1000 mcg +
+
morning
+
+
+
+ + +
+
+
+
+

PEPTIDES

+ Current & past protocols +
+ + Add +
+
+
+
+
BPC-157250 mcg subQ · 2x daily
+ until Jan 23, 2025 +
+
active
+
+
+
+
TB-5002.5 mg subQ · 2x weekly
+ until Feb 5, 2025 +
+
active
+
+
+
+
BPC-157250 mcg subQ · 2x daily
+ Aug 15 – Sep 7, 2025 +
+
completed
+
+
+
+ + +
+
+
+
+

Upload Area

+
+
+
+
+
+

Click or drag files here

+

DICOM, PDF, CSV, VCF, and more

+
+
+
+ + +
+
+
+
+

Empty State

+
+
+
+
No lab data
+
+
+ + {{template "footer"}} + +
+ + +
+
+

Ask AI about CYP2C19

+
I have a genetic variant in CYP2C19 (rs4244285) with genotype G;A. + +This makes me an intermediate metabolizer. + +What medications are affected by this? What should I discuss with my doctor?
+
+ + +
+
+
+ +{{end}} diff --git a/templates/upload.tmpl b/templates/upload.tmpl new file mode 100644 index 0000000..87dec91 --- /dev/null +++ b/templates/upload.tmpl @@ -0,0 +1,187 @@ +{{define "upload"}} +
+

← Back to {{.TargetDossier.Name}}

+ +

Upload health data

+

Files are automatically deleted after 7 days

+ +
+ + +
+ +
+
+ + + +
+

Click or drag files here

+

DICOM, PDF, CSV, VCF, and more

+
+ + + {{if .UploadList}} + +
+ {{range .UploadList}} +
+
+ {{.FileName}} + + {{if and (not .Deleted) (eq .Status "uploaded")}} + + {{else}} + {{.Category}} + {{end}} + · {{.SizeHuman}} · {{.UploadedAt}} + +
+
+ {{if .Deleted}} + {{.DeletedReason}} + {{else}} + {{if ne .Status "uploaded"}}{{.Status}}{{end}} + Expires {{.ExpiresAt}} + + {{end}} +
+
+ {{end}} +
+ {{else}} +
+ No files uploaded yet +
+ {{end}} +
+ +
+
+

Uploading...

+
+
+
+

+
+
+ + +{{end}} diff --git a/templates/verify.tmpl b/templates/verify.tmpl new file mode 100644 index 0000000..d3cda49 --- /dev/null +++ b/templates/verify.tmpl @@ -0,0 +1,33 @@ +{{define "verify"}} +
+ +
+
+
inou health
+

{{.T.check_email}}

+

{{.T.code_sent_to}}
{{.Email}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ +
+ + +
+ +
+ +

+ {{.T.use_different_email}} +

+
+
+ + {{template "footer"}} + +
+{{end}} diff --git a/test-prompts/main.go b/test-prompts/main.go new file mode 100644 index 0000000..d0776ea --- /dev/null +++ b/test-prompts/main.go @@ -0,0 +1,244 @@ +package main + +import ( + "encoding/json" + "fmt" + "inou/lib" + "os" + "path/filepath" + "strings" +) + +// --- Local Structs for Prompt Processing --- +// These are defined locally to this application as per the new architecture. + +type TriageResponse struct { + Category string `json:"category"` + Language string `json:"language"` + HasData bool `json:"has_data"` + Error string `json:"error,omitempty"` +} + +type ExtractionResult struct { + Question string `json:"question"` + Category string `json:"category"` + Type string `json:"type"` + InputType string `json:"input_type"` + InputConfig InputConfig `json:"input_config"` + Schedule []ScheduleSlot `json:"schedule"` + Entries []*EntryData `json:"entries,omitempty"` + Error string `json:"error,omitempty"` +} + +type InputConfig struct { + Fields []FormField `json:"fields,omitempty"` + Groups []FormGroup `json:"groups,omitempty"` +} + +type FormGroup struct { + Title string `json:"title"` + Fields []FormField `json:"fields"` +} + +type FormField struct { + Key string `json:"key"` + Type string `json:"type"` + Label string `json:"label"` + Unit string `json:"unit,omitempty"` + Options []string `json:"options,omitempty"` +} + +type ScheduleSlot struct { + Days []string `json:"days"` + Time string `json:"time"` +} + +type EntryData struct { + Value string `json:"value"` + Data interface{} `json:"data"` +} + +var ValidCategories = map[string]bool{ + "vital": true, "exercise": true, "medication": true, "supplement": true, + "symptom": true, "note": true, "surgery": true, "hospitalization": true, + "consultation": true, "diagnosis": true, "device": true, "therapy": true, + "assessment": true, "birth": true, "imaging_finding": true, "eeg_finding": true, + "provider": true, "question": true, "history": true, "family_history": true, + "nutrition": true, "fertility": true, "out_of_domain": true, +} + + +// --- Main Application Logic --- + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: test-prompts ") + os.Exit(1) + } + input := strings.Join(os.Args[1:], " ") + + // Initialize shared library components + lib.ConfigInit() // Sets lib.GeminiKey from .env + lib.InitPrompts("api/prompts") // Sets the prompts dir for the lib + + // Triage + fmt.Println("=== TRIAGE ===") + triage, err := runTriage(input) + if err != nil { + fmt.Printf("Triage error: %v\n", err) + os.Exit(1) + } + if triage.Error != "" { + fmt.Printf("Error: %s\n", triage.Error) + os.Exit(0) + } + fmt.Printf("Category: %s\nLanguage: %s\nHas data: %v\n", triage.Category, triage.Language, triage.HasData) + + + // Extraction + fmt.Println("\n=== EXTRACTION ===") + // For a standalone test, we don't have existing types to pass. + existingTypes := make(map[string][]string) + + // Special case for testing pregnancy contradiction + if input == "I just had my period" { + existingTypes["fertility"] = []string{"pregnancy"} + fmt.Println("INFO: Injected existing type: fertility: [pregnancy]") + } + + result, err := runExtraction(input, triage.Category, triage.Language, existingTypes) + if err != nil { + fmt.Printf("Extraction error: %v\n", err) + os.Exit(1) + } + + out, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(out)) + + // Summary + fmt.Println("\n=== SUMMARY ===") + if len(result.Entries) > 0 { + for i, entry := range result.Entries { + fmt.Printf("Entry %d: %s\n", i+1, entry.Value) + } + } else { + fmt.Println("Entry: (none)") + } + + if len(result.Schedule) > 0 && result.Question != "" { + fmt.Printf("Prompt: %s\n", result.Question) + fmt.Printf("Schedule contains %d slot(s)\n", len(result.Schedule)) + } else { + fmt.Println("Prompt: (none - one-time event)") + } +} + + +// --- Local Prompt Handling Functions --- + +func loadPrompt(name string) (string, error) { + path := filepath.Join(lib.PromptsDir(), name+".md") + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} + +func runTriage(userInput string) (*TriageResponse, error) { + tmpl, err := loadPrompt("triage") + if err != nil { + return nil, fmt.Errorf("failed to load triage prompt: %v", err) + } + prompt := strings.ReplaceAll(tmpl, "{{INPUT}}", userInput) + + respText, err := lib.CallGemini(prompt) + if err != nil { + return nil, err + } + + var result TriageResponse + if err := json.Unmarshal([]byte(respText), &result); err != nil { + var errMap map[string]string + if json.Unmarshal([]byte(respText), &errMap) == nil { + if errMsg, ok := errMap["error"]; ok { + result.Error = errMsg + return &result, nil + } + } + return nil, fmt.Errorf("failed to parse triage JSON: %v (raw: %s)", err, respText) + } + + if _, ok := ValidCategories[result.Category]; !ok && result.Error == "" { + result.Category = "note" + } + return &result, nil +} + +func runExtraction(userInput, category, language string, existingTypes map[string][]string) (*ExtractionResult, error) { + tmpl, err := loadPrompt(category) + if err != nil { + tmpl, err = loadPrompt("default") + if err != nil { + return nil, fmt.Errorf("failed to load prompt: %v", err) + } + } + + var existingStr string + for cat, types := range existingTypes { + if len(types) > 0 { + existingStr += fmt.Sprintf("- %s: %v\n", cat, types) + } + } + if existingStr == "" { + existingStr = "(none yet)" + } + + prompt := tmpl + prompt = strings.ReplaceAll(prompt, "{{INPUT}}", userInput) + prompt = strings.ReplaceAll(prompt, "{{LANGUAGE}}", language) + prompt = strings.ReplaceAll(prompt, "{{CATEGORY}}", category) + prompt = strings.ReplaceAll(prompt, "{{EXISTING_TYPES}}", existingStr) + + respText, err := lib.CallGemini(prompt) + if err != nil { + return nil, err + } + + var result ExtractionResult + if err := json.Unmarshal([]byte(respText), &result); err != nil { + // Fallback for single entry to maintain compatibility with older prompts + var singleEntryResult struct { + Question string `json:"question"` + Category string `json:"category"` + Type string `json:"type"` + InputType string `json:"input_type"` + InputConfig InputConfig `json:"input_config"` + Schedule []ScheduleSlot `json:"schedule"` + Entry *EntryData `json:"entry,omitempty"` + Error string `json:"error,omitempty"` + } + if err2 := json.Unmarshal([]byte(respText), &singleEntryResult); err2 == nil { + result.Question = singleEntryResult.Question + result.Category = singleEntryResult.Category + result.Type = singleEntryResult.Type + result.InputType = singleEntryResult.InputType + result.InputConfig = singleEntryResult.InputConfig + result.Schedule = singleEntryResult.Schedule + result.Error = singleEntryResult.Error + if singleEntryResult.Entry != nil { + result.Entries = []*EntryData{singleEntryResult.Entry} + } + if result.Category == "" { + result.Category = category + } + return &result, nil + } + return nil, fmt.Errorf("failed to parse extraction JSON: %v (raw: %s)", err, respText) + } + + if result.Category == "" { + result.Category = category + } + return &result, nil +} \ No newline at end of file diff --git a/test-prompts/test-prompts b/test-prompts/test-prompts new file mode 100755 index 0000000..ee64432 Binary files /dev/null and b/test-prompts/test-prompts differ diff --git a/test/qa_prompts.sh b/test/qa_prompts.sh new file mode 100644 index 0000000..86efcc5 --- /dev/null +++ b/test/qa_prompts.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Simple prompts QA - 4 test cases + +API="http://localhost:8082" +AUTH="Authorization: Bearer test-token-sophie" + +echo "=== Prompts QA (no DB changes) ===" +echo + +# Get freeform prompt ID (creates one if needed) +freeform_id=$(curl -s "$API/api/prompts?dossier=4f1ffbc65c0a44d9" -H "$AUTH" | jq -r '.[] | select(.input_type=="freeform") | .id') +echo "Freeform prompt: $freeform_id" +echo + +# Test function - responds to freeform which triggers prompt creation +test_input() { + local input="$1" + local expect_cat="$2" + echo "Input: $input" + + result=$(curl -s -X POST "$API/api/prompts/respond" \ + -H "Content-Type: application/json" \ + -H "$AUTH" \ + -d "{\"prompt_id\": \"$freeform_id\", \"response_raw\": \"$input\", \"action\": \"respond\"}") + + new_prompt=$(echo "$result" | jq -r '.new_prompt // empty') + + if [ -n "$new_prompt" ] && [ "$new_prompt" != "null" ]; then + question=$(echo "$new_prompt" | jq -r '.question // empty') + category=$(echo "$new_prompt" | jq -r '.category // empty') + type=$(echo "$new_prompt" | jq -r '.type // empty') + input_type=$(echo "$new_prompt" | jq -r '.input_type // empty') + + if [ -n "$expect_cat" ] && [ "$category" = "$expect_cat" ]; then + echo " ✓ $question" + echo " [$category/$type] $input_type" + elif [ -n "$expect_cat" ]; then + echo " ✗ $question" + echo " Got: $category, Expected: $expect_cat" + else + echo " ? $question" + echo " [$category/$type] $input_type" + fi + else + echo " ✗ No new prompt created" + echo " Response: $result" + fi + echo +} + +# Run tests +test_input "I take 2 capsules of Pure Magnesium Glutaminate every day" "supplement" +test_input "I stopped drinking coffee" "nutrition" +test_input "my blood pressure is 87 over 116" "vital" +test_input "My period started today" "fertility" + +echo "=== Test finished ===" \ No newline at end of file diff --git a/test/run_all_prompts.sh b/test/run_all_prompts.sh new file mode 100644 index 0000000..d124e10 --- /dev/null +++ b/test/run_all_prompts.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# Batch-test prompts against the test-prompts executable + +SUCCESS_COUNT=0 +FAIL_COUNT=0 +TOTAL_COUNT=0 + +TEST_PROMPTS_EXEC="./bin/test-prompts" + +if [ ! -f "$TEST_PROMPTS_EXEC" ]; then + echo "✗ ERROR: '$TEST_PROMPTS_EXEC' not found. Please build it first." + exit 1 +fi + +echo "=== Batch Prompts Test ===" +echo + +# --- Test Functions --- + +# Test for inputs that should successfully parse and generate a prompt +test_positive() { + local input="$1" + local description="$2" + TOTAL_COUNT=$((TOTAL_COUNT+1)) + + echo "---" + echo "Testing (should succeed): $description" + # echo "Input: $input" + + # Run the command, capture output + output=$("$TEST_PROMPTS_EXEC" "$input") + exit_code=$? + + # Check for command execution error + if [ $exit_code -ne 0 ]; then + echo " ✗ FAIL: test-prompts command failed with exit code $exit_code." + FAIL_COUNT=$((FAIL_COUNT+1)) + echo "---" + return + fi + + # Check for a valid question and category in the EXTRACTION section + extraction_json=$(echo "$output" | awk '/^=== EXTRACTION ===$/,/^=== SUMMARY ===$/' | sed '1d;$d') + question=$(echo "$extraction_json" | jq -r '.question // ""') + category=$(echo "$extraction_json" | jq -r '.category // ""') + + if [ -n "$question" ] && [ "$question" != "null" ] && [ "$question" != "" ] && [ -n "$category" ]; then + echo " ✓ SUCCESS: New prompt created." + echo " Question: $question" + echo " Category: $category" + SUCCESS_COUNT=$((SUCCESS_COUNT+1)) + else + echo " ✗ FAIL: Did not generate a valid question or category." + # echo "$output" + FAIL_COUNT=$((FAIL_COUNT+1)) + fi + # echo +} + +# Test for inputs that should be ignored (no prompt/schedule generated) +test_negative() { + local input="$1" + local expected_error="$2" + TOTAL_COUNT=$((TOTAL_COUNT+1)) + + echo "---" + echo "Testing (should be ignored): $input" + + output=$("$TEST_PROMPTS_EXEC" "$input" 2>&1) + exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo " ✗ FAIL: test-prompts command failed unexpectedly with exit code $exit_code." + FAIL_COUNT=$((FAIL_COUNT+1)) + echo "---" + return + fi + + # Extract the Triage Error from the output + triage_error=$(echo "$output" | grep -A 4 "=== TRIAGE ===" | grep "Error:" | awk -F': ' '{print $2}') + + if [[ -n "$triage_error" ]] && [[ "$triage_error" == *"$expected_error"* ]]; then + echo " ✓ SUCCESS: Correctly triaged with error '$triage_error'" + SUCCESS_COUNT=$((SUCCESS_COUNT+1)) + else + echo " ✗ FAIL: Did not produce the expected triage error. Expected '$expected_error', got '$triage_error'." + echo "$output" + FAIL_COUNT=$((FAIL_COUNT+1)) + fi +} + +# --- Test Data --- + +declare -a positive_prompts=( + "I went to the gym and did 20 push-ups, 30 pull-ups, and ran 5 miles on the treadmill." + "Today for lunch I had a chicken salad and a glass of water, and for dinner I had a steak with a side of potatoes." + "I took my morning pills: 10mg of Lisinopril and a multivitamin." + "I have a follow-up with my cardiologist on Monday and a dental cleaning on Wednesday." + "Ich habe heute Morgen 30 Minuten Yoga gemacht." + "Ik heb een afspraak met de fysiotherapeut op dinsdag." + "J'ai une douleur au genou depuis 3 jours." + "Я измерил уровень сахара в крови, он 5.8." + "My period started today." + "I feel really tired and fatigued." + "I have a new prescription for Warfarin." + "I need to stop eating sugar." + "my blood pressure is 87 over 123" + "I went to the dentist; next Monday I need to do a root canal" + "I'm pregnant!" +) + +declare -A negative_prompts +negative_prompts["What is the capital of Mexico?"]="not_health_related" +negative_prompts["I have a headache, what should I do?"]="no_medical_advice" +negative_prompts["Set a timer for 5 minutes."]="not_health_related" +negative_prompts["What's the weather like tomorrow?"]="not_health_related" + +# --- Test Execution --- + +for prompt in "${positive_prompts[@]}"; do + test_positive "$prompt" "$prompt" +done + +for prompt in "${!negative_prompts[@]}"; do + test_negative "$prompt" "${negative_prompts[$prompt]}" +done + +# --- Summary --- +echo +echo "---" +echo "=== Test Summary ===" +echo "Total Tests: $TOTAL_COUNT" +echo "✓ Successes: $SUCCESS_COUNT" +echo "✗ Failures: $FAIL_COUNT" +echo "---" + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi \ No newline at end of file