diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 0000000..bff52cb --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,9 @@ +[ + { + "label": "Debug Flutter App", + "adapter": "Dart", + "type": "flutter", + "program": "lib/main.dart", + "args": ["--web-port=9090"] + } +] diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dac0589..2fa143c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,12 +4,20 @@ + + + + + + + + 70 y", "sex": "male", "value": 900, "ul": 3000 }, - { "age_range": ">70 y", "sex": "female", "value": 700, "ul": 3000 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 750, - "ul": 2800 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 770, - "ul": 3000 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 1200, - "ul": 2800 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 1300, - "ul": 3000 - } - ] - }, - "vitamin_c": { - "unit": "mg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 40, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 50, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 15, "ul": 400 }, - { "age_range": "4-8 y", "sex": "both", "value": 25, "ul": 650 }, - { "age_range": "9-13 y", "sex": "male", "value": 45, "ul": 1200 }, - { "age_range": "9-13 y", "sex": "female", "value": 45, "ul": 1200 }, - { "age_range": "14-18 y", "sex": "male", "value": 75, "ul": 1800 }, - { "age_range": "14-18 y", "sex": "female", "value": 65, "ul": 1800 }, - { "age_range": "19-30 y", "sex": "male", "value": 75, "ul": 1800 }, - { "age_range": "19-30 y", "sex": "female", "value": 90, "ul": 2000 }, - { "age_range": "31-50 y", "sex": "male", "value": 75, "ul": 2000 }, - { "age_range": "31-50 y", "sex": "female", "value": 75, "ul": 2000 }, - { "age_range": "51-70 y", "sex": "both", "value": 75, "ul": 2000 }, - { "age_range": ">70 y", "sex": "both", "value": 75, "ul": 2000 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 80, - "ul": 1800 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 85, - "ul": 2000 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 115, - "ul": 1800 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 120, - "ul": 2000 - } - ] - }, - "vitamin_d": { - "unit": "µg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 10, "ul": 25 }, - { "age_range": "7-12 mo", "sex": "infant", "value": 15, "ul": 25 }, - { "age_range": "1-3 y", "sex": "both", "value": 15, "ul": 50 }, - { "age_range": "4-8 y", "sex": "both", "value": 15, "ul": 50 }, - { "age_range": "9-13 y", "sex": "both", "value": 15, "ul": 100 }, - { "age_range": "14-18 y", "sex": "both", "value": 15, "ul": 100 }, - { "age_range": "19-30 y", "sex": "both", "value": 15, "ul": 100 }, - { "age_range": "31-50 y", "sex": "both", "value": 15, "ul": 100 }, - { "age_range": "51-70 y", "sex": "both", "value": 20, "ul": 100 }, - { "age_range": ">70 y", "sex": "both", "value": 20, "ul": 100 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 15, - "ul": 100 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 15, - "ul": 100 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 15, - "ul": 100 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 15, - "ul": 100 - } - ] - }, - "vitamin_e": { - "unit": "mg/day (alpha-tocopherol)", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 4, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 5, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 6, "ul": 200 }, - { "age_range": "4-8 y", "sex": "both", "value": 7, "ul": 300 }, - { "age_range": "9-13 y", "sex": "both", "value": 11, "ul": 600 }, - { "age_range": "14-18 y", "sex": "both", "value": 15, "ul": 800 }, - { "age_range": "19-30 y", "sex": "both", "value": 15, "ul": 800 }, - { "age_range": "31-50 y", "sex": "both", "value": 15, "ul": 800 }, - { "age_range": "51-70 y", "sex": "both", "value": 15, "ul": 800 }, - { "age_range": ">70 y", "sex": "both", "value": 15, "ul": 800 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 19, - "ul": 800 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 19, - "ul": 800 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 19, - "ul": 800 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 19, - "ul": 800 - } - ] - }, - "vitamin_k": { - "unit": "µg/day", - "rda_type": "AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 2, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 2.5, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 30, "ul": null }, - { "age_range": "4-8 y", "sex": "both", "value": 55, "ul": null }, - { "age_range": "9-13 y", "sex": "both", "value": 60, "ul": null }, - { "age_range": "14-18 y", "sex": "male", "value": 75, "ul": null }, - { "age_range": "14-18 y", "sex": "female", "value": 75, "ul": null }, - { "age_range": "19-30 y", "sex": "male", "value": 75, "ul": null }, - { "age_range": "19-30 y", "sex": "female", "value": 75, "ul": null }, - { "age_range": "31-50 y", "sex": "male", "value": 75, "ul": null }, - { "age_range": "31-50 y", "sex": "female", "value": 75, "ul": null }, - { "age_range": "51-70 y", "sex": "male", "value": 75, "ul": null }, - { "age_range": "51-70 y", "sex": "female", "value": 75, "ul": null }, - { "age_range": ">70 y", "sex": "male", "value": 75, "ul": null }, - { "age_range": ">70 y", "sex": "female", "value": 75, "ul": null }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 75, - "ul": null - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 75, - "ul": null - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 75, - "ul": null - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 75, - "ul": null - } - ] - }, - "vitamin_b1": { - "unit": "mg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 0.2, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 0.3, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 0.5, "ul": null }, - { "age_range": "4-8 y", "sex": "both", "value": 0.6, "ul": null }, - { "age_range": "9-13 y", "sex": "male", "value": 0.9, "ul": null }, - { "age_range": "9-13 y", "sex": "female", "value": 0.9, "ul": null }, - { "age_range": "14-18 y", "sex": "male", "value": 1.2, "ul": null }, - { "age_range": "14-18 y", "sex": "female", "value": 1.0, "ul": null }, - { "age_range": "19-30 y", "sex": "male", "value": 1.2, "ul": null }, - { "age_range": "19-30 y", "sex": "female", "value": 1.1, "ul": null }, - { "age_range": "31-50 y", "sex": "male", "value": 1.2, "ul": null }, - { "age_range": "31-50 y", "sex": "female", "value": 1.1, "ul": null }, - { "age_range": "51-70 y", "sex": "male", "value": 1.2, "ul": null }, - { "age_range": "51-70 y", "sex": "female", "value": 1.1, "ul": null }, - { "age_range": ">70 y", "sex": "male", "value": 1.2, "ul": null }, - { "age_range": ">70 y", "sex": "female", "value": 1.1, "ul": null }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 1.4, - "ul": null - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 1.4, - "ul": null - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 1.5, - "ul": null - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 1.5, - "ul": null - } - ] - }, - "vitamin_b2": { - "unit": "mg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 0.3, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 0.4, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 0.5, "ul": null }, - { "age_range": "4-8 y", "sex": "both", "value": 0.6, "ul": null }, - { "age_range": "9-13 y", "sex": "male", "value": 0.9, "ul": null }, - { "age_range": "9-13 y", "sex": "female", "value": 0.9, "ul": null }, - { "age_range": "14-18 y", "sex": "male", "value": 1.3, "ul": null }, - { "age_range": "14-18 y", "sex": "female", "value": 1.0, "ul": null }, - { "age_range": "19-30 y", "sex": "male", "value": 1.3, "ul": null }, - { "age_range": "19-30 y", "sex": "female", "value": 1.1, "ul": null }, - { "age_range": "31-50 y", "sex": "male", "value": 1.3, "ul": null }, - { "age_range": "31-50 y", "sex": "female", "value": 1.1, "ul": null }, - { "age_range": "51-70 y", "sex": "male", "value": 1.3, "ul": null }, - { "age_range": "51-70 y", "sex": "female", "value": 1.1, "ul": null }, - { "age_range": ">70 y", "sex": "male", "value": 1.3, "ul": null }, - { "age_range": ">70 y", "sex": "female", "value": 1.1, "ul": null }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 1.4, - "ul": null - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 1.4, - "ul": null - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 1.6, - "ul": null - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 1.6, - "ul": null - } - ] - }, - "vitamin_b3": { - "unit": "mg/day (niacin equivalents, NE)", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 2, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 4, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 6, "ul": 10 }, - { "age_range": "4-8 y", "sex": "both", "value": 8, "ul": 15 }, - { "age_range": "9-13 y", "sex": "both", "value": 12, "ul": 20 }, - { "age_range": "14-18 y", "sex": "male", "value": 16, "ul": 30 }, - { "age_range": "14-18 y", "sex": "female", "value": 14, "ul": 30 }, - { "age_range": "19-30 y", "sex": "male", "value": 16, "ul": 35 }, - { "age_range": "19-30 y", "sex": "female", "value": 14, "ul": 35 }, - { "age_range": "31-50 y", "sex": "male", "value": 16, "ul": 35 }, - { "age_range": "31-50 y", "sex": "female", "value": 14, "ul": 35 }, - { "age_range": "51-70 y", "sex": "male", "value": 16, "ul": 35 }, - { "age_range": "51-70 y", "sex": "female", "value": 14, "ul": 35 }, - { "age_range": ">70 y", "sex": "male", "value": 16, "ul": 35 }, - { "age_range": ">70 y", "sex": "female", "value": 14, "ul": 35 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 18, - "ul": 35 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 18, - "ul": 35 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 17, - "ul": 35 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 17, - "ul": 35 - } - ] - }, - "vitamin_b5": { - "unit": "mg/day", - "rda_type": "AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 1.7, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 1.8, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 2.0, "ul": null }, - { "age_range": "4-8 y", "sex": "both", "value": 3.0, "ul": null }, - { "age_range": "9-13 y", "sex": "both", "value": 4.0, "ul": null }, - { "age_range": "14-18 y", "sex": "both", "value": 5.0, "ul": null }, - { "age_range": "19-30 y", "sex": "both", "value": 5.0, "ul": null }, - { "age_range": "31-50 y", "sex": "both", "value": 5.0, "ul": null }, - { "age_range": "51-70 y", "sex": "both", "value": 5.0, "ul": null }, - { "age_range": ">70 y", "sex": "both", "value": 5.0, "ul": null }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 6.0, - "ul": null - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 6.0, - "ul": null - }, - { - "age_range": "pregnancy 31-50 y", - "sex": "female", - "value": 6.0, - "ul": null - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 7.0, - "ul": null - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 7.0, - "ul": null - }, - { - "age_range": "lactation 31-50 y", - "sex": "female", - "value": 7.0, - "ul": null - } - ] - }, - "vitamin_b6": { - "unit": "mg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 0.1, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 0.3, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 0.5, "ul": 30 }, - { "age_range": "4-8 y", "sex": "both", "value": 0.6, "ul": 40 }, - { "age_range": "9-13 y", "sex": "both", "value": 1.0, "ul": 60 }, - { "age_range": "14-18 y", "sex": "male", "value": 1.3, "ul": 80 }, - { "age_range": "14-18 y", "sex": "female", "value": 1.2, "ul": 80 }, - { "age_range": "19-50 y", "sex": "male", "value": 1.3, "ul": 100 }, - { "age_range": "19-50 y", "sex": "female", "value": 1.3, "ul": 100 }, - { "age_range": "51-70 y", "sex": "male", "value": 1.7, "ul": 100 }, - { "age_range": "51-70 y", "sex": "female", "value": 1.5, "ul": 100 }, - { "age_range": ">70 y", "sex": "male", "value": 1.7, "ul": 100 }, - { "age_range": ">70 y", "sex": "female", "value": 1.5, "ul": 100 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 1.9, - "ul": 80 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 1.9, - "ul": 80 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 2.0, - "ul": 100 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 2.0, - "ul": 100 - } - ] - }, - "vitamin_b12": { - "unit": "µg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 0.4, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 0.5, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 0.9, "ul": null }, - { "age_range": "4-8 y", "sex": "both", "value": 1.2, "ul": null }, - { "age_range": "9-13 y", "sex": "both", "value": 1.8, "ul": null }, - { "age_range": "14-18 y", "sex": "both", "value": 2.4, "ul": null }, - { "age_range": "19-30 y", "sex": "both", "value": 2.4, "ul": null }, - { "age_range": "31-50 y", "sex": "both", "value": 2.4, "ul": null }, - { "age_range": "51-70 y", "sex": "both", "value": 2.4, "ul": null }, - { "age_range": ">70 y", "sex": "both", "value": 2.4, "ul": null }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 2.6, - "ul": null - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 2.6, - "ul": null - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 2.8, - "ul": null - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 2.8, - "ul": null - } - ] - }, - "iron": { - "unit": "mg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 0.27, "ul": 40 }, - { "age_range": "7-12 mo", "sex": "infant", "value": 11, "ul": 40 }, - { "age_range": "1-3 y", "sex": "both", "value": 7, "ul": 40 }, - { "age_range": "4-8 y", "sex": "both", "value": 10, "ul": 40 }, - { "age_range": "9-13 y", "sex": "male", "value": 8, "ul": 45 }, - { "age_range": "9-13 y", "sex": "female", "value": 8, "ul": 45 }, - { "age_range": "14-18 y", "sex": "male", "value": 11, "ul": 45 }, - { "age_range": "14-18 y", "sex": "female", "value": 15, "ul": 45 }, - { "age_range": "19-30 y", "sex": "male", "value": 8, "ul": 45 }, - { "age_range": "19-30 y", "sex": "female", "value": 18, "ul": 45 }, - { "age_range": "31-50 y", "sex": "male", "value": 8, "ul": 45 }, - { "age_range": "31-50 y", "sex": "female", "value": 18, "ul": 45 }, - { "age_range": "51-70 y", "sex": "male", "value": 8, "ul": 45 }, - { "age_range": "51-70 y", "sex": "female", "value": 8, "ul": 45 }, - { "age_range": ">70 y", "sex": "male", "value": 8, "ul": 45 }, - { "age_range": ">70 y", "sex": "female", "value": 8, "ul": 45 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 27, - "ul": 45 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 27, - "ul": 45 - }, - { - "age_range": "pregnancy 31-50 y", - "sex": "female", - "value": 27, - "ul": 45 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 10, - "ul": 45 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 9, - "ul": 45 - }, - { - "age_range": "lactation 31-50 y", - "sex": "female", - "value": 9, - "ul": 45 - } - ] - }, - "magnesium": { - "unit": "mg/day", - "rda_type": "RDA/AI", - "note": "UL represents intake from pharmacological agents only, not food or water.", - "life_stages": [ - { - "age_range": "0-6 mo", - "sex": "infant", - "value": 30, - "rda_type": "AI", - "ul": 40 - }, - { - "age_range": "7-12 mo", - "sex": "infant", - "value": 75, - "rda_type": "AI", - "ul": 40 - }, - { - "age_range": "1-3 y", - "sex": "both", - "value": 80, - "rda_type": "RDA", - "ul": 65 - }, - { - "age_range": "4-8 y", - "sex": "both", - "value": 130, - "rda_type": "RDA", - "ul": 110 - }, - { - "age_range": "9-13 y", - "sex": "male", - "value": 240, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "9-13 y", - "sex": "female", - "value": 240, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "14-18 y", - "sex": "male", - "value": 410, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "14-18 y", - "sex": "female", - "value": 360, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "19-30 y", - "sex": "male", - "value": 400, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "19-30 y", - "sex": "female", - "value": 310, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "31-50 y", - "sex": "male", - "value": 420, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "31-50 y", - "sex": "female", - "value": 320, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "51-70 y", - "sex": "male", - "value": 420, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "51-70 y", - "sex": "female", - "value": 320, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": ">70 y", - "sex": "male", - "value": 420, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": ">70 y", - "sex": "female", - "value": 320, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 400, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 350, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "pregnancy 31-50 y", - "sex": "female", - "value": 360, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 360, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 320, - "rda_type": "RDA", - "ul": 350 - }, - { - "age_range": "lactation 31-50 y", - "sex": "female", - "value": 320, - "rda_type": "RDA", - "ul": 350 - } - ] - }, - "zinc": { - "unit": "mg/day", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 2, "ul": 4 }, - { "age_range": "7-12 mo", "sex": "infant", "value": 3, "ul": 5 }, - { "age_range": "1-3 y", "sex": "both", "value": 3, "ul": 7 }, - { "age_range": "4-8 y", "sex": "both", "value": 5, "ul": 12 }, - { "age_range": "9-13 y", "sex": "male", "value": 8, "ul": 23 }, - { "age_range": "9-13 y", "sex": "female", "value": 8, "ul": 23 }, - { "age_range": "14-18 y", "sex": "male", "value": 11, "ul": 34 }, - { "age_range": "14-18 y", "sex": "female", "value": 9, "ul": 34 }, - { "age_range": "19-30 y", "sex": "male", "value": 11, "ul": 40 }, - { "age_range": "19-30 y", "sex": "female", "value": 8, "ul": 40 }, - { "age_range": "31-50 y", "sex": "male", "value": 11, "ul": 40 }, - { "age_range": "31-50 y", "sex": "female", "value": 8, "ul": 40 }, - { "age_range": "51-70 y", "sex": "male", "value": 11, "ul": 40 }, - { "age_range": "51-70 y", "sex": "female", "value": 8, "ul": 40 }, - { "age_range": ">70 y", "sex": "male", "value": 11, "ul": 40 }, - { "age_range": ">70 y", "sex": "female", "value": 8, "ul": 40 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 12, - "ul": 40 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 11, - "ul": 40 - }, - { - "age_range": "pregnancy 31-50 y", - "sex": "female", - "value": 11, - "ul": 40 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 13, - "ul": 40 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 12, - "ul": 40 - }, - { - "age_range": "lactation 31-50 y", - "sex": "female", - "value": 12, - "ul": 40 - } - ] - }, - "creatine": { - "unit": "g/day", - "rda_type": "Recommended Dosage", - "note": "Creatine monohydrate supplementation typically involves a loading phase of 20-25 g/day divided into 4-5 g doses for 5-7 days, followed by a maintenance dose of 3-5 g/day. The UL is not a strict daily maximum but based on safe dosages studied for various durations.", - "life_stages": [ - { - "age_range": "adult", - "sex": "both", - "value_min": 3, - "value_max": 5, - "description": "Maintenance dose (typical daily intake)" - }, - { - "age_range": "adult", - "sex": "both", - "value": 20, - "description": "Loading phase daily dose (divided into 4-5 g servings for 5-7 days)" - } - ], - "ul": { - "value": 25, - "unit": "g/day", - "duration": "up to 14 days", - "note": "Doses up to 25 g/day for up to 14 days have been safely used. Long-term doses of 3-5 g/day are considered safe." - } - }, - "manganese": { - "unit": "mg/day", - "rda_type": "AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 0.003, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 0.6, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 1.2, "ul": 2 }, - { "age_range": "4-8 y", "sex": "both", "value": 1.5, "ul": 3 }, - { "age_range": "9-13 y", "sex": "male", "value": 1.9, "ul": 6 }, - { "age_range": "9-13 y", "sex": "female", "value": 1.6, "ul": 6 }, - { "age_range": "14-18 y", "sex": "male", "value": 2.2, "ul": 9 }, - { "age_range": "14-18 y", "sex": "female", "value": 1.6, "ul": 9 }, - { "age_range": "19-30 y", "sex": "male", "value": 2.3, "ul": 11 }, - { "age_range": "19-30 y", "sex": "female", "value": 1.8, "ul": 11 }, - { "age_range": "31-50 y", "sex": "male", "value": 2.3, "ul": 11 }, - { "age_range": "31-50 y", "sex": "female", "value": 1.8, "ul": 11 }, - { "age_range": "51-70 y", "sex": "male", "value": 2.3, "ul": 11 }, - { "age_range": "51-70 y", "sex": "female", "value": 1.8, "ul": 11 }, - { "age_range": ">70 y", "sex": "male", "value": 2.3, "ul": 11 }, - { "age_range": ">70 y", "sex": "female", "value": 1.8, "ul": 11 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 1.9, - "ul": 11 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 1.8, - "ul": 11 - }, - { - "age_range": "pregnancy 31-50 y", - "sex": "female", - "value": 1.8, - "ul": 11 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 2.6, - "ul": 11 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 2.3, - "ul": 11 - }, - { - "age_range": "lactation 31-50 y", - "sex": "female", - "value": 2.3, - "ul": 11 - } - ] - }, - "folate_dfe": { - "unit": "µg/day (DFE)", - "rda_type": "RDA/AI", - "life_stages": [ - { "age_range": "0-6 mo", "sex": "infant", "value": 65, "ul": null }, - { "age_range": "7-12 mo", "sex": "infant", "value": 80, "ul": null }, - { "age_range": "1-3 y", "sex": "both", "value": 150, "ul": 300 }, - { "age_range": "4-8 y", "sex": "both", "value": 200, "ul": 400 }, - { "age_range": "9-13 y", "sex": "male", "value": 300, "ul": 600 }, - { "age_range": "9-13 y", "sex": "female", "value": 300, "ul": 600 }, - { "age_range": "14-18 y", "sex": "both", "value": 400, "ul": 800 }, - { "age_range": "19-30 y", "sex": "female", "value": 400, "ul": 1000 }, - { "age_range": "19-30 y", "sex": "male", "value": 400, "ul": 1000 }, - { "age_range": "31-50 y", "sex": "both", "value": 400, "ul": 1000 }, - { "age_range": "51-70 y", "sex": "both", "value": 400, "ul": 1000 }, - { "age_range": ">70 y", "sex": "both", "value": 400, "ul": 1000 }, - { - "age_range": "pregnancy ≤18 y", - "sex": "female", - "value": 600, - "ul": 800 - }, - { - "age_range": "pregnancy 19-30 y", - "sex": "female", - "value": 600, - "ul": 1000 - }, - { - "age_range": "lactation ≤18 y", - "sex": "female", - "value": 500, - "ul": 800 - }, - { - "age_range": "lactation 19-30 y", - "sex": "female", - "value": 500, - "ul": 1000 - } - ] - } - } -} diff --git a/lib/logging.dart b/lib/logging.dart new file mode 100644 index 0000000..39b7f5a --- /dev/null +++ b/lib/logging.dart @@ -0,0 +1,8 @@ +// A simple logging function that prints a message to the console if [kDebugMode] is enabled. +import 'package:flutter/foundation.dart'; + +void printLog(String message) { + if (kDebugMode) { + print('SupplementsLog: $message'); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index befb96f..1207c30 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,17 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:supplements/logging.dart'; import 'providers/settings_provider.dart'; import 'providers/simple_sync_provider.dart'; import 'providers/supplement_provider.dart'; import 'screens/home_screen.dart'; import 'screens/profile_setup_screen.dart'; +import 'services/notification_router.dart'; +import 'services/simple_notification_service.dart'; + +final GlobalKey navigatorKey = GlobalKey(); void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -42,15 +47,23 @@ class MyApp extends StatelessWidget { WidgetsBinding.instance.addPostFrameCallback((_) { final supplementProvider = context.read(); + // Initialize notification router with the app's navigator + NotificationRouter.instance.initialize(navigatorKey); + + // If the app was launched via a notification, route to the proper dialog + SimpleNotificationService.instance.getLaunchDetails().then((details) { + NotificationRouter.instance.handleAppLaunchDetails(details); + }); + // Set up sync completion callback syncProvider.setOnSyncCompleteCallback(() async { if (kDebugMode) { - print('SupplementsLog: Sync completed, refreshing UI data...'); + printLog('Sync completed, refreshing UI data...'); } await supplementProvider.loadSupplements(); await supplementProvider.loadTodayIntakes(); if (kDebugMode) { - print('SupplementsLog: UI data refreshed after sync'); + printLog('UI data refreshed after sync'); } }); @@ -64,6 +77,7 @@ class MyApp extends StatelessWidget { }); return MaterialApp( + navigatorKey: navigatorKey, title: 'Supplements Tracker', theme: ThemeData( colorScheme: ColorScheme.fromSeed( diff --git a/lib/models/nutrient.dart b/lib/models/nutrient.dart deleted file mode 100644 index 1c0fcc3..0000000 --- a/lib/models/nutrient.dart +++ /dev/null @@ -1,87 +0,0 @@ - - -class Nutrient { - final String name; - final String unit; - final String rdaType; - final String? note; - final UpperLimit? ul; // nutrient-level UL (optional) - final List lifeStages; - - Nutrient({ - required this.name, - required this.unit, - required this.rdaType, - this.note, - this.ul, - required this.lifeStages, - }); - - factory Nutrient.fromJson(String name, Map json) { - return Nutrient( - name: name, - unit: json['unit'], - rdaType: json['rda_type'], - note: json['note'], - ul: (json['ul'] is Map) ? UpperLimit.fromJson(json['ul'] as Map) : null, - lifeStages: (json['life_stages'] as List) - .map((stage) => LifeStage.fromJson(stage)) - .toList(), - ); - } -} - -class LifeStage { - final String ageRange; - final String sex; - final double value; - final double? valueMin; - final double? valueMax; - final double? ul; - final String? description; - - LifeStage({ - required this.ageRange, - required this.sex, - required this.value, - this.valueMin, - this.valueMax, - this.ul, - this.description, - }); - - factory LifeStage.fromJson(Map json) { - return LifeStage( - ageRange: json['age_range'], - sex: json['sex'], - value: (json['value'] as num?)?.toDouble() ?? 0.0, - valueMin: json['value_min'] != null ? (json['value_min'] as num).toDouble() : null, - valueMax: json['value_max'] != null ? (json['value_max'] as num).toDouble() : null, - ul: json['ul'] != null ? (json['ul'] as num).toDouble() : null, - description: json['description'], - ); - } -} - -class UpperLimit { - final double value; - final String unit; - final String? duration; - final String? note; - - const UpperLimit({ - required this.value, - required this.unit, - this.duration, - this.note, - }); - - factory UpperLimit.fromJson(Map json) { - return UpperLimit( - value: (json['value'] as num).toDouble(), - unit: json['unit'] ?? '', - duration: json['duration'], - note: json['note'], - ); - } -} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f07017c..d5424e4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -24,10 +24,7 @@ class SettingsProvider extends ChangeNotifier { int _nightStart = 23; int _nightEnd = 4; - // Persistent reminder settings - bool _persistentReminders = true; - int _reminderRetryInterval = 5; // minutes - int _maxRetryAttempts = 3; + // Auto-sync settings bool _autoSyncEnabled = false; @@ -58,10 +55,7 @@ class SettingsProvider extends ChangeNotifier { int get nightStart => _nightStart; int get nightEnd => _nightEnd; - // Persistent reminder getters - bool get persistentReminders => _persistentReminders; - int get reminderRetryInterval => _reminderRetryInterval; - int get maxRetryAttempts => _maxRetryAttempts; + // Auto-sync getters bool get autoSyncEnabled => _autoSyncEnabled; @@ -110,10 +104,7 @@ class SettingsProvider extends ChangeNotifier { _nightStart = prefs.getInt('night_start') ?? 23; _nightEnd = prefs.getInt('night_end') ?? 4; - // Load persistent reminder settings - _persistentReminders = prefs.getBool('persistent_reminders') ?? true; - _reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5; - _maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3; + // Load auto-sync settings _autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false; @@ -278,30 +269,7 @@ class SettingsProvider extends ChangeNotifier { } } - // Persistent reminder setters - Future setPersistentReminders(bool enabled) async { - _persistentReminders = enabled; - notifyListeners(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('persistent_reminders', enabled); - } - - Future setReminderRetryInterval(int minutes) async { - _reminderRetryInterval = minutes; - notifyListeners(); - - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('reminder_retry_interval', minutes); - } - - Future setMaxRetryAttempts(int attempts) async { - _maxRetryAttempts = attempts; - notifyListeners(); - - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('max_retry_attempts', attempts); - } // Auto-sync setters Future setAutoSyncEnabled(bool enabled) async { diff --git a/lib/providers/simple_sync_provider.dart b/lib/providers/simple_sync_provider.dart index 052a39e..0966330 100644 --- a/lib/providers/simple_sync_provider.dart +++ b/lib/providers/simple_sync_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:supplements/logging.dart'; import '../services/database_sync_service.dart'; import '../services/auto_sync_service.dart'; @@ -66,7 +67,7 @@ class SimpleSyncProvider with ChangeNotifier { ); if (kDebugMode) { - print('SimpleSyncProvider: Auto-sync service initialized'); + printLog('SimpleSyncProvider: Auto-sync service initialized'); } } @@ -111,7 +112,7 @@ class SimpleSyncProvider with ChangeNotifier { await _syncService.syncDatabase(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Sync failed in provider: $e'); + printLog('Sync failed in provider: $e'); } rethrow; } finally { diff --git a/lib/providers/supplement_provider.dart b/lib/providers/supplement_provider.dart index 5cd23f0..408b4f6 100644 --- a/lib/providers/supplement_provider.dart +++ b/lib/providers/supplement_provider.dart @@ -3,22 +3,24 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:supplements/logging.dart'; import '../models/supplement.dart'; import '../models/supplement_intake.dart'; import '../services/database_helper.dart'; import '../services/database_sync_service.dart'; -import '../services/notification_service.dart'; +import '../services/simple_notification_service.dart'; class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { final DatabaseHelper _databaseHelper = DatabaseHelper.instance; - final NotificationService _notificationService = NotificationService(); + final SimpleNotificationService _notificationService = SimpleNotificationService.instance; + bool _initialized = false; List _supplements = []; List> _todayIntakes = []; List> _monthlyIntakes = []; bool _isLoading = false; - Timer? _persistentReminderTimer; + Timer? _dateChangeTimer; DateTime _lastDateCheck = DateTime.now(); @@ -41,37 +43,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { } Future initialize() async { + if (_initialized) { + return; + } + _initialized = true; + // Add this provider as an observer for app lifecycle changes WidgetsBinding.instance.addObserver(this); await _notificationService.initialize(); - // Set up the callback for handling supplement intake from notifications - print('SupplementsLog: 📱 Setting up notification callback...'); - _notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) { - print('SupplementsLog: 📱 === NOTIFICATION CALLBACK TRIGGERED ==='); - print('SupplementsLog: 📱 Supplement ID: $supplementId'); - print('SupplementsLog: 📱 Supplement Name: $supplementName'); - print('SupplementsLog: 📱 Units: $units'); - print('SupplementsLog: 📱 Unit Type: $unitType'); - - // Record the intake when user taps "Take" on notification - recordIntake(supplementId, 0.0, unitsTaken: units); - print('SupplementsLog: 📱 Intake recorded successfully'); - print('SupplementsLog: 📱 === CALLBACK COMPLETE ==='); - - if (kDebugMode) { - print('SupplementsLog: 📱 Recorded intake from notification: $supplementName ($units $unitType)'); - } - }); - print('SupplementsLog: 📱 Notification callback setup complete'); - // Request permissions with error handling try { await _notificationService.requestPermissions(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error requesting notification permissions: $e'); + printLog('Error requesting notification permissions: $e'); } // Continue without notifications rather than crashing } @@ -79,35 +66,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { await loadSupplements(); await loadTodayIntakes(); - // Reschedule notifications for all active supplements to ensure persistence + // Schedule notifications for all active supplements await _rescheduleAllNotifications(); - // Start periodic checking for persistent reminders (every 5 minutes) - _startPersistentReminderCheck(); - // Start date change monitoring to reset daily intake status _startDateChangeMonitoring(); } - void _startPersistentReminderCheck() { - // Cancel any existing timer - _persistentReminderTimer?.cancel(); - // Check every 5 minutes for persistent reminders - _persistentReminderTimer = Timer.periodic(const Duration(minutes: 5), (timer) async { - try { - // This will be called from settings provider context, so we need to import it - await _checkPersistentReminders(); - } catch (e) { - if (kDebugMode) { - print('SupplementsLog: Error checking persistent reminders: $e'); - } - } - }); - - // Also check immediately - _checkPersistentReminders(); - } void _startDateChangeMonitoring() { // Cancel any existing timer @@ -121,8 +87,8 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { if (currentDate != lastCheckDate) { if (kDebugMode) { - print('SupplementsLog: Date changed detected: ${lastCheckDate} -> ${currentDate}'); - print('SupplementsLog: Refreshing today\'s intakes for new day...'); + printLog('Date changed detected: ${lastCheckDate} -> ${currentDate}'); + printLog('Refreshing today\'s intakes for new day...'); } // Date has changed, refresh today's intakes @@ -130,49 +96,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { await loadTodayIntakes(); if (kDebugMode) { - print('SupplementsLog: Today\'s intakes refreshed for new day'); + printLog('Today\'s intakes refreshed for new day'); } } }); } - Future _checkPersistentReminders() async { - // This method will be enhanced to accept settings from the UI layer - // For now, we'll check with default settings - // In practice, the UI should call checkPersistentRemindersWithSettings - if (kDebugMode) { - print('SupplementsLog: 📱 Checking persistent reminders with default settings'); - } - } - // Method to be called from UI with actual settings - Future checkPersistentRemindersWithSettings({ - required bool persistentReminders, - required int reminderRetryInterval, - required int maxRetryAttempts, - }) async { - print('SupplementsLog: 📱 🔄 MANUAL CHECK: Persistent reminders called from UI'); - await _notificationService.checkPersistentReminders( - persistentReminders, - reminderRetryInterval, - maxRetryAttempts, - ); - } - // Add a manual trigger method for testing - Future triggerRetryCheck() async { - print('SupplementsLog: 📱 🚨 MANUAL TRIGGER: Forcing retry check...'); - await checkPersistentRemindersWithSettings( - persistentReminders: true, - reminderRetryInterval: 5, // Force 5 minute interval for testing - maxRetryAttempts: 3, - ); - } + + + @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _persistentReminderTimer?.cancel(); + _dateChangeTimer?.cancel(); super.dispose(); } @@ -184,7 +123,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { if (state == AppLifecycleState.resumed) { // App came back to foreground, check if date changed if (kDebugMode) { - print('SupplementsLog: App resumed, checking for date change...'); + printLog('App resumed, checking for date change...'); } forceCheckDateChange(); } @@ -192,23 +131,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { Future _rescheduleAllNotifications() async { if (kDebugMode) { - print('SupplementsLog: 📱 Rescheduling notifications for all active supplements...'); + printLog('📱 Rescheduling notifications for all active supplements...'); } - for (final supplement in _supplements) { - if (supplement.reminderTimes.isNotEmpty) { - try { - await _notificationService.scheduleSupplementReminders(supplement); - } catch (e) { - if (kDebugMode) { - print('SupplementsLog: 📱 Error rescheduling notifications for ${supplement.name}: $e'); - } - } + try { + await _notificationService.scheduleDailyGroupedRemindersSafe(_supplements); + await _notificationService.getPendingNotifications(); + } catch (e) { + if (kDebugMode) { + printLog('📱 Error scheduling grouped notifications: $e'); } } if (kDebugMode) { - print('SupplementsLog: 📱 Finished rescheduling notifications'); + printLog('📱 Finished rescheduling notifications'); } } @@ -217,16 +153,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { notifyListeners(); try { - print('SupplementsLog: Loading supplements from database...'); + printLog('Loading supplements from database...'); _supplements = await _databaseHelper.getAllSupplements(); - print('SupplementsLog: Loaded ${_supplements.length} supplements'); + printLog('Loaded ${_supplements.length} supplements'); for (var supplement in _supplements) { - print('SupplementsLog: Supplement: ${supplement.name}'); + printLog('Supplement: ${supplement.name}'); } } catch (e) { - print('SupplementsLog: Error loading supplements: $e'); + printLog('Error loading supplements: $e'); if (kDebugMode) { - print('SupplementsLog: Error loading supplements: $e'); + printLog('Error loading supplements: $e'); } } finally { _isLoading = false; @@ -236,28 +172,23 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { Future addSupplement(Supplement supplement) async { try { - print('SupplementsLog: Adding supplement: ${supplement.name}'); + printLog('Adding supplement: ${supplement.name}'); final id = await _databaseHelper.insertSupplement(supplement); - print('SupplementsLog: Supplement inserted with ID: $id'); + printLog('Supplement inserted with ID: $id'); final newSupplement = supplement.copyWith(id: id); - // Schedule notifications (skip if there's an error) - try { - await _notificationService.scheduleSupplementReminders(newSupplement); - print('SupplementsLog: Notifications scheduled'); - } catch (notificationError) { - print('SupplementsLog: Warning: Could not schedule notifications: $notificationError'); - } + // Notifications will be rescheduled in grouped mode after reloading supplements await loadSupplements(); - print('SupplementsLog: Supplements reloaded, count: ${_supplements.length}'); + printLog('Supplements reloaded, count: ${_supplements.length}'); + await _rescheduleAllNotifications(); // Trigger sync after adding supplement _triggerSyncIfEnabled(); } catch (e) { - print('SupplementsLog: Error adding supplement: $e'); + printLog('Error adding supplement: $e'); if (kDebugMode) { - print('SupplementsLog: Error adding supplement: $e'); + printLog('Error adding supplement: $e'); } rethrow; } @@ -267,16 +198,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { try { await _databaseHelper.updateSupplement(supplement); - // Reschedule notifications - await _notificationService.scheduleSupplementReminders(supplement); - await loadSupplements(); + await _rescheduleAllNotifications(); // Trigger sync after updating supplement _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error updating supplement: $e'); + printLog('Error updating supplement: $e'); } } } @@ -298,7 +227,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { } } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error duplicating supplement: $e'); + printLog('Error duplicating supplement: $e'); } } } @@ -307,16 +236,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { try { await _databaseHelper.deleteSupplement(id); - // Cancel notifications - await _notificationService.cancelSupplementReminders(id); - await loadSupplements(); + await _rescheduleAllNotifications(); // Trigger sync after deleting supplement _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error deleting supplement: $e'); + printLog('Error deleting supplement: $e'); } } } @@ -340,13 +267,13 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { // Show confirmation notification final supplement = _supplements.firstWhere((s) => s.id == supplementId); final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : ''; - await _notificationService.showInstantNotification( - 'Supplement Taken', - 'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})', + await _notificationService.showInstant( + title: 'Supplement Taken', + body: 'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})', ); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error recording intake: $e'); + printLog('Error recording intake: $e'); } } } @@ -355,22 +282,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { try { final today = DateTime.now(); if (kDebugMode) { - print('SupplementsLog: Loading intakes for date: ${today.year}-${today.month}-${today.day}'); + printLog('Loading intakes for date: ${today.year}-${today.month}-${today.day}'); } _todayIntakes = await _databaseHelper.getIntakesWithSupplementsForDate(today); if (kDebugMode) { - print('SupplementsLog: Loaded ${_todayIntakes.length} intakes for today'); + printLog('Loaded ${_todayIntakes.length} intakes for today'); for (var intake in _todayIntakes) { - print('SupplementsLog: - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}'); + printLog(' - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}'); } } notifyListeners(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error loading today\'s intakes: $e'); + printLog('Error loading today\'s intakes: $e'); } } } @@ -381,7 +308,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { notifyListeners(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error loading monthly intakes: $e'); + printLog('Error loading monthly intakes: $e'); } } } @@ -391,7 +318,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { return await _databaseHelper.getIntakesWithSupplementsForDate(date); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error loading intakes for date: $e'); + printLog('Error loading intakes for date: $e'); } return []; } @@ -411,7 +338,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error deleting intake: $e'); + printLog('Error deleting intake: $e'); } } } @@ -430,7 +357,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error permanently deleting intake: $e'); + printLog('Error permanently deleting intake: $e'); } } } @@ -462,7 +389,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { // Method to manually refresh daily status (useful for testing or manual refresh) Future refreshDailyStatus() async { if (kDebugMode) { - print('SupplementsLog: Manually refreshing daily status...'); + printLog('Manually refreshing daily status...'); } _lastDateCheck = DateTime.now(); await loadTodayIntakes(); @@ -475,20 +402,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day); if (kDebugMode) { - print('SupplementsLog: Force checking date change...'); - print('SupplementsLog: Current date: $currentDate'); - print('SupplementsLog: Last check date: $lastCheckDate'); + printLog('Force checking date change...'); + printLog('Current date: $currentDate'); + printLog('Last check date: $lastCheckDate'); } if (currentDate != lastCheckDate) { if (kDebugMode) { - print('SupplementsLog: Date change detected, refreshing intakes...'); + printLog('Date change detected, refreshing intakes...'); } _lastDateCheck = now; await loadTodayIntakes(); } else { if (kDebugMode) { - print('SupplementsLog: No date change detected'); + printLog('No date change detected'); } } } @@ -503,7 +430,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { notifyListeners(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error loading archived supplements: $e'); + printLog('Error loading archived supplements: $e'); } } } @@ -518,7 +445,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error archiving supplement: $e'); + printLog('Error archiving supplement: $e'); } } } @@ -533,7 +460,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error unarchiving supplement: $e'); + printLog('Error unarchiving supplement: $e'); } } } @@ -547,32 +474,39 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { _triggerSyncIfEnabled(); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error permanently deleting archived supplement: $e'); + printLog('Error permanently deleting archived supplement: $e'); } } } // Debug methods for notification testing Future testNotifications() async { - await _notificationService.testNotification(); + await _notificationService.showInstant( + title: 'Test Notification', + body: 'This is a test notification to verify the system is working.', + ); } Future testScheduledNotification() async { - await _notificationService.testScheduledNotification(); + await _notificationService.showInstant( + title: 'Test Scheduled Notification', + body: 'This is a simple test notification.', + ); } Future testNotificationActions() async { - await _notificationService.testNotificationWithActions(); + await _notificationService.showInstant( + title: 'Test Action Notification', + body: 'Actions are not available in the simple notification service.', + ); } Future> getPendingNotifications() async { - return await _notificationService.getPendingNotifications(); + // Not supported in simple service; return empty list for compatibility. + return []; } - // Get pending notifications with retry information from database - Future>> getTrackedNotifications() async { - return await DatabaseHelper.instance.getPendingNotifications(); - } + // Debug method to test notification persistence Future rescheduleAllNotifications() async { @@ -581,6 +515,6 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { // Debug method to cancel all notifications Future cancelAllNotifications() async { - await _notificationService.cancelAllReminders(); + await _notificationService.cancelAll(); } } diff --git a/lib/screens/add_supplement_screen.dart b/lib/screens/add_supplement_screen.dart index aa6cf60..9a23f38 100644 --- a/lib/screens/add_supplement_screen.dart +++ b/lib/screens/add_supplement_screen.dart @@ -3,10 +3,8 @@ import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; import '../models/ingredient.dart'; -import '../models/nutrient.dart'; import '../models/supplement.dart'; import '../providers/supplement_provider.dart'; -import '../services/nutrient_data_service.dart'; // Helper class to manage ingredient text controllers class IngredientController { @@ -53,12 +51,8 @@ class _AddSupplementScreenState extends State { final _numberOfUnitsController = TextEditingController(); final _notesController = TextEditingController(); - // Nutrient data for autocomplete - final NutrientDataService _nutrientDataService = NutrientDataService(); - List _nutrients = []; - // Multi-ingredient support with persistent controllers - List _ingredientControllers = []; + final _ingredientControllers = []; String _selectedUnitType = 'capsules'; int _frequencyPerDay = 1; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5e7b945..144d0ec 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; + import '../providers/supplement_provider.dart'; -import '../providers/settings_provider.dart'; -import 'supplements_list_screen.dart'; -import 'history_screen.dart'; import 'add_supplement_screen.dart'; +import 'history_screen.dart'; import 'settings_screen.dart'; +import 'supplements_list_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -28,45 +28,10 @@ class _HomeScreenState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().initialize(); - _startPersistentReminderCheck(); }); } - void _startPersistentReminderCheck() { - // Check immediately and then every 3 minutes (faster than any retry interval) - _checkPersistentReminders(); - - // Set up periodic checking every 3 minutes to ensure we catch all retry intervals - Future.doWhile(() async { - await Future.delayed(const Duration(minutes: 3)); - if (mounted) { - await _checkPersistentReminders(); - return true; - } - return false; - }); - } - - Future _checkPersistentReminders() async { - if (!mounted) return; - - try { - print('SupplementsLog: 📱 === HOME SCREEN: Checking persistent reminders ==='); - final supplementProvider = context.read(); - final settingsProvider = context.read(); - - print('SupplementsLog: 📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}'); - - await supplementProvider.checkPersistentRemindersWithSettings( - persistentReminders: settingsProvider.persistentReminders, - reminderRetryInterval: settingsProvider.reminderRetryInterval, - maxRetryAttempts: settingsProvider.maxRetryAttempts, - ); - print('SupplementsLog: 📱 === HOME SCREEN: Persistent reminder check complete ==='); - } catch (e) { - print('SupplementsLog: Error checking persistent reminders: $e'); - } - } + // Persistent reminder checks removed @override Widget build(BuildContext context) { diff --git a/lib/screens/pending_notifications_screen.dart b/lib/screens/pending_notifications_screen.dart index 61e3999..de18ca0 100644 --- a/lib/screens/pending_notifications_screen.dart +++ b/lib/screens/pending_notifications_screen.dart @@ -1,823 +1,58 @@ import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:provider/provider.dart'; -import '../services/notification_service.dart'; -import '../services/database_helper.dart'; -import '../providers/settings_provider.dart'; -import '../providers/supplement_provider.dart'; -class PendingNotificationsScreen extends StatefulWidget { +/// Simple placeholder screen for pending notifications. +/// In the simplified notification setup, we no longer track or retry notifications, +/// so there is no "pending notifications" list to display. +/// +/// Keep this screen minimal to avoid heavy logic and dependencies. +class PendingNotificationsScreen extends StatelessWidget { const PendingNotificationsScreen({super.key}); - @override - State createState() => _PendingNotificationsScreenState(); -} - -class _PendingNotificationsScreenState extends State { - List _pendingNotifications = []; - List> _trackedNotifications = []; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - _loadNotifications(); - } - - Future _loadNotifications() async { - setState(() { - _isLoading = true; - }); - - try { - // Get settings for retry interval calculation - final settingsProvider = Provider.of(context, listen: false); - final reminderRetryInterval = settingsProvider.reminderRetryInterval; - final maxRetryAttempts = settingsProvider.maxRetryAttempts; - - // Get system pending notifications - final notificationService = NotificationService(); - final systemPending = await notificationService.getPendingNotifications(); - - // Get tracked notifications from database (including retries) - final trackedNotifications = await DatabaseHelper.instance.getPendingNotifications(); - - // Create a more intelligent matching system - final allNotifications = >[]; - final matchedSystemIds = {}; - - // First, try to match tracked notifications with system notifications - for (final trackedNotification in trackedNotifications) { - final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal(); - final lastRetryTime = trackedNotification['lastRetryTime'] != null - ? DateTime.parse(trackedNotification['lastRetryTime']).toLocal() - : null; - final retryCount = trackedNotification['retryCount'] ?? 0; - final isRetrying = trackedNotification['status'] == 'retrying'; - final notificationId = trackedNotification['notificationId'] as int; - - // Try to find matching system notification(s) - final matchingSystemNotifications = systemPending.where((systemNotification) { - return _isMatchingNotification(systemNotification, trackedNotification, retryCount); - }).toList(); - - // Calculate next retry time if this is a retry notification - DateTime? nextRetryTime; - bool hasReachedMaxRetries = retryCount >= maxRetryAttempts; - - if (isRetrying && !hasReachedMaxRetries) { - if (lastRetryTime != null) { - // Next retry is based on last retry time + interval - nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval)); - } else { - // First retry is based on original scheduled time + interval - nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval)); - } - } - - // Create the notification entry - final notificationEntry = { - 'id': notificationId, - 'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}', - 'body': 'Take your supplement', - 'scheduledTime': scheduledTime, - 'nextRetryTime': nextRetryTime, - 'lastRetryTime': lastRetryTime, - 'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only', - 'status': trackedNotification['status'], - 'isRetry': isRetrying, - 'retryCount': retryCount, - 'hasReachedMaxRetries': hasReachedMaxRetries, - 'maxRetryAttempts': maxRetryAttempts, - 'supplementName': trackedNotification['supplementName'], - 'supplementId': trackedNotification['supplementId'], - 'systemNotificationCount': matchingSystemNotifications.length, - }; - - allNotifications.add(notificationEntry); - - // Mark these system notifications as matched - for (final systemNotification in matchingSystemNotifications) { - matchedSystemIds.add(systemNotification.id); - } - } - - // Add unmatched system notifications - for (final systemNotification in systemPending) { - if (!matchedSystemIds.contains(systemNotification.id)) { - allNotifications.add({ - 'id': systemNotification.id, - 'title': systemNotification.title ?? 'System Notification', - 'body': systemNotification.body ?? '', - 'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime - 'type': 'system_only', - 'isRetry': false, - 'retryCount': 0, - 'hasReachedMaxRetries': false, - 'maxRetryAttempts': maxRetryAttempts, - }); - } - } - - // Sort by scheduled time (soonest first) - allNotifications.sort((a, b) { - final timeA = a['scheduledTime'] as DateTime; - final timeB = b['scheduledTime'] as DateTime; - return timeA.compareTo(timeB); - }); - - setState(() { - _pendingNotifications = systemPending; - _trackedNotifications = trackedNotifications; - _isLoading = false; - }); - } catch (e) { - print('SupplementsLog: Error loading notifications: $e'); - setState(() { - _isLoading = false; - }); - } - } - - Future _showCleanupDialog() async { - final result = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Cleanup Old Notifications'), - content: const Text( - 'This will help clean up old or duplicate notifications:\n\n' - '• Clear stale system notifications\n' - '• Remove very old tracked notifications (>24h overdue)\n' - '• Reschedule fresh notifications for active supplements\n\n' - 'Choose an option:', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop('cancel'), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop('clear_old'), - child: const Text('Clear Old Only'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop('clear_all'), - child: const Text('Clear All & Reschedule'), - ), - ], - ); - }, - ); - - if (result != null && result != 'cancel') { - await _performCleanup(result); - } - } - - Future _performCleanup(String action) async { - try { - // Show loading indicator - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Cleaning up notifications...'), - duration: Duration(seconds: 2), - ), - ); - } - - final notificationService = NotificationService(); - - if (action == 'clear_all') { - // Clear all notifications and reschedule fresh ones - await notificationService.cancelAllReminders(); - await DatabaseHelper.instance.cleanupOldNotificationTracking(); - - // Reschedule notifications for all active supplements - final supplementProvider = Provider.of(context, listen: false); - await supplementProvider.rescheduleAllNotifications(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('All notifications cleared and rescheduled!'), - backgroundColor: Colors.green, - ), - ); - } - } else if (action == 'clear_old') { - // Clear only very old notifications (>24 hours overdue) - await _clearOldNotifications(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Old notifications cleared!'), - backgroundColor: Colors.orange, - ), - ); - } - } - - // Refresh the list - await _loadNotifications(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error during cleanup: $e'), - backgroundColor: Colors.red, - ), - ); - } - } - } - - Future _clearOldNotifications() async { - final now = DateTime.now(); - final cutoff = now.subtract(const Duration(hours: 24)); - - // Clear very old tracked notifications - final db = await DatabaseHelper.instance.database; - await db.delete( - 'notification_tracking', - where: 'scheduledTime < ? AND status IN (?, ?)', - whereArgs: [cutoff.toIso8601String(), 'pending', 'retrying'], - ); - - // Note: We can't selectively clear system notifications as we don't have their scheduled times - // This is a limitation of the flutter_local_notifications plugin - } - - bool _isMatchingNotification(PendingNotificationRequest systemNotification, Map trackedNotification, int retryCount) { - final trackedId = trackedNotification['notificationId'] as int; - final supplementId = trackedNotification['supplementId'] as int; - - // Check for exact ID match (original notification) - if (systemNotification.id == trackedId) { - return true; - } - - // Check for retry notification IDs (200000 + original_id * 10 + retry_attempt) - for (int attempt = 1; attempt <= retryCount; attempt++) { - final retryId = 200000 + (trackedId * 10) + attempt; - if (systemNotification.id == retryId) { - return true; - } - } - - // Check for snooze notification IDs (supplementId * 1000 + minutes) - // Common snooze intervals: 5, 10, 15, 30 minutes - final snoozeIds = [5, 10, 15, 30].map((minutes) => supplementId * 1000 + minutes); - if (snoozeIds.contains(systemNotification.id)) { - return true; - } - - // Check if it's within the supplement's notification ID range (supplementId * 100 + reminderIndex) - final baseId = supplementId * 100; - if (systemNotification.id >= baseId && systemNotification.id < baseId + 10) { - return true; - } - - return false; - } - - Color _getTypeColor(String type) { - switch (type) { - case 'matched': - return Colors.green; - case 'tracked_only': - return Colors.orange; - case 'system_only': - return Colors.blue; - default: - return Colors.grey; - } - } - - Color _getTypeDarkColor(String type) { - switch (type) { - case 'matched': - return Colors.green.shade700; - case 'tracked_only': - return Colors.orange.shade700; - case 'system_only': - return Colors.blue.shade700; - default: - return Colors.grey.shade700; - } - } - - String _getTypeLabel(String type, Map notification) { - final systemCount = notification['systemNotificationCount'] as int? ?? 0; - - switch (type) { - case 'matched': - return systemCount > 1 ? 'Matched' : 'Synced'; - case 'tracked_only': - return 'Tracking Only'; - case 'system_only': - return 'System Only'; - default: - return 'Unknown'; - } - } - @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Pending Notifications'), - actions: [ - IconButton( - icon: const Icon(Icons.cleaning_services), - onPressed: _showCleanupDialog, - tooltip: 'Cleanup old notifications', - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadNotifications, - tooltip: 'Refresh', - ), - ], ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : RefreshIndicator( - onRefresh: _loadNotifications, - child: _buildNotificationsList(), - ), - ); - } - - Widget _buildNotificationsList() { - if (_pendingNotifications.isEmpty && _trackedNotifications.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.notifications_off, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - 'No pending notifications', - style: TextStyle( - fontSize: 18, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.notifications_none, + size: 72, color: Colors.grey, ), - ), - SizedBox(height: 8), - Text( - 'All caught up!', - style: TextStyle( - color: Colors.grey, + const SizedBox(height: 16), + Text( + 'No pending notifications UI in simple mode', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey.shade800, + fontWeight: FontWeight.w600, + ), ), - ), - ], - ), - ); - } - - // Get settings for retry interval calculation - final settingsProvider = Provider.of(context, listen: false); - final reminderRetryInterval = settingsProvider.reminderRetryInterval; - final maxRetryAttempts = settingsProvider.maxRetryAttempts; - - // Create a more intelligent matching system - final allNotifications = >[]; - final matchedSystemIds = {}; - - // First, try to match tracked notifications with system notifications - for (final trackedNotification in _trackedNotifications) { - final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal(); - final lastRetryTime = trackedNotification['lastRetryTime'] != null - ? DateTime.parse(trackedNotification['lastRetryTime']).toLocal() - : null; - final retryCount = trackedNotification['retryCount'] ?? 0; - final isRetrying = trackedNotification['status'] == 'retrying'; - final notificationId = trackedNotification['notificationId'] as int; - - // Try to find matching system notification(s) - final matchingSystemNotifications = _pendingNotifications.where((systemNotification) { - return _isMatchingNotification(systemNotification, trackedNotification, retryCount); - }).toList(); - - // Calculate next retry time if this is a retry notification - DateTime? nextRetryTime; - bool hasReachedMaxRetries = retryCount >= maxRetryAttempts; - - if (isRetrying && !hasReachedMaxRetries) { - if (lastRetryTime != null) { - // Next retry is based on last retry time + interval - nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval)); - } else { - // First retry is based on original scheduled time + interval - nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval)); - } - } - - // Create the notification entry - final notificationEntry = { - 'id': notificationId, - 'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}', - 'body': 'Take your supplement', - 'scheduledTime': scheduledTime, - 'nextRetryTime': nextRetryTime, - 'lastRetryTime': lastRetryTime, - 'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only', - 'status': trackedNotification['status'], - 'isRetry': isRetrying, - 'retryCount': retryCount, - 'hasReachedMaxRetries': hasReachedMaxRetries, - 'maxRetryAttempts': maxRetryAttempts, - 'supplementName': trackedNotification['supplementName'], - 'supplementId': trackedNotification['supplementId'], - 'systemNotificationCount': matchingSystemNotifications.length, - }; - - allNotifications.add(notificationEntry); - - // Mark these system notifications as matched - for (final systemNotification in matchingSystemNotifications) { - matchedSystemIds.add(systemNotification.id); - } - } - - // Add unmatched system notifications - for (final systemNotification in _pendingNotifications) { - if (!matchedSystemIds.contains(systemNotification.id)) { - allNotifications.add({ - 'id': systemNotification.id, - 'title': systemNotification.title ?? 'System Notification', - 'body': systemNotification.body ?? '', - 'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime - 'type': 'system_only', - 'isRetry': false, - 'retryCount': 0, - 'hasReachedMaxRetries': false, - 'maxRetryAttempts': maxRetryAttempts, - }); - } - } - - // Sort by scheduled time (soonest first) - allNotifications.sort((a, b) { - final timeA = a['scheduledTime'] as DateTime; - final timeB = b['scheduledTime'] as DateTime; - return timeA.compareTo(timeB); - }); - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: allNotifications.length, - itemBuilder: (context, index) { - final notification = allNotifications[index]; - return _buildNotificationCard(notification); - }, - ); - } - - Widget _buildNotificationCard(Map notification) { - final scheduledTime = notification['scheduledTime'] as DateTime; - final nextRetryTime = notification['nextRetryTime'] as DateTime?; - final lastRetryTime = notification['lastRetryTime'] as DateTime?; - final now = DateTime.now(); - final isOverdue = scheduledTime.isBefore(now); - final isRetry = notification['isRetry'] as bool; - final retryCount = notification['retryCount'] as int; - final hasReachedMaxRetries = notification['hasReachedMaxRetries'] as bool? ?? false; - final maxRetryAttempts = notification['maxRetryAttempts'] as int? ?? 3; - final type = notification['type'] as String; - - // Determine which time to show and calculate time text - DateTime displayTime = scheduledTime; - String timePrefix = ''; - bool showAsNextRetry = false; - String timeText = ''; - - if (isRetry && nextRetryTime != null && !hasReachedMaxRetries) { - // For retry notifications that haven't reached max retries, show next retry time - final timeDifference = nextRetryTime.difference(now); - if (timeDifference.inSeconds.abs() < 30) { - // If within 30 seconds, show "due now" - displayTime = nextRetryTime; - timePrefix = 'Next retry: '; - showAsNextRetry = true; - timeText = 'due now'; - } else if (nextRetryTime.isAfter(now)) { - displayTime = nextRetryTime; - timePrefix = 'Next retry: '; - showAsNextRetry = true; - } else { - // If next retry time has passed, show it's overdue for retry - displayTime = nextRetryTime; - timePrefix = 'Retry overdue: '; - showAsNextRetry = true; - } - } else if (isRetry && hasReachedMaxRetries) { - // For notifications that have reached max retries, don't show next retry time - showAsNextRetry = false; - } - - // Calculate time text if not already set - if (timeText.isEmpty) { - final isDisplayTimeOverdue = displayTime.isBefore(now); - final timeUntil = displayTime.difference(now); - - if (isDisplayTimeOverdue) { - final overdue = now.difference(displayTime); - if (overdue.inDays > 0) { - timeText = 'Overdue by ${overdue.inDays}d ${overdue.inHours % 24}h'; - } else if (overdue.inHours > 0) { - timeText = 'Overdue by ${overdue.inHours}h ${overdue.inMinutes % 60}m'; - } else if (overdue.inMinutes > 0) { - timeText = 'Overdue by ${overdue.inMinutes}m'; - } else { - timeText = 'Overdue by ${overdue.inSeconds}s'; - } - } else { - if (timeUntil.inDays > 0) { - timeText = 'In ${timeUntil.inDays}d ${timeUntil.inHours % 24}h'; - } else if (timeUntil.inHours > 0) { - timeText = 'In ${timeUntil.inHours}h ${timeUntil.inMinutes % 60}m'; - } else if (timeUntil.inMinutes > 0) { - timeText = 'In ${timeUntil.inMinutes}m'; - } else { - timeText = 'In ${timeUntil.inSeconds}s'; - } - } - } - - // Determine if display time is overdue (needed for icon colors later) - final isDisplayTimeOverdue = displayTime.isBefore(now); - - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - notification['title'] ?? 'Notification', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - if (notification['body'] != null) ...[ - const SizedBox(height: 4), - Text( - notification['body'], - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (isRetry) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: hasReachedMaxRetries - ? Colors.red.withOpacity(0.2) - : Colors.orange.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: hasReachedMaxRetries - ? Colors.red.withOpacity(0.5) - : Colors.orange.withOpacity(0.5), - ), - ), - child: Text( - hasReachedMaxRetries - ? 'Max retries ($retryCount/$maxRetryAttempts)' - : 'Retry #$retryCount', - style: TextStyle( - fontSize: 12, - color: hasReachedMaxRetries - ? Colors.red.shade700 - : Colors.orange.shade700, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 4), - ], - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: _getTypeColor(type).withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _getTypeColor(type).withOpacity(0.5), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _getTypeLabel(type, notification), - style: TextStyle( - fontSize: 12, - color: _getTypeDarkColor(type), - fontWeight: FontWeight.bold, - ), - ), - if (type == 'matched' && (notification['systemNotificationCount'] as int? ?? 0) > 1) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.8), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${notification['systemNotificationCount']}', - style: TextStyle( - fontSize: 10, - color: _getTypeDarkColor(type), - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ], - ), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - // Show original scheduled time - Row( - children: [ - Icon( - isOverdue ? Icons.schedule_outlined : Icons.access_time, - size: 16, - color: isOverdue ? Colors.red : Colors.grey, - ), - const SizedBox(width: 8), - Text( - 'Scheduled: ${_formatTime(scheduledTime)}', - style: TextStyle( - color: isOverdue ? Colors.red : Colors.grey.shade700, - fontWeight: isOverdue ? FontWeight.bold : FontWeight.normal, - ), - ), - if (isOverdue) ...[ - const SizedBox(width: 8), - Text( - '(${_formatOverdueTime(scheduledTime)})', - style: TextStyle( - color: Colors.red, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ), - // Show next retry time if applicable - if (showAsNextRetry && nextRetryTime != null) ...[ - const SizedBox(height: 6), - Row( - children: [ - Icon( - isDisplayTimeOverdue ? Icons.warning_outlined : Icons.refresh, - size: 16, - color: isDisplayTimeOverdue ? Colors.red : Colors.orange, - ), - const SizedBox(width: 8), - Text( - '${timePrefix}${_formatTime(displayTime)} • $timeText', - style: TextStyle( - color: isDisplayTimeOverdue ? Colors.red : Colors.orange.shade700, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ], - ), - ], - // Show max retries reached message - if (isRetry && hasReachedMaxRetries) ...[ - const SizedBox(height: 6), - Row( - children: [ - const Icon( - Icons.block, - size: 16, - color: Colors.red, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Maximum retry attempts reached. No more reminders will be sent.', - style: TextStyle( - color: Colors.red.shade700, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ], - // Show last retry time if available - if (isRetry && lastRetryTime != null) ...[ - const SizedBox(height: 6), - Row( - children: [ - const Icon( - Icons.history, - size: 16, - color: Colors.grey, - ), - const SizedBox(width: 8), - Text( - 'Last retry: ${_formatTime(lastRetryTime)}', - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 12, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ], - if (type == 'tracked' && notification['supplementName'] != null) ...[ const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.medication, - size: 16, - color: Colors.grey, - ), - const SizedBox(width: 8), - Text( - notification['supplementName'], - style: TextStyle( - color: Colors.grey.shade700, - fontStyle: FontStyle.italic, + Text( + 'Notifications are now scheduled daily at the selected times ' + 'without retries or tracking.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey.shade600, ), - ), - ], + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.close), + label: const Text('Close'), ), ], - ], + ), ), ), ); } - - String _formatTime(DateTime dateTime) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final tomorrow = today.add(const Duration(days: 1)); - final notificationDate = DateTime(dateTime.year, dateTime.month, dateTime.day); - - String timeStr = '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; - - if (notificationDate == today) { - return 'Today $timeStr'; - } else if (notificationDate == tomorrow) { - return 'Tomorrow $timeStr'; - } else { - return '${dateTime.day}/${dateTime.month} $timeStr'; - } - } - - String _formatOverdueTime(DateTime scheduledTime) { - final now = DateTime.now(); - final overdue = now.difference(scheduledTime); - - if (overdue.inDays > 0) { - return '${overdue.inDays}d ${overdue.inHours % 24}h overdue'; - } else if (overdue.inHours > 0) { - return '${overdue.inHours}h ${overdue.inMinutes % 60}m overdue'; - } else { - return '${overdue.inMinutes}m overdue'; - } - } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4728a55..55ff76d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/settings_provider.dart'; -import 'pending_notifications_screen.dart'; import 'profile_setup_screen.dart'; import 'simple_sync_settings_screen.dart'; @@ -101,101 +100,7 @@ class SettingsScreen extends StatelessWidget { ), ), const SizedBox(height: 16), - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.notifications_active, color: Colors.blue), - const SizedBox(width: 8), - Text( - 'Reminders', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Configure reminders and how often they are retried when ignored', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('Enable Persistent Reminders'), - subtitle: const Text('Resend notifications if ignored after a specific time'), - value: settingsProvider.persistentReminders, - onChanged: (value) { - settingsProvider.setPersistentReminders(value); - }, - ), - if (settingsProvider.persistentReminders) ...[ - const SizedBox(height: 16), - Text( - 'Retry Interval', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 5, label: Text('5 min')), - ButtonSegment(value: 10, label: Text('10 min')), - ButtonSegment(value: 15, label: Text('15 min')), - ButtonSegment(value: 30, label: Text('30 min')), - ], - selected: {settingsProvider.reminderRetryInterval}, - onSelectionChanged: (values) { - settingsProvider.setReminderRetryInterval(values.first); - }, - ), - const SizedBox(height: 16), - Text( - 'Maximum Retry Attempts', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: 1, label: Text('1')), - ButtonSegment(value: 2, label: Text('2')), - ButtonSegment(value: 3, label: Text('3')), - ButtonSegment(value: 4, label: Text('4')), - ButtonSegment(value: 5, label: Text('5')), - ], - selected: {settingsProvider.maxRetryAttempts}, - onSelectionChanged: (values) { - settingsProvider.setMaxRetryAttempts(values.first); - }, - ), - const SizedBox(height: 16), - Text( - 'Notification Actions', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - SizedBox( - width: 320, - child: ElevatedButton.icon( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const PendingNotificationsScreen(), - ), - ); - }, - icon: const Icon(Icons.list), - label: const Text('View Pending Notifications'), - ), - ), - ], - ], - ), - ), - ), +// Reminders settings removed const SizedBox(height: 16), Card( child: Padding( diff --git a/lib/screens/supplements_list_screen.dart b/lib/screens/supplements_list_screen.dart index 40cdb67..4681179 100644 --- a/lib/screens/supplements_list_screen.dart +++ b/lib/screens/supplements_list_screen.dart @@ -1,16 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:supplements/widgets/info_chip.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../models/ingredient.dart'; import '../models/supplement.dart'; import '../providers/settings_provider.dart'; import '../providers/simple_sync_provider.dart'; import '../providers/supplement_provider.dart'; import '../services/database_sync_service.dart'; -import '../services/rda_service.dart'; import '../widgets/supplement_card.dart'; +import '../widgets/dialogs/take_supplement_dialog.dart'; import 'add_supplement_screen.dart'; import 'archived_supplements_screen.dart'; @@ -117,162 +114,12 @@ class SupplementsListScreen extends StatelessWidget { return ListView( padding: const EdgeInsets.all(16), children: [ - // Daily RDA overview header - FutureBuilder>>( - future: (() async { - if (provider.todayIntakes.isEmpty) return >{}; - final dailyItems = []; - for (final intake in provider.todayIntakes) { - final supId = intake['supplement_id'] as int; - final unitsRaw = intake['unitsTaken']; - final double units = unitsRaw is int ? unitsRaw.toDouble() : (unitsRaw as double? ?? 1.0); - final matching = provider.supplements.where((s) => s.id == supId); - if (matching.isEmpty) continue; - final sup = matching.first; - for (final ing in sup.ingredients) { - dailyItems.add(ing.copyWith(amount: ing.amount * units)); - } - } - if (dailyItems.isEmpty) return >{}; - final service = RdaService(); - final agg = await service.aggregateDailyIntake( - dailyItems, - dateOfBirth: settingsProvider.dateOfBirth, - gender: settingsProvider.gender, - ); - // Convert to plain map for UI without depending on service types - final result = >{}; - agg.forEach((key, value) { - final v = value; // dynamic - result[key] = { - 'unitLabel': v.unitLabel, - 'rdaValue': v.rdaValue, - 'rdaValueMin': v.rdaValueMin, - 'rdaValueMax': v.rdaValueMax, - 'ulValue': v.ulValue, - 'total': v.totalAmountInRdaUnit, - 'pctRda': v.percentOfRda, - 'pctUl': v.percentOfUl, - 'lifeStage': v.matchedLifeStageLabel, - 'lifeStageDescription': v.matchedLifeStageDescription, - 'rdaType': v.rdaType, - 'note': v.note, - 'nutrientUl': v.nutrientUl != null - ? { - 'value': v.nutrientUl!.value, - 'unit': v.nutrientUl!.unit, - 'duration': v.nutrientUl!.duration, - 'note': v.nutrientUl!.note, - } - : null, - }; - }); - return result; - })(), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const SizedBox.shrink(); - } - final data = snapshot.data; - if (data == null || data.isEmpty) { - return const SizedBox.shrink(); - } - return Card( - margin: const EdgeInsets.only(bottom: 16), - color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.25), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.health_and_safety, size: 18, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Text( - "Today's intake ", - style: Theme.of(context).textTheme.titleSmall, - ), - Text( - "(Vs Recommended Dietary Allowance)", - style: Theme.of(context).textTheme.labelSmall, - ), - const Spacer(), - IconButton( - tooltip: 'Sources & disclaimer', - icon: const Icon(Icons.info_outline, size: 18), - onPressed: () => _showRdaSourcesSheet(context), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: data.entries.map((e) { - final v = e.value; - final double pct = (v['pctRda'] as double?) ?? 0.0; - final double? pctUl = v['pctUl'] as double?; - final pretty = e.key.split('_').map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}').join(' '); - Color bg = Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5); - if (pctUl != null && pctUl > 100.0) { - bg = (pctUl <= 110.0) ? Colors.amber.withOpacity(0.25) : Colors.red.withOpacity(0.25); - } else if (pct >= 100.0) { - bg = Colors.green.withOpacity(0.25); - } - final color = Theme.of(context).colorScheme.onSurfaceVariant; - return InkWell( - onTap: () { - _showRdaDetailsSheet(context, pretty, v); - }, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withOpacity(0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - pretty, - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color), - ), - const SizedBox(width: 6), - Text( - '${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}%', - style: TextStyle(fontSize: 12, color: color), - ), - if (pctUl != null) ...[ - const SizedBox(width: 6), - Icon( - pctUl > 100.0 ? Icons.warning_amber : Icons.shield_outlined, - size: 14, - color: pctUl > 100.0 - ? (pctUl <= 110.0 ? Colors.amber : Colors.red) - : color, - ), - ], - ], - ), - ), - ); - }).toList(), - ), - ], - ), - ), - ); - }, - ), if (groupedSupplements['morning']!.isNotEmpty) ...[ _buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length), ...groupedSupplements['morning']!.map((supplement) => SupplementCard( supplement: supplement, - onTake: () => _showTakeDialog(context, supplement), + onTake: () => showTakeSupplementDialog(context, supplement), onEdit: () => _editSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement), @@ -287,7 +134,7 @@ class SupplementsListScreen extends StatelessWidget { ...groupedSupplements['afternoon']!.map((supplement) => SupplementCard( supplement: supplement, - onTake: () => _showTakeDialog(context, supplement), + onTake: () => showTakeSupplementDialog(context, supplement), onEdit: () => _editSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement), @@ -302,7 +149,7 @@ class SupplementsListScreen extends StatelessWidget { ...groupedSupplements['evening']!.map((supplement) => SupplementCard( supplement: supplement, - onTake: () => _showTakeDialog(context, supplement), + onTake: () => showTakeSupplementDialog(context, supplement), onEdit: () => _editSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement), @@ -317,7 +164,7 @@ class SupplementsListScreen extends StatelessWidget { ...groupedSupplements['night']!.map((supplement) => SupplementCard( supplement: supplement, - onTake: () => _showTakeDialog(context, supplement), + onTake: () => showTakeSupplementDialog(context, supplement), onEdit: () => _editSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement), @@ -332,7 +179,7 @@ class SupplementsListScreen extends StatelessWidget { ...groupedSupplements['anytime']!.map((supplement) => SupplementCard( supplement: supplement, - onTake: () => _showTakeDialog(context, supplement), + onTake: () => showTakeSupplementDialog(context, supplement), onEdit: () => _editSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement), @@ -433,248 +280,6 @@ class SupplementsListScreen extends StatelessWidget { return grouped; } - void _showRdaSourcesSheet(BuildContext context) { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Dietary reference sources', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Source: Health Canada Dietary Reference Intakes. Values are matched by your age and sex. Some ULs (e.g., magnesium) apply to supplemental intake only.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.open_in_new, size: 18), - title: const Text( - 'Vitamins reference values', - style: TextStyle(fontSize: 13), - ), - subtitle: const Text( - 'canada.ca • reference-values-vitamins', - style: TextStyle(fontSize: 11), - ), - onTap: () => _launchUrl('https://www.canada.ca/en/health-canada/services/food-nutrition/healthy-eating/dietary-reference-intakes/tables/reference-values-vitamins.html'), - ), - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.open_in_new, size: 18), - title: const Text( - 'Elements (minerals) reference values', - style: TextStyle(fontSize: 13), - ), - subtitle: const Text( - 'canada.ca • reference-values-elements', - style: TextStyle(fontSize: 11), - ), - onTap: () => _launchUrl('https://www.canada.ca/en/health-canada/services/food-nutrition/healthy-eating/dietary-reference-intakes/tables/reference-values-elements.html'), - ), - const SizedBox(height: 8), - Text( - 'Disclaimer: Informational only, some of the details in this app are parsed using AI, and may not be accurate. Always consult a healthcare professional for personalized advice.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - }, - ); - } - - Future _launchUrl(String url) async { - final uri = Uri.parse(url); - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - - void _showRdaDetailsSheet(BuildContext context, String nutrientPretty, Map data) { - showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: false, - builder: (context) { - final unitLabel = (data['unitLabel'] as String?) ?? ''; - final rdaValue = data['rdaValue'] as double?; - final ulValue = data['ulValue'] as double?; - final total = data['total'] as double?; - final pctRda = data['pctRda'] as double?; - final pctUl = data['pctUl'] as double?; - final rdaType = data['rdaType'] as String? ?? ''; - final lifeStage = data['lifeStage'] as String? ?? ''; - final note = data['note'] as String?; - final lifeStageDesc = data['lifeStageDescription'] as String?; - final rdaValueMin = data['rdaValueMin'] as double?; - final rdaValueMax = data['rdaValueMax'] as double?; - final nutrientUl = (data['nutrientUl'] as Map?)?.cast(); - return Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Header row: title and sources button - Row( - children: [ - Expanded( - child: Text( - nutrientPretty, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - tooltip: 'Sources & disclaimer', - icon: const Icon(Icons.info_outline), - onPressed: () => _showRdaSourcesSheet(context), - ), - ], - ), - if (lifeStage.isNotEmpty || (lifeStageDesc != null && lifeStageDesc.isNotEmpty)) ...[ - const SizedBox(height: 4), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - if (lifeStage.isNotEmpty) - InfoChip( - icon: Icons.person_outline, - label: 'Life stage: $lifeStage', - context: context, - ), - if (lifeStageDesc != null && lifeStageDesc.isNotEmpty) - InfoChip( - icon: Icons.info_outline, - label: lifeStageDesc!, - context: context, - ), - ], - ), - ], - const SizedBox(height: 8), - // Intake vs RDA chips - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (total != null) - InfoChip( - icon: Icons.local_drink, - label: 'Intake: ${total.toStringAsFixed(total % 1 == 0 ? 0 : 1)} $unitLabel', - context: context, - ), - if (rdaValueMin != null && rdaValueMax != null) - InfoChip( - icon: Icons.rule, - label: 'RDA: ${rdaValueMin!.toStringAsFixed(rdaValueMin! % 1 == 0 ? 0 : 1)}–${rdaValueMax!.toStringAsFixed(rdaValueMax! % 1 == 0 ? 0 : 1)} $unitLabel', - context: context, - ) - else if (rdaValue != null) - InfoChip( - icon: Icons.rule, - label: 'RDA: ${rdaValue!.toStringAsFixed(rdaValue! % 1 == 0 ? 0 : 1)} $unitLabel', - context: context, - ), - if (pctRda != null) - InfoChip( - icon: Icons.percent, - label: '%RDA: ${pctRda.toStringAsFixed(pctRda >= 10 ? 0 : 1)}%', - context: context, - ), - if (ulValue != null) - InfoChip( - icon: Icons.shield_outlined, - label: 'UL: ${ulValue.toStringAsFixed(ulValue % 1 == 0 ? 0 : 1)} $unitLabel', - context: context, - ), - if (pctUl != null) - InfoChip( - icon: Icons.warning_amber, - label: '%UL: ${pctUl.toStringAsFixed(pctUl >= 10 ? 0 : 1)}%', - context: context, - ), - ], - ), - if (rdaType.isNotEmpty || (note != null && note!.isNotEmpty) || nutrientUl != null) ...[ - const SizedBox(height: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (rdaType.isNotEmpty) - Text( - 'Basis: $rdaType', - style: Theme.of(context).textTheme.bodySmall, - ), - if (nutrientUl != null) ...[ - const SizedBox(height: 6), - Text( - 'Upper limit guidance', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 4), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - InfoChip( - icon: Icons.shield_moon_outlined, - label: 'UL: ${nutrientUl['value']} ${nutrientUl['unit']}', - context: context, - ), - if ((nutrientUl['duration'] as String?)?.isNotEmpty ?? false) - InfoChip( - icon: Icons.schedule, - label: 'Duration: ${nutrientUl['duration']}', - context: context, - ), - ], - ), - if ((nutrientUl['note'] as String?)?.isNotEmpty ?? false) ...[ - const SizedBox(height: 4), - Text( - nutrientUl['note'], - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ], - if (note != null && note!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - note!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ], - ], - ), - ), - ); - }, - ); - } - void _showTakeDialog(BuildContext context, Supplement supplement) { final unitsController = TextEditingController(text: supplement.numberOfUnits.toString()); final notesController = TextEditingController(); diff --git a/lib/services/auto_sync_service.dart b/lib/services/auto_sync_service.dart index 5dfb1bf..d66907e 100644 --- a/lib/services/auto_sync_service.dart +++ b/lib/services/auto_sync_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; +import 'package:supplements/logging.dart'; import '../providers/settings_provider.dart'; import '../providers/simple_sync_provider.dart'; @@ -69,7 +70,7 @@ class AutoSyncService { // Check if auto-sync is enabled if (!_settingsProvider.autoSyncEnabled) { if (kDebugMode) { - print('AutoSyncService: Auto-sync is disabled, skipping trigger'); + printLog('AutoSyncService: Auto-sync is disabled, skipping trigger'); } return; } @@ -77,7 +78,7 @@ class AutoSyncService { // Check if auto-sync was disabled due to persistent errors if (_autoDisabledDueToErrors) { if (kDebugMode) { - print('AutoSyncService: Auto-sync disabled due to persistent errors, skipping trigger'); + printLog('AutoSyncService: Auto-sync disabled due to persistent errors, skipping trigger'); } return; } @@ -90,7 +91,7 @@ class AutoSyncService { timestamp: DateTime.now(), )); if (kDebugMode) { - print('AutoSyncService: Sync not configured, skipping auto-sync'); + printLog('AutoSyncService: Sync not configured, skipping auto-sync'); } return; } @@ -99,7 +100,7 @@ class AutoSyncService { if (_syncInProgress || _syncProvider.isSyncing) { _hasPendingSync = true; if (kDebugMode) { - print('AutoSyncService: Sync in progress, marking pending sync'); + printLog('AutoSyncService: Sync in progress, marking pending sync'); } return; } @@ -111,7 +112,7 @@ class AutoSyncService { final backoffDelay = _calculateBackoffDelay(); if (backoffDelay > 0) { if (kDebugMode) { - print('AutoSyncService: Applying backoff delay of ${backoffDelay}s due to recent failures'); + printLog('AutoSyncService: Applying backoff delay of ${backoffDelay}s due to recent failures'); } _debounceTimer = Timer(Duration(seconds: backoffDelay), () { _executePendingSync(); @@ -126,7 +127,7 @@ class AutoSyncService { }); if (kDebugMode) { - print('AutoSyncService: Auto-sync scheduled in ${debounceSeconds}s'); + printLog('AutoSyncService: Auto-sync scheduled in ${debounceSeconds}s'); } } @@ -135,14 +136,14 @@ class AutoSyncService { // Double-check conditions before executing if (!_settingsProvider.autoSyncEnabled) { if (kDebugMode) { - print('AutoSyncService: Auto-sync disabled during execution, aborting'); + printLog('AutoSyncService: Auto-sync disabled during execution, aborting'); } return; } if (_autoDisabledDueToErrors) { if (kDebugMode) { - print('AutoSyncService: Auto-sync disabled due to errors during execution, aborting'); + printLog('AutoSyncService: Auto-sync disabled due to errors during execution, aborting'); } return; } @@ -154,14 +155,14 @@ class AutoSyncService { timestamp: DateTime.now(), )); if (kDebugMode) { - print('AutoSyncService: Sync not configured during execution, aborting'); + printLog('AutoSyncService: Sync not configured during execution, aborting'); } return; } if (_syncInProgress || _syncProvider.isSyncing) { if (kDebugMode) { - print('AutoSyncService: Sync already in progress during execution, aborting'); + printLog('AutoSyncService: Sync already in progress during execution, aborting'); } return; } @@ -171,7 +172,7 @@ class AutoSyncService { try { if (kDebugMode) { - print('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})'); + printLog('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})'); } // Check network connectivity before attempting sync @@ -191,12 +192,12 @@ class AutoSyncService { _autoDisabledDueToErrors = false; if (kDebugMode) { - print('AutoSyncService: Auto-sync completed successfully'); + printLog('AutoSyncService: Auto-sync completed successfully'); } } catch (e) { if (kDebugMode) { - print('AutoSyncService: Auto-sync failed: $e'); + printLog('AutoSyncService: Auto-sync failed: $e'); } // Handle specific error types @@ -208,7 +209,7 @@ class AutoSyncService { // If there was a pending sync request while we were syncing, trigger it if (_hasPendingSync && !_autoDisabledDueToErrors) { if (kDebugMode) { - print('AutoSyncService: Processing queued sync request'); + printLog('AutoSyncService: Processing queued sync request'); } _hasPendingSync = false; // Use a small delay to avoid immediate re-triggering @@ -231,14 +232,14 @@ class AutoSyncService { if (_consecutiveFailures >= _autoDisableThreshold) { _autoDisabledDueToErrors = true; if (kDebugMode) { - print('AutoSyncService: Auto-sync disabled due to ${_consecutiveFailures} consecutive failures'); + printLog('AutoSyncService: Auto-sync disabled due to ${_consecutiveFailures} consecutive failures'); } // For configuration errors, disable immediately if (autoSyncError.type == AutoSyncErrorType.configuration || autoSyncError.type == AutoSyncErrorType.authentication) { if (kDebugMode) { - print('AutoSyncService: Auto-sync disabled due to configuration/authentication error'); + printLog('AutoSyncService: Auto-sync disabled due to configuration/authentication error'); } } } @@ -328,7 +329,7 @@ class AutoSyncService { } if (kDebugMode) { - print('AutoSyncService: Recorded error: $error'); + printLog('AutoSyncService: Recorded error: $error'); } } @@ -375,13 +376,13 @@ class AutoSyncService { _retryTimer?.cancel(); _retryTimer = Timer(Duration(seconds: retryDelay), () { if (kDebugMode) { - print('AutoSyncService: Retrying auto-sync after backoff delay'); + printLog('AutoSyncService: Retrying auto-sync after backoff delay'); } triggerAutoSync(); }); if (kDebugMode) { - print('AutoSyncService: Scheduled retry in ${retryDelay}s'); + printLog('AutoSyncService: Scheduled retry in ${retryDelay}s'); } } @@ -392,7 +393,7 @@ class AutoSyncService { return result.isNotEmpty && result[0].rawAddress.isNotEmpty; } catch (e) { if (kDebugMode) { - print('AutoSyncService: Network check failed: $e'); + printLog('AutoSyncService: Network check failed: $e'); } return false; } @@ -406,7 +407,7 @@ class AutoSyncService { _hasPendingSync = false; if (kDebugMode) { - print('AutoSyncService: Cancelled pending sync and retry timer'); + printLog('AutoSyncService: Cancelled pending sync and retry timer'); } } @@ -426,7 +427,7 @@ class AutoSyncService { _retryTimer = null; if (kDebugMode) { - print('AutoSyncService: Error state reset, auto-sync re-enabled'); + printLog('AutoSyncService: Error state reset, auto-sync re-enabled'); } } @@ -440,7 +441,7 @@ class AutoSyncService { _recentErrors.clear(); if (kDebugMode) { - print('AutoSyncService: Disposed'); + printLog('AutoSyncService: Disposed'); } } diff --git a/lib/services/database_sync_service.dart b/lib/services/database_sync_service.dart index bc4b572..d114011 100644 --- a/lib/services/database_sync_service.dart +++ b/lib/services/database_sync_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:path/path.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; +import 'package:supplements/logging.dart'; import 'package:webdav_client/webdav_client.dart'; import '../models/supplement.dart'; @@ -92,7 +93,7 @@ class DatabaseSyncService { } } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error loading saved sync configuration: $e'); + printLog('Error loading saved sync configuration: $e'); } } } @@ -107,7 +108,7 @@ class DatabaseSyncService { if (_configuredRemotePath != null) await prefs.setString(_keyRemotePath, _configuredRemotePath!); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Error saving sync configuration: $e'); + printLog('Error saving sync configuration: $e'); } } } @@ -145,7 +146,7 @@ class DatabaseSyncService { return true; } catch (e) { if (kDebugMode) { - print('SupplementsLog: Connection test failed: $e'); + printLog('Connection test failed: $e'); } return false; } @@ -182,7 +183,7 @@ class DatabaseSyncService { _setStatus(SyncStatus.error); onError?.call(_lastError!); if (kDebugMode) { - print('SupplementsLog: Sync failed: $e'); + printLog('Sync failed: $e'); } rethrow; } @@ -196,13 +197,13 @@ class DatabaseSyncService { if (!remoteDbExists) { if (kDebugMode) { - print('SupplementsLog: No remote database found, will upload local database'); + printLog('No remote database found, will upload local database'); } return null; } if (kDebugMode) { - print('SupplementsLog: Remote database found, downloading...'); + printLog('Remote database found, downloading...'); } // Download the remote database @@ -217,14 +218,14 @@ class DatabaseSyncService { await tempFile.writeAsBytes(remoteDbBytes); if (kDebugMode) { - print('SupplementsLog: Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath'); + printLog('Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath'); } return tempDbPath; } catch (e) { if (kDebugMode) { - print('SupplementsLog: Failed to download remote database: $e'); + printLog('Failed to download remote database: $e'); } return null; } @@ -233,13 +234,13 @@ class DatabaseSyncService { Future _mergeDatabases(String? remoteDbPath) async { if (remoteDbPath == null) { if (kDebugMode) { - print('SupplementsLog: No remote database to merge'); + printLog('No remote database to merge'); } return; } if (kDebugMode) { - print('SupplementsLog: Starting database merge from: $remoteDbPath'); + printLog('Starting database merge from: $remoteDbPath'); } final localDb = await _databaseHelper.database; @@ -249,21 +250,21 @@ class DatabaseSyncService { // Check what tables exist in remote database if (kDebugMode) { final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'"); - print('SupplementsLog: Remote database tables: ${tables.map((t) => t['name']).toList()}'); + printLog('Remote database tables: ${tables.map((t) => t['name']).toList()}'); // Count records in each table try { final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements'); - print('SupplementsLog: Remote supplements count: ${supplementCount.first['count']}'); + printLog('Remote supplements count: ${supplementCount.first['count']}'); } catch (e) { - print('SupplementsLog: Error counting supplements: $e'); + printLog('Error counting supplements: $e'); } try { final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes'); - print('SupplementsLog: Remote intakes count: ${intakeCount.first['count']}'); + printLog('Remote intakes count: ${intakeCount.first['count']}'); } catch (e) { - print('SupplementsLog: Error counting intakes: $e'); + printLog('Error counting intakes: $e'); } } @@ -274,7 +275,7 @@ class DatabaseSyncService { await _mergeIntakes(localDb, remoteDb); if (kDebugMode) { - print('SupplementsLog: Database merge completed successfully'); + printLog('Database merge completed successfully'); } } finally { @@ -284,7 +285,7 @@ class DatabaseSyncService { Future _mergeSupplements(Database localDb, Database remoteDb) async { if (kDebugMode) { - print('SupplementsLog: Starting supplement merge...'); + printLog('Starting supplement merge...'); } // Get all supplements from remote database @@ -293,19 +294,19 @@ class DatabaseSyncService { remoteMaps.map((map) => Supplement.fromMap(map)).toList(); if (kDebugMode) { - print( - 'SupplementsLog: Found ${remoteSupplements.length} supplements in remote database'); + printLog( + 'Found ${remoteSupplements.length} supplements in remote database'); for (final supplement in remoteSupplements) { - print( - 'SupplementsLog: Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})'); + printLog( + 'Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})'); } } for (final remoteSupplement in remoteSupplements) { if (remoteSupplement.syncId.isEmpty) { if (kDebugMode) { - print( - 'SupplementsLog: Skipping supplement ${remoteSupplement.name} - no syncId'); + printLog( + 'Skipping supplement ${remoteSupplement.name} - no syncId'); } continue; } @@ -325,13 +326,13 @@ class DatabaseSyncService { mapToInsert.remove('id'); await localDb.insert('supplements', mapToInsert); if (kDebugMode) { - print( - 'SupplementsLog: ✓ Inserted new supplement: ${remoteSupplement.name}'); + printLog( + '✓ Inserted new supplement: ${remoteSupplement.name}'); } } else { if (kDebugMode) { - print( - 'SupplementsLog: Skipping deleted supplement: ${remoteSupplement.name}'); + printLog( + 'Skipping deleted supplement: ${remoteSupplement.name}'); } } } else { @@ -349,26 +350,26 @@ class DatabaseSyncService { whereArgs: [existingSupplement.id], ); if (kDebugMode) { - print( - 'SupplementsLog: ✓ Updated supplement: ${remoteSupplement.name}'); + printLog( + '✓ Updated supplement: ${remoteSupplement.name}'); } } else { if (kDebugMode) { - print( - 'SupplementsLog: Local supplement ${remoteSupplement.name} is newer, keeping local version'); + printLog( + 'Local supplement ${remoteSupplement.name} is newer, keeping local version'); } } } } if (kDebugMode) { - print('SupplementsLog: Supplement merge completed'); + printLog('Supplement merge completed'); } } Future _mergeIntakes(Database localDb, Database remoteDb) async { if (kDebugMode) { - print('SupplementsLog: Starting intake merge...'); + printLog('Starting intake merge...'); } // Get all intakes from remote database @@ -376,13 +377,13 @@ class DatabaseSyncService { final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList(); if (kDebugMode) { - print('SupplementsLog: Found ${remoteIntakes.length} intakes in remote database'); + printLog('Found ${remoteIntakes.length} intakes in remote database'); } for (final remoteIntake in remoteIntakes) { if (remoteIntake.syncId.isEmpty) { if (kDebugMode) { - print('SupplementsLog: Skipping intake - no syncId'); + printLog('Skipping intake - no syncId'); } continue; } @@ -405,16 +406,16 @@ class DatabaseSyncService { ); await localDb.insert('supplement_intakes', intakeToInsert.toMap()); if (kDebugMode) { - print('SupplementsLog: ✓ Inserted new intake: ${remoteIntake.syncId}'); + printLog('✓ Inserted new intake: ${remoteIntake.syncId}'); } } else { if (kDebugMode) { - print('SupplementsLog: Could not find local supplement for intake ${remoteIntake.syncId}'); + printLog('Could not find local supplement for intake ${remoteIntake.syncId}'); } } } else { if (kDebugMode) { - print('SupplementsLog: Skipping deleted intake: ${remoteIntake.syncId}'); + printLog('Skipping deleted intake: ${remoteIntake.syncId}'); } } } else { @@ -430,18 +431,18 @@ class DatabaseSyncService { whereArgs: [existingIntake.id], ); if (kDebugMode) { - print('SupplementsLog: ✓ Updated intake: ${remoteIntake.syncId}'); + printLog('✓ Updated intake: ${remoteIntake.syncId}'); } } else { if (kDebugMode) { - print('SupplementsLog: Local intake ${remoteIntake.syncId} is newer, keeping local version'); + printLog('Local intake ${remoteIntake.syncId} is newer, keeping local version'); } } } } if (kDebugMode) { - print('SupplementsLog: Intake merge completed'); + printLog('Intake merge completed'); } } @@ -476,7 +477,7 @@ class DatabaseSyncService { final dbPath = localDb.path; if (kDebugMode) { - print('SupplementsLog: Reading database from: $dbPath'); + printLog('Reading database from: $dbPath'); } // Read the database file @@ -488,7 +489,7 @@ class DatabaseSyncService { final dbBytes = await dbFile.readAsBytes(); if (kDebugMode) { - print('SupplementsLog: Database file size: ${dbBytes.length} bytes'); + printLog('Database file size: ${dbBytes.length} bytes'); } if (dbBytes.isEmpty) { @@ -500,7 +501,7 @@ class DatabaseSyncService { await _client!.readDir(_remotePath!); } catch (e) { if (kDebugMode) { - print('SupplementsLog: Creating remote directory: $_remotePath'); + printLog('Creating remote directory: $_remotePath'); } await _client!.mkdir(_remotePath!); } @@ -510,12 +511,12 @@ class DatabaseSyncService { await _client!.write(remoteUrl, dbBytes); if (kDebugMode) { - print('SupplementsLog: Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl'); + printLog('Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl'); } } catch (e) { if (kDebugMode) { - print('SupplementsLog: Failed to upload database: $e'); + printLog('Failed to upload database: $e'); } rethrow; } diff --git a/lib/services/notification_router.dart b/lib/services/notification_router.dart new file mode 100644 index 0000000..50fa9fb --- /dev/null +++ b/lib/services/notification_router.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:provider/provider.dart'; +import 'package:supplements/logging.dart'; + +import '../models/supplement.dart'; +import '../providers/supplement_provider.dart'; +import '../widgets/dialogs/bulk_take_dialog.dart'; +import '../widgets/dialogs/take_supplement_dialog.dart'; + +/// Centralizes routing from notification actions/taps to in-app UI. +/// Handles both foreground/background taps and terminated-launch scenarios. +class NotificationRouter { + NotificationRouter._internal(); + static final NotificationRouter instance = NotificationRouter._internal(); + + GlobalKey? _navigatorKey; + + void initialize(GlobalKey navigatorKey) { + _navigatorKey = navigatorKey; + } + + Future handleNotificationResponse(NotificationResponse response) async { + final payloadMap = _decodePayload(response.payload); + final actionId = response.actionId; + printLog('🔔 handleNotificationResponse: actionId=$actionId payload=${response.payload} map=$payloadMap'); + + await _routeFromPayload(payloadMap); + } + + Future handleAppLaunchDetails(NotificationAppLaunchDetails? details) async { + if (details == null) return; + if (!details.didNotificationLaunchApp) return; + + final resp = details.notificationResponse; + final payloadMap = _decodePayload(resp?.payload); + printLog('🚀 App launched from notification: payload=${resp?.payload} map=$payloadMap'); + + await _routeFromPayload(payloadMap); + } + + Map? _decodePayload(String? payload) { + if (payload == null || payload.isEmpty) return null; + + // Try JSON first + try { + final map = jsonDecode(payload); + if (map is Map) return map; + } catch (_) { + // Ignore and try fallback + } + + // Fallback: previous implementation used HH:mm as raw payload + final hhmm = RegExp(r'^\d{2}:\d{2}$'); + if (hhmm.hasMatch(payload)) { + return { 'type': 'group', 'time': payload }; + } + + return null; + } + + Future _routeFromPayload(Map? payload) async { + if (_navigatorKey == null) { + printLog('⚠️ NotificationRouter not initialized with navigatorKey'); + return; + } + + // Wait until navigator is ready and providers have loaded + final ready = await _waitUntilReady(timeout: const Duration(seconds: 5)); + if (!ready) { + printLog('⚠️ Timeout waiting for app to be ready for routing'); + return; + } + + final context = _navigatorKey!.currentContext!; + final provider = context.read(); + + if (payload == null) { + printLog('⚠️ No payload to route'); + return; + } + + final type = payload['type']; + if (type == 'single') { + final id = payload['id']; + if (id is int) { + Supplement? s; + try { + s = provider.supplements.firstWhere((el) => el.id == id); + } catch (_) { + s = null; + } + if (s == null) { + // Attempt reload once + await provider.loadSupplements(); + try { + s = provider.supplements.firstWhere((el) => el.id == id); + } catch (_) { + s = null; + } + } + if (s != null) { + // For single: use the regular dialog (with time selection) + // Ensure we close any existing dialog first + _popAnyDialog(context); + await showTakeSupplementDialog(context, s, hideTime: false); + } else { + printLog('⚠️ Supplement id=$id not found for single-take routing'); + _showSnack(context, 'Supplement not found'); + } + } + } else if (type == 'group') { + final timeKey = payload['time']; + if (timeKey is String) { + // Build list of supplements scheduled at this timeKey + final List list = provider.supplements.where((s) { + return s.isActive && s.reminderTimes.contains(timeKey); + }).toList(); + + if (list.isEmpty) { + printLog('⚠️ No supplements found for group time=$timeKey'); + _showSnack(context, 'No supplements for $timeKey'); + return; + } + + _popAnyDialog(context); + await showBulkTakeDialog(context, list); + } + } else { + printLog('⚠️ Unknown payload type: $type'); + } + } + + Future _waitUntilReady({required Duration timeout}) async { + final start = DateTime.now(); + while (DateTime.now().difference(start) < timeout) { + final ctx = _navigatorKey!.currentContext; + if (ctx != null) { + final provider = Provider.of(ctx, listen: false); + if (!provider.isLoading) { + return true; + } + } + await Future.delayed(const Duration(milliseconds: 100)); + } + return false; + } + + void _popAnyDialog(BuildContext context) { + if (Navigator.of(context, rootNavigator: true).canPop()) { + Navigator.of(context, rootNavigator: true).pop(); + } + } + + void _showSnack(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index e49c9a4..c41fbc2 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,704 +1,11 @@ -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:timezone/timezone.dart' as tz; -import 'package:timezone/data/latest.dart' as tz; -import '../models/supplement.dart'; -import 'database_helper.dart'; +/* + Deprecated/removed: notification_service.dart -// Top-level function to handle notification responses when app is running -@pragma('vm:entry-point') -void notificationTapBackground(NotificationResponse notificationResponse) { - print('SupplementsLog: 📱 === BACKGROUND NOTIFICATION RESPONSE ==='); - print('SupplementsLog: 📱 Action ID: ${notificationResponse.actionId}'); - print('SupplementsLog: 📱 Payload: ${notificationResponse.payload}'); - print('SupplementsLog: 📱 Notification ID: ${notificationResponse.id}'); - print('SupplementsLog: 📱 =========================================='); + This legacy notification service has been intentionally removed. + The app now uses a minimal scheduler in: + services/simple_notification_service.dart - // For now, just log the action. The main app handler will process it. - if (notificationResponse.actionId == 'take_supplement') { - print('SupplementsLog: 📱 BACKGROUND: Take action detected'); - } else if (notificationResponse.actionId == 'snooze_10') { - print('SupplementsLog: 📱 BACKGROUND: Snooze action detected'); - } -} - -class NotificationService { - static final NotificationService _instance = NotificationService._internal(); - factory NotificationService() => _instance; - NotificationService._internal(); - - final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); - bool _isInitialized = false; - static bool _engineInitialized = false; - bool _permissionsRequested = false; - - // Callback for handling supplement intake from notifications - Function(int supplementId, String supplementName, double units, String unitType)? _onTakeSupplementCallback; - - // Set callback for handling supplement intake from notifications - void setTakeSupplementCallback(Function(int supplementId, String supplementName, double units, String unitType) callback) { - _onTakeSupplementCallback = callback; - } - - Future initialize() async { - print('SupplementsLog: 📱 Initializing NotificationService...'); - if (_isInitialized) { - print('SupplementsLog: 📱 Already initialized'); - return; - } - - try { - print('SupplementsLog: 📱 Initializing timezones...'); - print('SupplementsLog: 📱 Engine initialized flag: $_engineInitialized'); - - if (!_engineInitialized) { - tz.initializeTimeZones(); - _engineInitialized = true; - print('SupplementsLog: 📱 Timezones initialized successfully'); - } else { - print('SupplementsLog: 📱 Timezones already initialized, skipping'); - } - } catch (e) { - print('SupplementsLog: 📱 Warning: Timezone initialization issue (may already be initialized): $e'); - _engineInitialized = true; // Mark as initialized to prevent retry - } - - // Try to detect and set the local timezone more reliably - try { - // First try using the system timezone name - final String timeZoneName = DateTime.now().timeZoneName; - print('SupplementsLog: 📱 System timezone name: $timeZoneName'); - - tz.Location? location; - - // Try common timezone mappings for your region - if (timeZoneName.contains('CET') || timeZoneName.contains('CEST')) { - location = tz.getLocation('Europe/Amsterdam'); // Netherlands - } else if (timeZoneName.contains('UTC') || timeZoneName.contains('GMT')) { - location = tz.getLocation('UTC'); - } else { - // Fallback: try to use the timezone name directly - try { - location = tz.getLocation(timeZoneName); - } catch (e) { - print('SupplementsLog: 📱 Could not find timezone $timeZoneName, using Europe/Amsterdam as default'); - location = tz.getLocation('Europe/Amsterdam'); - } - } - - tz.setLocalLocation(location); - print('SupplementsLog: 📱 Timezone set to: ${location.name}'); - - } catch (e) { - print('SupplementsLog: 📱 Error setting timezone: $e, using default'); - // Fallback to a reasonable default for Netherlands - tz.setLocalLocation(tz.getLocation('Europe/Amsterdam')); - } - - print('SupplementsLog: 📱 Current local time: ${tz.TZDateTime.now(tz.local)}'); - print('SupplementsLog: 📱 Current system time: ${DateTime.now()}'); - - const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); - const DarwinInitializationSettings iosSettings = DarwinInitializationSettings( - requestAlertPermission: false, // We'll request these separately - requestBadgePermission: false, - requestSoundPermission: false, - ); - const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings( - defaultActionName: 'Open notification', - ); - - const InitializationSettings initSettings = InitializationSettings( - android: androidSettings, - iOS: iosSettings, - linux: linuxSettings, - ); - - print('SupplementsLog: 📱 Initializing flutter_local_notifications...'); - await _notifications.initialize( - initSettings, - onDidReceiveNotificationResponse: _onNotificationResponse, - onDidReceiveBackgroundNotificationResponse: notificationTapBackground, - ); - - // Test if notification response callback is working - print('SupplementsLog: 📱 Callback function is set and ready'); - - _isInitialized = true; - print('SupplementsLog: 📱 NotificationService initialization complete'); - } - - // Handle notification responses (when user taps on notification or action) - void _onNotificationResponse(NotificationResponse response) { - print('SupplementsLog: 📱 === NOTIFICATION RESPONSE ==='); - print('SupplementsLog: 📱 Action ID: ${response.actionId}'); - print('SupplementsLog: 📱 Payload: ${response.payload}'); - print('SupplementsLog: 📱 Notification ID: ${response.id}'); - print('SupplementsLog: 📱 Input: ${response.input}'); - print('SupplementsLog: 📱 ==============================='); - - if (response.actionId == 'take_supplement') { - print('SupplementsLog: 📱 Processing TAKE action...'); - _handleTakeAction(response.payload, response.id); - } else if (response.actionId == 'snooze_10') { - print('SupplementsLog: 📱 Processing SNOOZE action...'); - _handleSnoozeAction(response.payload, 10, response.id); - } else { - print('SupplementsLog: 📱 Default notification tap (no specific action)'); - // Default tap (no actionId) opens the app normally - } - } - - Future _handleTakeAction(String? payload, int? notificationId) async { - print('SupplementsLog: 📱 === HANDLING TAKE ACTION ==='); - print('SupplementsLog: 📱 Payload received: $payload'); - - if (payload != null) { - try { - // Parse the payload to get supplement info - final parts = payload.split('|'); - print('SupplementsLog: 📱 Payload parts: $parts (length: ${parts.length})'); - - if (parts.length >= 4) { - final supplementId = int.parse(parts[0]); - final supplementName = parts[1]; - final units = double.parse(parts[2]); - final unitType = parts[3]; - - print('SupplementsLog: 📱 Parsed data:'); - print('SupplementsLog: 📱 - ID: $supplementId'); - print('SupplementsLog: 📱 - Name: $supplementName'); - print('SupplementsLog: 📱 - Units: $units'); - print('SupplementsLog: 📱 - Type: $unitType'); - - // Call the callback to record the intake - if (_onTakeSupplementCallback != null) { - print('SupplementsLog: 📱 Calling supplement callback...'); - _onTakeSupplementCallback!( - supplementId, supplementName, units, unitType); - print('SupplementsLog: 📱 Callback completed'); - } else { - print('SupplementsLog: 📱 ERROR: No callback registered!'); - } - - // For retry notifications, the original notification ID is in the payload - int originalNotificationId; - if (parts.length > 4 && int.tryParse(parts[4]) != null) { - originalNotificationId = int.parse(parts[4]); - print( - 'SupplementsLog: 📱 Retry notification detected. Original ID: $originalNotificationId'); - } else if (notificationId != null) { - originalNotificationId = notificationId; - } else { - print( - 'SupplementsLog: 📱 ERROR: Could not determine notification ID to cancel.'); - return; - } - - // Mark notification as taken in database (this will cancel any pending retries) - print( - 'SupplementsLog: 📱 Marking notification $originalNotificationId as taken'); - await DatabaseHelper.instance - .markNotificationTaken(originalNotificationId); - - // Cancel any pending retry notifications for this notification - _cancelRetryNotifications(originalNotificationId); - - // Show a confirmation notification - print('SupplementsLog: 📱 Showing confirmation notification...'); - showInstantNotification( - 'Supplement Taken!', - '$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}', - ); - } else { - print( - 'SupplementsLog: 📱 ERROR: Invalid payload format - not enough parts'); - } - } catch (e) { - print('SupplementsLog: 📱 ERROR in _handleTakeAction: $e'); - } - } else { - print('SupplementsLog: 📱 ERROR: Payload is null'); - } - print('SupplementsLog: 📱 === TAKE ACTION COMPLETE ==='); - } - - void _cancelRetryNotifications(int notificationId) { - // Retry notifications use ID range starting from 200000 - for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries - int retryId = 200000 + (notificationId * 10) + i; - _notifications.cancel(retryId); - print('SupplementsLog: 📱 Cancelled retry notification ID: $retryId'); - } - } - - void _handleSnoozeAction(String? payload, int minutes, int? notificationId) { - print('SupplementsLog: 📱 === HANDLING SNOOZE ACTION ==='); - print('SupplementsLog: 📱 Payload: $payload, Minutes: $minutes'); - - if (payload != null) { - try { - final parts = payload.split('|'); - if (parts.length >= 2) { - final supplementId = int.parse(parts[0]); - final supplementName = parts[1]; - - print('SupplementsLog: 📱 Snoozing supplement for $minutes minutes: $supplementName'); - - // Mark notification as snoozed in database (increment retry count) - if (notificationId != null) { - print('SupplementsLog: 📱 Incrementing retry count for notification $notificationId'); - DatabaseHelper.instance.incrementRetryCount(notificationId); - } - - // Schedule a new notification for the snooze time - final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes)); - print('SupplementsLog: 📱 Snooze time: $snoozeTime'); - - _notifications.zonedSchedule( - supplementId * 1000 + minutes, // Unique ID for snooze notifications - 'Reminder: $supplementName', - 'Snoozed reminder - Take your $supplementName now', - snoozeTime, - NotificationDetails( - android: AndroidNotificationDetails( - 'supplement_reminders', - 'Supplement Reminders', - channelDescription: 'Notifications for supplement intake reminders', - importance: Importance.high, - priority: Priority.high, - actions: [ - AndroidNotificationAction( - 'take_supplement', - 'Take', - ), - AndroidNotificationAction( - 'snooze_10', - 'Snooze 10min', - ), - ], - ), - iOS: const DarwinNotificationDetails(), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - payload: payload, - ); - - showInstantNotification( - 'Reminder Snoozed', - '$supplementName reminder snoozed for $minutes minutes', - ); - print('SupplementsLog: 📱 Snooze scheduled successfully'); - } - } catch (e) { - print('SupplementsLog: 📱 Error handling snooze action: $e'); - } - } - print('SupplementsLog: 📱 === SNOOZE ACTION COMPLETE ==='); - } - - /// Check for persistent reminders from app context with settings - Future checkPersistentReminders( - bool persistentReminders, - int reminderRetryInterval, - int maxRetryAttempts, - ) async { - await schedulePersistentReminders( - persistentReminders: persistentReminders, - reminderRetryInterval: reminderRetryInterval, - maxRetryAttempts: maxRetryAttempts, - ); - } - - /// Check for pending notifications that need retry and schedule them - Future schedulePersistentReminders({ - required bool persistentReminders, - required int reminderRetryInterval, - required int maxRetryAttempts, - }) async { - print('SupplementsLog: 📱 Checking for pending notifications to retry...'); - - try { - if (!persistentReminders) { - print('SupplementsLog: 📱 Persistent reminders disabled'); - return; - } - - print('SupplementsLog: 📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts'); - - // Get all pending notifications from database - final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications(); - print('SupplementsLog: 📱 Found ${pendingNotifications.length} pending notifications'); - - final now = DateTime.now(); - - for (final notification in pendingNotifications) { - final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal(); - final retryCount = notification['retryCount'] as int; - final lastRetryTime = notification['lastRetryTime'] != null - ? DateTime.parse(notification['lastRetryTime']).toLocal() - : null; - - // Check if notification is overdue - final timeSinceScheduled = now.difference(scheduledTime).inMinutes; - final shouldRetry = timeSinceScheduled >= reminderRetryInterval; - - print('SupplementsLog: 📱 Checking notification ${notification['notificationId']}:'); - print('SupplementsLog: 📱 Scheduled: $scheduledTime (local)'); - print('SupplementsLog: 📱 Now: $now'); - print('SupplementsLog: 📱 Time since scheduled: $timeSinceScheduled minutes'); - print('SupplementsLog: 📱 Retry interval: $reminderRetryInterval minutes'); - print('SupplementsLog: 📱 Should retry: $shouldRetry'); - print('SupplementsLog: 📱 Retry count: $retryCount / $maxRetryAttempts'); - - // Check if we haven't exceeded max retry attempts - if (retryCount >= maxRetryAttempts) { - print('SupplementsLog: 📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)'); - continue; - } - - // Check if enough time has passed since last retry - if (lastRetryTime != null) { - final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes; - if (timeSinceLastRetry < reminderRetryInterval) { - print('SupplementsLog: 📱 Notification ${notification['notificationId']} not ready for retry yet'); - continue; - } - } - - if (shouldRetry) { - print('SupplementsLog: 📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}'); - await _scheduleRetryNotification(notification, retryCount + 1); - } else { - print('SupplementsLog: 📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}'); - } - } - } catch (e) { - print('SupplementsLog: 📱 Error scheduling persistent reminders: $e'); - } - } - - Future _scheduleRetryNotification(Map notification, int retryAttempt) async { - try { - final notificationId = notification['notificationId'] as int; - final supplementId = notification['supplementId'] as int; - - // Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt) - final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt; - - print('SupplementsLog: 📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)'); - - // Get supplement details from database - final supplements = await DatabaseHelper.instance.getAllSupplements(); - final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found')); - - // Schedule the retry notification immediately - await _notifications.show( - retryNotificationId, - 'Reminder: ${supplement.name}', - 'Don\'t forget to take your ${supplement.name}! (Retry #$retryAttempt)', - NotificationDetails( - android: AndroidNotificationDetails( - 'supplement_reminders', - 'Supplement Reminders', - channelDescription: 'Notifications for supplement intake reminders', - importance: Importance.high, - priority: Priority.high, - actions: [ - AndroidNotificationAction( - 'take_supplement', - 'Take', - showsUserInterface: true, - ), - AndroidNotificationAction( - 'snooze_10', - 'Snooze 10min', - showsUserInterface: true, - ), - ], - ), - iOS: const DarwinNotificationDetails(), - ), - payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId', - ); - - // Update the retry count in database - await DatabaseHelper.instance.incrementRetryCount(notificationId); - - print('SupplementsLog: 📱 Retry notification scheduled successfully'); - } catch (e) { - print('SupplementsLog: 📱 Error scheduling retry notification: $e'); - } - } - - Future requestPermissions() async { - print('SupplementsLog: 📱 Requesting notification permissions...'); - if (_permissionsRequested) { - print('SupplementsLog: 📱 Permissions already requested'); - return true; - } - - try { - _permissionsRequested = true; - - final androidPlugin = _notifications.resolvePlatformSpecificImplementation(); - if (androidPlugin != null) { - print('SupplementsLog: 📱 Requesting Android permissions...'); - final granted = await androidPlugin.requestNotificationsPermission(); - print('SupplementsLog: 📱 Android permissions granted: $granted'); - if (granted != true) { - _permissionsRequested = false; - return false; - } - } - - final iosPlugin = _notifications.resolvePlatformSpecificImplementation(); - if (iosPlugin != null) { - print('SupplementsLog: 📱 Requesting iOS permissions...'); - final granted = await iosPlugin.requestPermissions( - alert: true, - badge: true, - sound: true, - ); - print('SupplementsLog: 📱 iOS permissions granted: $granted'); - if (granted != true) { - _permissionsRequested = false; - return false; - } - } - - print('SupplementsLog: 📱 All permissions granted successfully'); - return true; - } catch (e) { - _permissionsRequested = false; - print('SupplementsLog: 📱 Error requesting permissions: $e'); - return false; - } - } - - Future scheduleSupplementReminders(Supplement supplement) async { - print('SupplementsLog: 📱 Scheduling reminders for ${supplement.name}'); - print('SupplementsLog: 📱 Reminder times: ${supplement.reminderTimes}'); - - // Cancel existing notifications for this supplement - await cancelSupplementReminders(supplement.id!); - - for (int i = 0; i < supplement.reminderTimes.length; i++) { - final timeStr = supplement.reminderTimes[i]; - final timeParts = timeStr.split(':'); - final hour = int.parse(timeParts[0]); - final minute = int.parse(timeParts[1]); - - final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder - final scheduledTime = _nextInstanceOfTime(hour, minute); - - print('SupplementsLog: 📱 Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}'); - - // Track this notification in the database - await DatabaseHelper.instance.trackNotification( - notificationId: notificationId, - supplementId: supplement.id!, - scheduledTime: scheduledTime.toLocal(), - ); - - await _notifications.zonedSchedule( - notificationId, - 'Time for ${supplement.name}', - 'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.ingredientsPerUnit})', - scheduledTime, - NotificationDetails( - android: AndroidNotificationDetails( - 'supplement_reminders', - 'Supplement Reminders', - channelDescription: 'Notifications for supplement intake reminders', - importance: Importance.high, - priority: Priority.high, - actions: [ - AndroidNotificationAction( - 'take_supplement', - 'Take', - icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'), - showsUserInterface: true, // Changed to true to open app - ), - AndroidNotificationAction( - 'snooze_10', - 'Snooze 10min', - icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'), - showsUserInterface: true, // Changed to true to open app - ), - ], - ), - iOS: const DarwinNotificationDetails(), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - matchDateTimeComponents: DateTimeComponents.time, - payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}', - ); - - print('SupplementsLog: 📱 Successfully scheduled notification ID $notificationId'); - } - - // Get all pending notifications to verify - final pendingNotifications = await _notifications.pendingNotificationRequests(); - print('SupplementsLog: 📱 Total pending notifications: ${pendingNotifications.length}'); - for (final notification in pendingNotifications) { - print('SupplementsLog: 📱 Pending: ID=${notification.id}, Title=${notification.title}'); - } - } - - Future cancelSupplementReminders(int supplementId) async { - // Cancel all notifications for this supplement (up to 10 possible reminders) - for (int i = 0; i < 10; i++) { - final notificationId = supplementId * 100 + i; - await _notifications.cancel(notificationId); - } - - // Also clean up database tracking records for this supplement - await DatabaseHelper.instance.clearNotificationTracking(supplementId); - } - - Future cancelAllReminders() async { - await _notifications.cancelAll(); - } - - tz.TZDateTime _nextInstanceOfTime(int hour, int minute) { - final tz.TZDateTime now = tz.TZDateTime.now(tz.local); - tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute); - - print('SupplementsLog: 📱 Current time: $now (${now.timeZoneName})'); - print('SupplementsLog: 📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'); - print('SupplementsLog: 📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})'); - - if (scheduledDate.isBefore(now)) { - scheduledDate = scheduledDate.add(const Duration(days: 1)); - print('SupplementsLog: 📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})'); - } else { - print('SupplementsLog: 📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})'); - } - - return scheduledDate; - } - - Future showInstantNotification(String title, String body) async { - print('SupplementsLog: 📱 Showing instant notification: $title - $body'); - const NotificationDetails notificationDetails = NotificationDetails( - android: AndroidNotificationDetails( - 'instant_notifications', - 'Instant Notifications', - channelDescription: 'Instant notifications for supplement app', - importance: Importance.high, - priority: Priority.high, - ), - iOS: DarwinNotificationDetails(), - ); - - await _notifications.show( - DateTime.now().millisecondsSinceEpoch ~/ 1000, - title, - body, - notificationDetails, - ); - print('SupplementsLog: 📱 Instant notification sent'); - } - - // Debug function to test notifications - Future testNotification() async { - print('SupplementsLog: 📱 Testing notification system...'); - await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.'); - } - - // Debug function to schedule a test notification 1 minute from now - Future testScheduledNotification() async { - print('SupplementsLog: 📱 Testing scheduled notification...'); - final now = tz.TZDateTime.now(tz.local); - final testTime = now.add(const Duration(minutes: 1)); - - print('SupplementsLog: 📱 Scheduling test notification for: $testTime'); - - await _notifications.zonedSchedule( - 99999, // Special ID for test notifications - 'Test Scheduled Notification', - 'This notification was scheduled 1 minute ago at ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}', - testTime, - const NotificationDetails( - android: AndroidNotificationDetails( - 'test_notifications', - 'Test Notifications', - channelDescription: 'Test notifications for debugging', - importance: Importance.high, - priority: Priority.high, - ), - iOS: DarwinNotificationDetails(), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - ); - - print('SupplementsLog: 📱 Test notification scheduled successfully'); - } - - // Debug function to get all pending notifications - Future> getPendingNotifications() async { - return await _notifications.pendingNotificationRequests(); - } - - // Debug function to test notification actions - Future testNotificationWithActions() async { - print('SupplementsLog: 📱 Creating test notification with actions...'); - - await _notifications.show( - 88888, // Special test ID - 'Test Action Notification', - 'Tap Take or Snooze to test notification actions', - NotificationDetails( - android: AndroidNotificationDetails( - 'test_notifications', - 'Test Notifications', - channelDescription: 'Test notifications for debugging actions', - importance: Importance.high, - priority: Priority.high, - actions: [ - AndroidNotificationAction( - 'take_supplement', - 'Take', - icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'), - showsUserInterface: true, - ), - AndroidNotificationAction( - 'snooze_10', - 'Snooze 10min', - icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'), - showsUserInterface: true, - ), - ], - ), - iOS: const DarwinNotificationDetails(), - ), - payload: '999|Test Supplement|1.0|capsule', - ); - - print('SupplementsLog: 📱 Test notification with actions created'); - } - - // Debug function to test basic notification tap response - Future testBasicNotification() async { - print('SupplementsLog: 📱 Creating basic test notification...'); - - await _notifications.show( - 77777, // Special test ID for basic notification - 'Basic Test Notification', - 'Tap this notification to test basic callback', - NotificationDetails( - android: AndroidNotificationDetails( - 'test_notifications', - 'Test Notifications', - channelDescription: 'Test notifications for debugging', - importance: Importance.high, - priority: Priority.high, - ), - iOS: const DarwinNotificationDetails(), - ), - payload: 'basic_test', - ); - - print('SupplementsLog: 📱 Basic test notification created'); - } -} + All retry/snooze/database-tracking logic has been dropped to keep things simple. + This file is left empty to ensure any lingering references fail at compile time, + prompting migration to the new SimpleNotificationService. +*/ diff --git a/lib/services/nutrient_data_service.dart b/lib/services/nutrient_data_service.dart deleted file mode 100644 index 86290cd..0000000 --- a/lib/services/nutrient_data_service.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/services.dart'; - -import '../models/nutrient.dart'; - -class NutrientDataService { - static final NutrientDataService _instance = NutrientDataService._internal(); - - factory NutrientDataService() { - return _instance; - } - - NutrientDataService._internal(); - - List? _nutrients; - - Future> get nutrients async { - if (_nutrients != null) { - return _nutrients!; - } - await _loadNutrientData(); - return _nutrients!; - } - - Future _loadNutrientData() async { - try { - final String response = await rootBundle.loadString('assets/canada_health.json'); - final data = await json.decode(response); - final nutrientsData = data['nutrients'] as Map; - - _nutrients = nutrientsData.entries.map((entry) { - return Nutrient.fromJson(entry.key, entry.value); - }).toList(); - } catch (e) { - print('Error loading nutrient data: $e'); - _nutrients = []; - } - } -} diff --git a/lib/services/rda_service.dart b/lib/services/rda_service.dart deleted file mode 100644 index e9e6260..0000000 --- a/lib/services/rda_service.dart +++ /dev/null @@ -1,617 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import '../models/ingredient.dart'; -import '../models/nutrient.dart'; -import 'nutrient_data_service.dart'; - -/// Represents an RDA/AI match for a user and nutrient -class RdaResult { - final String nutrientKey; - final String unitLabel; // e.g., "mg/day", "µg/day (RAE)" - final String rdaType; // "RDA/AI" or "AI" - final double value; // RDA/AI value in the units of unitLabel - final double? valueMin; // Optional minimum recommended value - final double? valueMax; // Optional maximum recommended value - final double? ul; // Upper limit (if provided) in the same base units as unitLabel (life-stage) - final String matchedLifeStageLabel; // e.g., "19-30 y", "51-70 y" - final String? lifeStageDescription; // Optional description for the life stage (e.g., maintenance/loading) - final UpperLimit? nutrientUl; // Nutrient-level UL (object with unit/duration/note), if available - final String? note; // Optional dataset note (e.g., magnesium UL is supplemental only) - - const RdaResult({ - required this.nutrientKey, - required this.unitLabel, - required this.rdaType, - required this.value, - this.valueMin, - this.valueMax, - required this.ul, - required this.matchedLifeStageLabel, - this.lifeStageDescription, - this.nutrientUl, - this.note, - }); -} - -/// Aggregated daily overview by nutrient -class RdaAggregate { - final String nutrientKey; - final String unitLabel; // RDA unit label - final double rdaValue; // Midpoint of range when available - final double? rdaValueMin; // Optional minimum recommended value - final double? rdaValueMax; // Optional maximum recommended value - final double? ulValue; - final double totalAmountInRdaUnit; // Total intake converted to RDA units - final double percentOfRda; // 0..100+ (may exceed 100) - final double? percentOfUl; // 0..100+ (may exceed 100) - final String? matchedLifeStageLabel; // e.g., "19-30 y" - final String? matchedLifeStageDescription; // Optional description for the life stage - final String? rdaType; // e.g., "RDA/AI" or "AI" - final UpperLimit? nutrientUl; // Nutrient-level UL object (unit/duration/note) - final String? note; // Optional dataset note - - const RdaAggregate({ - required this.nutrientKey, - required this.unitLabel, - required this.rdaValue, - this.rdaValueMin, - this.rdaValueMax, - required this.ulValue, - required this.totalAmountInRdaUnit, - required this.percentOfRda, - required this.percentOfUl, - this.matchedLifeStageLabel, - this.matchedLifeStageDescription, - this.rdaType, - this.nutrientUl, - this.note, - }); -} - -/// Service for working with Health Canada DRIs (RDA/AI and UL) -/// - Maps app ingredient names to nutrient keys in canada_health.json -/// - Computes user-specific RDA/AI and UL values based on age and gender -/// - Converts units and calculates % of RDA/AI and % of UL -class RdaService { - RdaService._internal(); - - static final RdaService _instance = RdaService._internal(); - - factory RdaService() => _instance; - - final NutrientDataService _nutrientDataService = NutrientDataService(); - - // Cache nutrients by key: e.g., "vitamin_d" - Map? _nutrientsByKey; - - // Known alias mapping for common ingredient names to nutrient keys - // Keys must be lowercase for matching - static const Map _aliasToNutrientKey = { - // Vitamin C - 'vitamin c': 'vitamin_c', - 'ascorbic acid': 'vitamin_c', - - // Vitamin D - 'vitamin d': 'vitamin_d', - 'vitamin d3': 'vitamin_d', - 'cholecalciferol': 'vitamin_d', - 'vitamin d2': 'vitamin_d', // ergocalciferol - treat same RDA - - // Vitamin A (RAE) - 'vitamin a': 'vitamin_a', - 'retinol': 'vitamin_a', - 'beta-carotene': 'vitamin_a', - - // Vitamin E (alpha-tocopherol) - 'vitamin e': 'vitamin_e', - 'alpha tocopherol': 'vitamin_e', - 'alpha-tocopherol': 'vitamin_e', - - // Vitamin K (K1/K2 common mapping to total Vitamin K AI) - 'vitamin k': 'vitamin_k', - 'vitamin k1': 'vitamin_k', - 'phylloquinone': 'vitamin_k', - 'vitamin k2': 'vitamin_k', - 'menaquinone': 'vitamin_k', - - // B1 (Thiamine) - 'vitamin b1': 'vitamin_b1', - 'thiamine': 'vitamin_b1', - 'thiamin': 'vitamin_b1', - - // B2 (Riboflavin) - 'vitamin b2': 'vitamin_b2', - 'riboflavin': 'vitamin_b2', - - // Folate - 'folate': 'folate_dfe', - 'folic acid': 'folate_dfe', - 'folate (dfe)': 'folate_dfe', - 'dfe': 'folate_dfe', - }; - - // RDA result and aggregate types moved to top-level (Dart doesn't support nested classes) - - /// Get a user-specific RDA result for a given ingredient name. - /// - Resolves the ingredient to a nutrient key using aliases and simple heuristics. - /// - Computes the appropriate life-stage record based on age and gender. - /// - /// If the ingredient doesn't map to a known nutrient or no life stage matches, - /// returns null. - Future getUserRdaForIngredient( - String ingredientName, { - DateTime? dateOfBirth, - String? gender, // expected values similar to ['Male','Female','Other','Prefer not to say'] - }) async { - final key = await mapIngredientToNutrientKey(ingredientName); - if (key == null) return null; - return getUserRdaForNutrientKey( - key, - dateOfBirth: dateOfBirth, - gender: gender, - ); - } - - /// Get a user-specific RDA result for a known nutrient key - /// e.g., "vitamin_d", "vitamin_c". - Future getUserRdaForNutrientKey( - String nutrientKey, { - DateTime? dateOfBirth, - String? gender, - }) async { - final nutrient = await _getNutrientByKey(nutrientKey); - if (nutrient == null) return null; - - final _UserProfile profile = _UserProfile.from(dateOfBirth: dateOfBirth, gender: gender); - final LifeStage? stage = _matchLifeStageForProfile(nutrient.lifeStages, profile); - - if (stage == null) return null; - - return RdaResult( - nutrientKey: nutrientKey, - unitLabel: nutrient.unit, - rdaType: nutrient.rdaType, - value: stage.value, - valueMin: stage.valueMin, - valueMax: stage.valueMax, - ul: stage.ul, - matchedLifeStageLabel: stage.ageRange, - lifeStageDescription: stage.description, - nutrientUl: nutrient.ul, - note: nutrient.note, - ); - } - - /// Compute % of RDA and % of UL for a single ingredient dose. - /// - Resolves ingredient to nutrient key - /// - Converts the amount+unit to the RDA unit base for that nutrient - /// - Calculates percent of RDA and UL - /// - /// Returns null if the ingredient cannot be mapped or units cannot be converted. - Future computePercentForDose( - String ingredientName, - double amount, - String unit, { - DateTime? dateOfBirth, - String? gender, - }) async { - final rda = await getUserRdaForIngredient( - ingredientName, - dateOfBirth: dateOfBirth, - gender: gender, - ); - if (rda == null) return null; - - final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel); // "mg" or "ug" - final String normalizedInputUnit = _normalizeUnit(unit); - - final double? amountInRdaUnit = _convertAmountToTargetUnit( - ingredientName: ingredientName, - amount: amount, - fromUnit: normalizedInputUnit, - toUnit: rdaUnitSymbol, - ); - - if (amountInRdaUnit == null) return null; - - final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null) - ? ((rda.valueMin! + rda.valueMax!) / 2.0) - : rda.value; - final double percentOfRda = (amountInRdaUnit / rdaForCalc) * 100.0; - final double? percentOfUl = - rda.ul != null && rda.ul! > 0 ? (amountInRdaUnit / rda.ul!) * 100.0 : null; - - return RdaAggregate( - nutrientKey: rda.nutrientKey, - unitLabel: rda.unitLabel, - rdaValue: rdaForCalc, - rdaValueMin: rda.valueMin, - rdaValueMax: rda.valueMax, - ulValue: rda.ul, - totalAmountInRdaUnit: amountInRdaUnit, - percentOfRda: percentOfRda, - percentOfUl: percentOfUl, - matchedLifeStageLabel: rda.matchedLifeStageLabel, - matchedLifeStageDescription: rda.lifeStageDescription, - rdaType: rda.rdaType, - nutrientUl: rda.nutrientUl, - note: rda.note, - ); - } - - /// Aggregate multiple ingredients (e.g., full-day intake) into user-specific RDA overview. - /// - Sums all ingredients mapped to the same nutrient - /// - Converts units to the RDA base unit - /// - Returns map keyed by nutrientKey - Future> aggregateDailyIntake( - List ingredients, { - DateTime? dateOfBirth, - String? gender, - }) async { - final Map totalsByNutrient = {}; - final Map rdaByNutrient = {}; - - for (final ing in ingredients) { - final key = await mapIngredientToNutrientKey(ing.name); - if (key == null) continue; - - // Ensure RDA is loaded for the nutrient - rdaByNutrient[key] = rdaByNutrient[key] ?? - (await getUserRdaForNutrientKey(key, dateOfBirth: dateOfBirth, gender: gender))!; - - final rda = rdaByNutrient[key]; - if (rda == null) continue; // no match for this nutrient - - final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel); - final double? converted = _convertAmountToTargetUnit( - ingredientName: ing.name, - amount: ing.amount, - fromUnit: _normalizeUnit(ing.unit), - toUnit: rdaUnitSymbol, - ); - if (converted == null) continue; - - totalsByNutrient[key] = (totalsByNutrient[key] ?? 0.0) + converted; - } - - final Map result = {}; - for (final entry in totalsByNutrient.entries) { - final key = entry.key; - final total = entry.value; - final rda = rdaByNutrient[key]; - if (rda == null) continue; - final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null) - ? ((rda.valueMin! + rda.valueMax!) / 2.0) - : rda.value; - final double percentOfRda = (total / rdaForCalc) * 100.0; - final double? percentOfUl = rda.ul != null && rda.ul! > 0 ? (total / rda.ul!) * 100.0 : null; - - result[key] = RdaAggregate( - nutrientKey: key, - unitLabel: rda.unitLabel, - rdaValue: rdaForCalc, - rdaValueMin: rda.valueMin, - rdaValueMax: rda.valueMax, - ulValue: rda.ul, - totalAmountInRdaUnit: total, - percentOfRda: percentOfRda, - percentOfUl: percentOfUl, - matchedLifeStageLabel: rda.matchedLifeStageLabel, - matchedLifeStageDescription: rda.lifeStageDescription, - rdaType: rda.rdaType, - nutrientUl: rda.nutrientUl, - note: rda.note, - ); - } - - return result; - } - - /// Map an ingredient name (e.g., "Vitamin D3") to a nutrient key (e.g., "vitamin_d") used in canada_health.json - /// Returns null if no mapping is found. - Future mapIngredientToNutrientKey(String ingredientName) async { - await _ensureNutrientsLoaded(); - - final String cleaned = _normalizeIngredientName(ingredientName); - - // Direct alias mapping - final direct = _aliasToNutrientKey[cleaned]; - if (direct != null && _nutrientsByKey!.containsKey(direct)) return direct; - - // Heuristic contains-based mapping - if (cleaned.contains('vitamin d')) return _nutrientsByKey!.containsKey('vitamin_d') ? 'vitamin_d' : null; - if (cleaned.contains('vitamin c')) return _nutrientsByKey!.containsKey('vitamin_c') ? 'vitamin_c' : null; - if (cleaned.contains('vitamin a') || cleaned.contains('retinol') || cleaned.contains('beta carotene')) { - return _nutrientsByKey!.containsKey('vitamin_a') ? 'vitamin_a' : null; - } - if (cleaned.contains('vitamin e') || cleaned.contains('alpha tocopherol') || cleaned.contains('alpha-tocopherol')) { - return _nutrientsByKey!.containsKey('vitamin_e') ? 'vitamin_e' : null; - } - if (cleaned.contains('vitamin k') || cleaned.contains('phylloquinone') || cleaned.contains('menaquinone')) { - return _nutrientsByKey!.containsKey('vitamin_k') ? 'vitamin_k' : null; - } - if (cleaned.contains('b1') || cleaned.contains('thiamin') || cleaned.contains('thiamine')) { - return _nutrientsByKey!.containsKey('vitamin_b1') ? 'vitamin_b1' : null; - } - if (cleaned.contains('b2') || cleaned.contains('riboflavin')) { - return _nutrientsByKey!.containsKey('vitamin_b2') ? 'vitamin_b2' : null; - } - if (cleaned.contains('folate') || cleaned.contains('folic')) { - return _nutrientsByKey!.containsKey('folate_dfe') ? 'folate_dfe' : null; - } - if (cleaned.contains('vitamin b3') || cleaned.contains('niacin') || cleaned.contains('nicotinic acid') || cleaned.contains('niacinamide')) { - return _nutrientsByKey!.containsKey('vitamin_b3') ? 'vitamin_b3' : null; - } - if (cleaned.contains('vitamin b5') || cleaned.contains('pantothenic')) { - return _nutrientsByKey!.containsKey('vitamin_b5') ? 'vitamin_b5' : null; - } - if (cleaned.contains('vitamin b6') || cleaned.contains('pyridoxine')) { - return _nutrientsByKey!.containsKey('vitamin_b6') ? 'vitamin_b6' : null; - } - if (cleaned.contains('vitamin b12') || cleaned.contains('cobalamin') || cleaned.contains('cyanocobalamin') || cleaned.contains('methylcobalamin')) { - return _nutrientsByKey!.containsKey('vitamin_b12') ? 'vitamin_b12' : null; - } - if (cleaned.contains('magnesium')) { - return _nutrientsByKey!.containsKey('magnesium') ? 'magnesium' : null; - } - if (cleaned.contains('zinc') || cleaned == 'zn') { - return _nutrientsByKey!.containsKey('zinc') ? 'zinc' : null; - } - if (cleaned.contains('iron') || cleaned.contains('ferrous') || cleaned.contains('ferric')) { - return _nutrientsByKey!.containsKey('iron') ? 'iron' : null; - } - if (cleaned.contains('creatine') || cleaned.contains('creapure') || cleaned.contains('creatine monohydrate')) { - return _nutrientsByKey!.containsKey('creatine') ? 'creatine' : null; - } - - return null; - } - - // ----------------------- - // Internal helpers - // ----------------------- - - Future _ensureNutrientsLoaded() async { - if (_nutrientsByKey != null) return; - final list = await _nutrientDataService.nutrients; - _nutrientsByKey = {for (final n in list) n.name: n}; - } - - Future _getNutrientByKey(String key) async { - await _ensureNutrientsLoaded(); - return _nutrientsByKey![key]; - } - - // Normalize units (user input and stored ingredients) - // Supported return values: "mg", "ug", "g", "iu", others returned as lowercased original (e.g., "ml") - String _normalizeUnit(String unit) { - final u = unit.trim().toLowerCase(); - if (u == 'mg') return 'mg'; - if (u == 'g' || u == 'gram' || u == 'grams') return 'g'; - if (u == 'µg' || u == 'μg' || u == 'mcg' || u == 'ug' || u == 'microgram' || u == 'micrograms') return 'ug'; - if (u == 'iu') return 'iu'; - return u; // e.g., "ml", "drops" etc. (unhandled for RDA calc) - } - - // Extract the base unit symbol ("mg" or "ug") from the dataset unit label (e.g., "µg/day (RAE)") - String _unitSymbolFromLabel(String label) { - final lower = label.toLowerCase(); - if (lower.startsWith('mg')) return 'mg'; - if (lower.startsWith('g')) return 'g'; - if (lower.startsWith('µg') || lower.startsWith('μg') || lower.startsWith('mcg')) return 'ug'; - // Fallback: assume microgram if unknown - return 'ug'; - } - - // Convert an amount from one unit to another. - // Supported: - // - mg <-> ug <-> g - // - IU->ug for Vitamin D only (1 µg = 40 IU) - // Returns null if conversion cannot be performed. - double? _convertAmountToTargetUnit({ - required String ingredientName, - required double amount, - required String fromUnit, - required String toUnit, - }) { - if (amount.isNaN || amount.isInfinite) return null; - - // Handle IU conversions only for Vitamin D - final name = _normalizeIngredientName(ingredientName); - final isVitaminD = name.contains('vitamin d') || name.contains('cholecalciferol') || name.contains('ergocalciferol'); - - // If fromUnit equals toUnit and it's one of our supported numeric units - if ((fromUnit == toUnit) && (fromUnit == 'mg' || fromUnit == 'ug' || fromUnit == 'g')) { - return amount; - } - - // IU -> ug for Vitamin D - if (fromUnit == 'iu' && isVitaminD) { - // 1 µg = 40 IU => ug = IU / 40 - final ug = amount / 40.0; - if (toUnit == 'ug') return ug; - if (toUnit == 'mg') return ug / 1000.0; - if (toUnit == 'g') return ug / 1e6; - return null; - } - - // Mass conversions - double? inUg; - if (fromUnit == 'ug') { - inUg = amount; - } else if (fromUnit == 'mg') { - inUg = amount * 1000.0; - } else if (fromUnit == 'g') { - inUg = amount * 1e6; - } else { - // Unsupported unit (e.g., ml, drops) - return null; - } - - if (toUnit == 'ug') return inUg; - if (toUnit == 'mg') return inUg / 1000.0; - if (toUnit == 'g') return inUg / 1e6; - - return null; - } - - // Normalize an ingredient name for alias matching - String _normalizeIngredientName(String name) { - final lower = name.trim().toLowerCase(); - // Replace common punctuation with spaces, then condense - final replaced = lower - .replaceAll(RegExp(r'[\(\)\[\]\{\},;:+/_-]+'), ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - return replaced; - } - - // ----------------------- - // Life stage matching - // ----------------------- - - LifeStage? _matchLifeStageForProfile(List stages, _UserProfile profile) { - // Exclude pregnancy/lactation when we don't track that state yet - final filtered = stages.where((s) { - final l = s.ageRange.toLowerCase(); - return !(l.contains('pregnancy') || l.contains('lactation')); - }).toList(); - - // Try in order: - // 1) Exact age match + exact sex - final exactSexMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && _matchesSex(s.sex, profile)).toList(); - if (exactSexMatch.isNotEmpty) return exactSexMatch.first; - - // 2) Age match + sex == 'both' - final bothMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && s.sex.toLowerCase() == 'both').toList(); - if (bothMatch.isNotEmpty) return bothMatch.first; - - // 3) Age match ignoring sex (fallback) - final ageOnly = filtered.where((s) => _matchesAge(s.ageRange, profile)).toList(); - if (ageOnly.isNotEmpty) return ageOnly.first; - - // 4) If nothing matches, try 'adult-like' fallback: pick a reasonable adult range - final adultFallback = filtered.where((s) => s.ageRange.contains('19-30') || s.ageRange.contains('31-50')).toList(); - if (adultFallback.isNotEmpty) return adultFallback.first; - - // 5) Any entry as last resort - return filtered.isNotEmpty ? filtered.first : null; - } - - bool _matchesSex(String stageSex, _UserProfile profile) { - final s = stageSex.toLowerCase(); - if (s == 'both') return true; - if (profile.isInfant && s == 'infant') return true; - if (profile.sex == _Sex.male && s == 'male') return true; - if (profile.sex == _Sex.female && s == 'female') return true; - - // For 'Other' or 'Prefer not to say', accept 'both' - if (profile.sex == _Sex.unknown && s == 'both') return true; - - return false; - } - - bool _matchesAge(String ageRange, _UserProfile profile) { - final ar = ageRange.toLowerCase().trim(); - - // Common shorthand: "adult" (assume >= 18 years) - if (ar == 'adult' || ar == 'adults') { - return profile.ageYears >= 18; - } - - // Months range: e.g., "0-6 mo" or "7-12 mo" - final moMatch = RegExp(r'^(\d+)\s*-\s*(\d+)\s*mo$').firstMatch(ar); - if (moMatch != null) { - if (!profile.isInfant) return false; - final minMo = int.parse(moMatch.group(1)!); - final maxMo = int.parse(moMatch.group(2)!); - return profile.ageMonths >= minMo && profile.ageMonths <= maxMo; - } - - // Years range: e.g., "1-3 y", "4-8 y", "9-13 y", "14-18 y", "19-30 y", "31-50 y", "51-70 y" - final yearRange = RegExp(r'^(\d+)\s*-\s*(\d+)\s*y$').firstMatch(ar); - if (yearRange != null) { - final minY = int.parse(yearRange.group(1)!); - final maxY = int.parse(yearRange.group(2)!); - return profile.ageYears >= minY && profile.ageYears <= maxY; - } - - // Greater than: e.g., ">70 y" - final gtYear = RegExp(r'^>\s*(\d+)\s*y$').firstMatch(ar); - if (gtYear != null) { - final minExclusive = int.parse(gtYear.group(1)!); - return profile.ageYears > minExclusive; - } - - // "infant" buckets handled via months range above - // Any unknown format: do a best-effort fallback - if (profile.isInfant) { - // If the stage mentions "infant", accept - if (ar.contains('infant')) return true; - // Else, if it starts at 0-? y, let's accept if upper bound >= 0 (rare) - if (ar.contains('0-') && ar.contains('y')) return true; - return false; - } - - // If we are adult and stage is one of the adult ranges not following the patterns, - // just return false to avoid false positives. - return false; - } -} - -/// Internal representation of user profile for matching -class _UserProfile { - final int ageYears; // rounded down, e.g., 29 - final int ageMonths; // total months (for infants). If >= 12, consider non-infant. - final _Sex sex; - bool get isInfant => ageYears < 1; - - _UserProfile({ - required this.ageYears, - required this.ageMonths, - required this.sex, - }); - - factory _UserProfile.from({DateTime? dateOfBirth, String? gender}) { - final now = DateTime.now(); - - int years; - int monthsTotal; - if (dateOfBirth == null) { - // Default to adult 30 years old when unknown - years = 30; - monthsTotal = 30 * 12; - } else { - years = now.year - dateOfBirth.year; - final beforeBirthday = (now.month < dateOfBirth.month) || - (now.month == dateOfBirth.month && now.day < dateOfBirth.day); - if (beforeBirthday) years = max(0, years - 1); - - // Calculate total months difference - int months = (now.year - dateOfBirth.year) * 12 + (now.month - dateOfBirth.month); - if (now.day < dateOfBirth.day) { - months = max(0, months - 1); - } - monthsTotal = max(0, months); - } - - final s = _parseSex(gender); - - return _UserProfile( - ageYears: years, - ageMonths: monthsTotal, - sex: s, - ); - } -} - -enum _Sex { male, female, unknown } - -_Sex _parseSex(String? gender) { - if (gender == null) return _Sex.unknown; - final g = gender.trim().toLowerCase(); - if (g == 'male') return _Sex.male; - if (g == 'female') return _Sex.female; - return _Sex.unknown; // 'Other', 'Prefer not to say' -> unknown -} diff --git a/lib/services/simple_notification_service.dart b/lib/services/simple_notification_service.dart new file mode 100644 index 0000000..da7b2f0 --- /dev/null +++ b/lib/services/simple_notification_service.dart @@ -0,0 +1,359 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:supplements/logging.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; +import 'dart:convert'; +import 'package:supplements/services/notification_router.dart'; + +import '../models/supplement.dart'; + +/// A minimal notification scheduler focused purely on: +/// - Initialization +/// - Permission requests +/// - Scheduling daily notifications for supplements +/// - Canceling scheduled notifications +/// +/// No retries, no snooze, no database logic. +class SimpleNotificationService { + SimpleNotificationService._internal(); + static final SimpleNotificationService instance = + SimpleNotificationService._internal(); + + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + + bool _initialized = false; + + // Channel IDs + static const String _channelDailyId = 'supplement_reminders'; + static const String _channelDailyName = 'Supplement Reminders'; + static const String _channelDailyDescription = 'Daily supplement intake reminders'; + + /// Initialize timezone data and the notifications plugin. + /// + /// Note: This does not request runtime permissions. Call [requestPermissions] + /// to prompt the user for notification permissions. + Future initialize() async { + if (_initialized) return; + + // Initialize timezone database and set a sane default. + // If you prefer, replace 'Europe/Amsterdam' with your preferred default, + // or integrate a platform timezone resolver. + tzdata.initializeTimeZones(); + tz.setLocalLocation(tz.getLocation('Europe/Amsterdam')); + + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + final iosSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + notificationCategories: [ + DarwinNotificationCategory( + 'single', + actions: [ + DarwinNotificationAction.plain('take_single', 'Take'), + ], + ), + DarwinNotificationCategory( + 'group', + actions: [ + DarwinNotificationAction.plain('take_group', 'Take All'), + ], + ), + ], + ); + const linuxSettings = LinuxInitializationSettings( + defaultActionName: 'Open notification', + ); + + final initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + linux: linuxSettings, + ); + + await _plugin.initialize( + initSettings, + onDidReceiveNotificationResponse: (response) { + NotificationRouter.instance.handleNotificationResponse(response); + }, + ); + + _initialized = true; + } + + /// Request runtime notification permissions. + /// + /// On Android 13+, this will prompt for POST_NOTIFICATIONS. On older Android, + /// this is a no-op. On iOS, it requests alert/badge/sound. + Future requestPermissions() async { + // Ensure the plugin is ready before requesting permissions. + if (!_initialized) { + await initialize(); + } + + bool granted = true; + + final androidPlugin = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + if (androidPlugin != null) { + final ok = await androidPlugin.requestNotificationsPermission(); + granted = granted && (ok == true); + } + + final iosPlugin = _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>(); + if (iosPlugin != null) { + final ok = await iosPlugin.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + granted = granted && (ok == true); + } + + return granted; + } + + /// Schedule grouped daily reminders for a list of supplements. + /// + /// - Groups supplements by HH:mm and schedules one notification per time slot. + /// - Uses daily recurrence via matchDateTimeComponents: DateTimeComponents.time. + /// - Keeps iOS pending notifications well below the 64 limit. + /// + /// IDs: + /// - Group ID per time slot: 40000 + hour*60 + minute. + /// - Stable and predictable for cancel/update operations. + Future scheduleDailyGroupedReminders(List supplements) async { + if (!_initialized) { + await initialize(); + } + + printLog('🛠 scheduleDailyGroupedReminders -> ${supplements.length} supplements'); + + // Clear everything first to avoid duplicates or stale schedules + await cancelAll(); + printLog('🧹 Cleared all existing notifications before scheduling groups'); + + // Build groups: HH:mm -> list + final Map> groups = {}; + for (final s in supplements.where((s) => s.isActive && s.reminderTimes.isNotEmpty && s.id != null)) { + for (final timeStr in s.reminderTimes) { + final parts = timeStr.split(':'); + if (parts.length != 2) continue; + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || minute == null) continue; + + final key = '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; + groups.putIfAbsent(key, () => []).add(s); + } + } + + printLog('⏱ Found ${groups.length} time group(s): ${groups.keys.toList()}'); + + if (groups.isEmpty) { + printLog('⚠️ No groups to schedule (no active supplements with reminder times)'); + return; + } + + // Schedule one notification per time group + for (final entry in groups.entries) { + final timeKey = entry.key; // HH:mm + final items = entry.value; + + final parts = timeKey.split(':'); + final hour = int.parse(parts[0]); + final minute = int.parse(parts[1]); + + final when = _nextInstanceOfTime(hour, minute); + final id = 40000 + (hour * 60) + minute; + + final count = items.length; + final title = count == 1 + ? 'Time for ${items.first.name}' + : 'Time for $count supplements'; + + // Build body that lists each supplement concisely + final bodyLines = items.map((s) { + final units = s.numberOfUnits; + final unitType = s.unitType; + final perUnit = s.ingredientsPerUnit; + return '${s.name} — $units $unitType (${perUnit})'; + }).toList(); + final body = bodyLines.join('\n'); + + printLog('📅 Scheduling group $timeKey (count=$count) id=$id'); + printLog('🕒 Now=${tz.TZDateTime.now(tz.local)} | When=$when'); + + // Use BigTextStyle/InboxStyle for Android to show multiple lines + final bool isSingle = count == 1; + final String payloadStr = isSingle + ? jsonEncode({"type": "single", "id": items.first.id}) + : jsonEncode({"type": "group", "time": timeKey}); + + final androidDetails = AndroidNotificationDetails( + _channelDailyId, + _channelDailyName, + channelDescription: _channelDailyDescription, + importance: Importance.high, + priority: Priority.high, + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + htmlFormatContentTitle: false, + ), + actions: [ + if (isSingle) + AndroidNotificationAction( + 'take_single', + 'Take', + showsUserInterface: true, + cancelNotification: true, + ) + else + AndroidNotificationAction( + 'take_group', + 'Take All', + showsUserInterface: true, + cancelNotification: true, + ), + ], + ); + + final iosDetails = DarwinNotificationDetails( + categoryIdentifier: isSingle ? 'single' : 'group', + ); + + await _plugin.zonedSchedule( + id, + title, + isSingle ? body : 'Tap to see details', + when, + NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ), + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + matchDateTimeComponents: DateTimeComponents.time, + payload: payloadStr, + ); + + printLog('✅ Scheduled group $timeKey with ID $id'); + } + + // Log what the system reports as pending + try { + final pending = await _plugin.pendingNotificationRequests(); + printLog('📋 Pending notifications after scheduling: ${pending.length}'); + for (final p in pending) { + printLog(' - ID=${p.id}, Title=${p.title}, BodyLen=${p.body?.length ?? 0}'); + } + } catch (e) { + printLog('⚠️ Could not fetch pending notifications: $e'); + } + } + + /// Convenience to schedule grouped reminders for today and tomorrow. + /// + /// For iOS’s 64 limit, we stick to one day for recurring (matchDateTimeComponents) + /// which already repeats every day without needing to schedule future dates. + /// If you want an extra safety net, you could schedule tomorrow’s one-offs, + /// but with daily components this is unnecessary and risks hitting iOS limits. + Future scheduleDailyGroupedRemindersSafe(List supplements) async { + // For now, just schedule today’s recurring groups. + await scheduleDailyGroupedReminders(supplements); + } + + /// Cancel all scheduled reminders for a given [supplementId]. + /// + /// We assume up to 100 slots per supplement (00-99). This is simple and safe. + Future cancelSupplementReminders(int supplementId) async { + if (!_initialized) { + await initialize(); + } + + for (int i = 0; i < 100; i++) { + await _plugin.cancel(supplementId * 100 + i); + } + } + + /// Cancel all scheduled notifications. + Future cancelAll() async { + if (!_initialized) { + await initialize(); + } + await _plugin.cancelAll(); + } + + /// Show an immediate notification. Useful for quick diagnostics. + Future showInstant({ + required String title, + required String body, + String? payload, + }) async { + if (!_initialized) { + await initialize(); + } + + await _plugin.show( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + title, + body, + const NotificationDetails( + android: AndroidNotificationDetails( + 'instant_notifications', + 'Instant Notifications', + channelDescription: 'One-off or immediate notifications', + importance: Importance.high, + priority: Priority.high, + ), + iOS: DarwinNotificationDetails(), + ), + payload: payload, + ); + } + + Future getLaunchDetails() async { + if (!_initialized) { + await initialize(); + } + try { + final details = await _plugin.getNotificationAppLaunchDetails(); + return details; + } catch (e) { + printLog('⚠️ getLaunchDetails error: $e'); + return null; + } + } + + /// Helper to compute the next instance of [hour]:[minute] in the local tz. + tz.TZDateTime _nextInstanceOfTime(int hour, int minute) { + final now = tz.TZDateTime.now(tz.local); + var scheduled = + tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute); + + if (scheduled.isBefore(now)) { + scheduled = scheduled.add(const Duration(days: 1)); + printLog('⏭ Scheduling for tomorrow at ${scheduled.toString()} (${scheduled.timeZoneName})'); + } else { + printLog('⏲ Scheduling for today at ${scheduled.toString()} (${scheduled.timeZoneName})'); + } + return scheduled; + } + + /// Debug helper to fetch and log all pending notifications. + Future> getPendingNotifications() async { + if (!_initialized) { + await initialize(); + } + final list = await _plugin.pendingNotificationRequests(); + printLog('🧾 getPendingNotifications -> ${list.length} pending'); + for (final p in list) { + printLog(' • ID=${p.id}, Title=${p.title}, Payload=${p.payload}'); + } + return list; + } +} diff --git a/lib/widgets/dialogs/bulk_take_dialog.dart b/lib/widgets/dialogs/bulk_take_dialog.dart new file mode 100644 index 0000000..a1fbfd0 --- /dev/null +++ b/lib/widgets/dialogs/bulk_take_dialog.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/supplement.dart'; +import '../../providers/supplement_provider.dart'; + +/// Shows a bulk "Take supplements" dialog for a list of supplements. +/// - No time selection (records as now) +/// - Allows editing units per supplement +/// - Optional shared notes (applies to all) +Future showBulkTakeDialog( + BuildContext context, + List supplements, +) async { + if (supplements.isEmpty) { + return; + } + + // Controllers for each supplement's units + final Map unitControllers = { + for (final s in supplements.where((s) => s.id != null)) + s.id!: TextEditingController(text: s.numberOfUnits.toString()), + }; + + final notesController = TextEditingController(); + + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Take ${supplements.length} supplements'), + content: SizedBox( + width: double.maxFinite, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // List of supplements with editable units + // Use a scroll view with explicit max height to avoid intrinsic dimension issues. + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: SingleChildScrollView( + child: Column( + children: [ + for (int index = 0; index < supplements.length; index++) ...[ + () { + final s = supplements[index]; + final controller = unitControllers[s.id] ?? + TextEditingController(text: s.numberOfUnits.toString()); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name and per-unit info + Row( + children: [ + Expanded( + child: Text( + s.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + s.unitType, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 8), + // Units editor + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Units', + border: const OutlineInputBorder(), + suffixText: s.unitType, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // Dosage line + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Per unit:', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + s.ingredientsDisplay, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ), + ); + }(), + if (index != supplements.length - 1) const SizedBox(height: 8), + ], + ], + ), + ), + ), + const SizedBox(height: 12), + // Shared notes + TextField( + controller: notesController, + decoration: const InputDecoration( + labelText: 'Notes for all (optional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ], + ), + )), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final provider = context.read(); + int recorded = 0; + + for (final s in supplements) { + if (s.id == null) continue; + final controller = unitControllers[s.id]!; + final units = double.tryParse(controller.text) ?? s.numberOfUnits.toDouble(); + + // totalDosageTaken stays 0.0 for now (ingredients-based tracking later) + provider.recordIntake( + s.id!, + 0.0, + unitsTaken: units, + notes: notesController.text.isNotEmpty ? notesController.text : null, + takenAt: null, // "now" + ); + recorded++; + } + + Navigator.of(context).pop(); + if (recorded > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Recorded $recorded supplement${recorded == 1 ? '' : 's'}'), + backgroundColor: Colors.green, + ), + ); + } + }, + child: const Text('Record All'), + ), + ], + ); + }, + ); + + // Dispose controllers + for (final c in unitControllers.values) { + c.dispose(); + } + notesController.dispose(); +} diff --git a/lib/widgets/dialogs/take_supplement_dialog.dart b/lib/widgets/dialogs/take_supplement_dialog.dart new file mode 100644 index 0000000..fa92ce6 --- /dev/null +++ b/lib/widgets/dialogs/take_supplement_dialog.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/supplement.dart'; +import '../../providers/supplement_provider.dart'; + +/// Shows the "Take supplement" dialog. +/// - If [hideTime] is true, the time selection UI is hidden and intake is recorded as "now". +Future showTakeSupplementDialog( + BuildContext context, + Supplement supplement, { + bool hideTime = false, +}) async { + final unitsController = TextEditingController(text: supplement.numberOfUnits.toString()); + final notesController = TextEditingController(); + DateTime selectedDateTime = DateTime.now(); + bool useCustomTime = false; + + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: Text('Take ${supplement.name}'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: unitsController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: 'Number of ${supplement.unitType}', + border: const OutlineInputBorder(), + suffixText: supplement.unitType, + ), + onChanged: (value) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total dosage:', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + Text( + supplement.ingredientsDisplay, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + if (!hideTime) ...[ + // Time selection section + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.access_time, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + 'When did you take it?', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: RadioListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Just now', style: TextStyle(fontSize: 12)), + value: false, + groupValue: useCustomTime, + onChanged: (value) => setState(() => useCustomTime = value!), + ), + ), + Expanded( + child: RadioListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Custom time', style: TextStyle(fontSize: 12)), + value: true, + groupValue: useCustomTime, + onChanged: (value) => setState(() => useCustomTime = value!), + ), + ), + ], + ), + if (useCustomTime) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + child: Column( + children: [ + // Date picker + Row( + children: [ + Icon( + Icons.calendar_today, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Date: ${selectedDateTime.day}/${selectedDateTime.month}/${selectedDateTime.year}', + style: const TextStyle(fontSize: 12), + ), + ), + TextButton( + onPressed: () async { + final date = await showDatePicker( + context: context, + initialDate: selectedDateTime, + firstDate: DateTime.now().subtract(const Duration(days: 7)), + lastDate: DateTime.now(), + ); + if (date != null) { + setState(() { + selectedDateTime = DateTime( + date.year, + date.month, + date.day, + selectedDateTime.hour, + selectedDateTime.minute, + ); + }); + } + }, + child: const Text('Change', style: TextStyle(fontSize: 10)), + ), + ], + ), + const SizedBox(height: 4), + // Time picker + Row( + children: [ + Icon( + Icons.access_time, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Time: ${selectedDateTime.hour.toString().padLeft(2, '0')}:${selectedDateTime.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 12), + ), + ), + TextButton( + onPressed: () async { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(selectedDateTime), + ); + if (time != null) { + setState(() { + selectedDateTime = DateTime( + selectedDateTime.year, + selectedDateTime.month, + selectedDateTime.day, + time.hour, + time.minute, + ); + }); + } + }, + child: const Text('Change', style: TextStyle(fontSize: 10)), + ), + ], + ), + ], + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + ], + + TextField( + controller: notesController, + decoration: const InputDecoration( + labelText: 'Notes (optional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble(); + // For now, we'll record 0 as total dosage since we're transitioning to ingredients + // This will be properly implemented when we add the full ingredient tracking + final totalDosageTaken = 0.0; + context.read().recordIntake( + supplement.id!, + totalDosageTaken, + unitsTaken: unitsTaken, + notes: notesController.text.isNotEmpty ? notesController.text : null, + takenAt: hideTime + ? null + : (useCustomTime ? selectedDateTime : null), + ); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${supplement.name} recorded!'), + backgroundColor: Colors.green, + ), + ); + }, + child: const Text('Record'), + ), + ], + ); + }, + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index 29f1fb6..1739804 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + alarm: + dependency: "direct main" + description: + name: alarm + sha256: "1eef91f0b803a2370137e0dada9c7c24cc31edf4f1c30b06442dcf486cc192e0" + url: "https://pub.dev" + source: hosted + version: "5.1.4" args: dependency: transitive description: @@ -113,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -150,6 +166,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_fgbg: + dependency: transitive + description: + name: flutter_fgbg + sha256: eb6da9b2047372566a6e17b505975fe5bace94af01f6fc825c4b6f81baa6c447 + url: "https://pub.dev" + source: hosted + version: "0.7.1" flutter_lints: dependency: "direct dev" description: @@ -273,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -305,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -441,6 +481,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5cdbf72..755328d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: uuid: ^4.5.1 crypto: ^3.0.6 url_launcher: ^6.3.2 + alarm: ^5.1.4 dev_dependencies: flutter_test: @@ -53,6 +54,3 @@ dependency_overrides: flutter: uses-material-design: true - - assets: - - assets/canada_health.json