Compare commits

...

9 Commits

Author SHA1 Message Date
alvocool
1b5d105400 Show real photos for people instead of scene art
Your avatar is your photo, picked from the device by tapping it on Home or
from a new Profile photo row in Settings, shrunk to 256 px and kept on the
phone (there is no upload endpoint yet). Group members show the photo the
server sends in a new `photo` field, everywhere avatars appear. Without a
photo, an initial on a neutral disc. Mock mode bundles free Unsplash
portraits for the sample people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 08:22:41 +03:00
alvocool
72786701a9 Repaint the scene art in the new palette
Workout scenes are warm orange hills under a peach sky, meditation slate
hills with a soft orange sun, must-do sand to charcoal. Goal and group
scenes keep deriving from their colour, with a warmer sky and sun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 08:16:03 +03:00
alvocool
99225c2ee2 Carry the new palette into every remaining colour
The group chat bar drew white text on the old header token, now light;
it sits on near-black instead. Status, class, routine-kind, goal and chart
colours move from greens and navies to the reference scheme: orange for
met and held, near-black for fine and neutral, deeper orange then red as
standing worsens. Home's ring and kept-day leaves and Progress's bars use
the full accent so they read on grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 08:07:31 +03:00
alvocool
4cd3f6c038 Add Coach, take the reference's colours and copy its nav and motion
Coach is the centre tab: questions about routines, slips, streak, today,
active time and debt, answered on the device from the user's own data,
with a target bar chart and buttons that start routines or open the right
screen. The palette moves to neutral grey, near-black and one orange
accent, and Home's header becomes a light panel. The nav is now
overlapping frosted circles whose active disc pops forward, and tabs fade
and rise in on switch. Goals leaves the nav for Coach and stays one tap
away in Home's category row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:22:35 +03:00
alvocool
d3431ca5de Switch to Inter, round the corners softly and make the nav switch tabs
Inter replaces General Sans (Inter Display for titles and big numbers).
Corners come back from before the squaring commit, scaled to a soft set
(16 cards, 14 buttons, 12 rows, 8 chips) and applied to the screens built
since. The nav is now a cluster of round frosted buttons that switches
Today, Routines, Goals, Groups and Progress in place inside Home: no
pushed pages, no back arrows, and Android back returns to Today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-09 07:08:46 +03:00
alvocool
add94f621c Add Progress over time and a targets and reminders setup
Progress gives a Day/Week/Month view of routines held against a weekly
target, compared with the same point of the previous period, with active
time and the daily streak. Targets & reminders sets routines and minutes
per week and schedules a heads-up before each routine. The runner's summary
now shows today's held count and streak. Finished runs are mirrored to a
device run log that feeds both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-08 20:17:58 +03:00
alvocool
4b71257429 Add groups with shared goals, objectives and chat; drop the water tracker
Groups let people keep each other honest: create one or join with a
six-letter invite code. Each group has shared goals split into measurable
objectives (km, sessions, words) that every member logs toward, a
leaderboard of kept rate and streak with nudges, and a group chat where
check-ins and nudges appear as cards beside the talk. The open chat polls
for new messages.

Reached from a new Groups tab (Training moves into the day rail), a Home
"Your groups" rail that opens the chat, and a Group tile in the add sheet.
Mock mode seeds two groups, a joinable one and members who reply.

The water tracker is removed; it did not fit the app. Bottom sheets are
now square app-wide via the theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-03 11:46:41 +03:00
alvocool
4548e1f164 Rebuild Home around a forest header with streak, score and water
Home now opens on a deep green header: greeting, a Today / Weekly toggle,
a seven-day streak with a leaf on every kept day, minutes held with a +
ring, plan thumbnails, the report-card letter as the score, and one bar per
commitment class (weekly view swaps in per-day bars and class rates). The
+ opens an "Add to today" sheet over the blurred day. A water tracker sits
below the header, kept on the device. The nav marks the selected tab as a
filled block and drops the side action button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 20:58:41 +03:00
alvocool
9c858b8146 Square every corner, enlarge the main surfaces and add a to-do list
Rounded rectangles and round controls are now square across the app; only
status dots, glows, the sun, rings and the breathing circle stay round.
Cards, rows, thumbnails, featured cards, category tiles, the standing card
and the nav are larger. Home gets a quick To-do list kept on the device
(TodoItem in InternalMemory), seeded with sample items in mock mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
2026-11-02 14:50:31 +03:00
132 changed files with 9059 additions and 700 deletions

View File

@@ -32,7 +32,7 @@ informatics/ DataManager · AppDataManager
memory/ ConnectInternalMemory · InternalMemory memory/ ConnectInternalMemory · InternalMemory
configs/ Navigator · Env · NotificationServiceConfig configs/ Navigator · Env · NotificationServiceConfig
designs/ Component · Shell · Responsive · buttons/ input/ text/ designs/ Component · Shell · Responsive · buttons/ input/ text/
see/<feature>/ screens, 4 files each see/<feature>/ screens, 4 files each (group/ = groups, shared goals, chat)
utils/ Colors · CommonUtils · engines · validators utils/ Colors · CommonUtils · engines · validators
``` ```
@@ -109,9 +109,10 @@ status field. That's what makes honest history and excuse analysis possible.
## 6. Design language ## 6. Design language
See `design.md`. In short: sage-to-cream ambient backdrop, frosted glass See `design.md`. In short: neutral light-grey backdrop with one orange accent, frosted glass
controls, painted `SceneArt` instead of photos, image-led cards, two-tone controls, painted `SceneArt` instead of photos, image-led cards, two-tone
greetings, night pills with a lime spark. greetings, night buttons with a lime spark, softly rounded corners (never
pill-soft) and a floating round-button nav that switches tabs in place.
- Screens are built from `Sheet` (`designs/Shell.dart`); Home, Routines and - Screens are built from `Sheet` (`designs/Shell.dart`); Home, Routines and
the routine player compose `AmbientBackground` + glass directly. the routine player compose `AmbientBackground` + glass directly.
@@ -121,7 +122,7 @@ greetings, night pills with a lime spark.
- Standing owns a colour used consistently everywhere (chrome, chip, meter). - Standing owns a colour used consistently everywhere (chrome, chip, meter).
- One primary button per screen; everything else outlined or text. - One primary button per screen; everything else outlined or text.
Font: **General Sans** (Fontshare, FFL — see `fonts/LICENSE-GeneralSans.txt`), Font: **Inter** (SIL OFL — see `fonts/LICENSE-Inter.txt`; Light is Inter Display),
wrapped as `TextType.{Bold,Medium,Regular,Light}`. wrapped as `TextType.{Bold,Medium,Regular,Light}`.
--- ---

View File

@@ -0,0 +1,13 @@
Sample portraits for mock mode (person_*.jpg), from Unsplash under the
Unsplash License (free to use, no attribution required). Source photo ids:
person_alex.jpg photo-1507003211169-0a1dd7228f2d
person_sam.jpg photo-1500648767791-00dcc994a43e
person_priya.jpg photo-1494790108377-be9c29b29330
person_jordan.jpg photo-1472099645785-5658abf4ff4e
person_mei.jpg photo-1544005313-94ddf0286df2
person_tom.jpg photo-1506794778202-cad84cf45f1d
person_ade.jpg photo-1531123897727-8f129e1688ce
person_lena.jpg photo-1438761681033-6461ffad8d80
They stand in for real users' photos in the preview only.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -4,7 +4,7 @@ Reference: an airy fitness-app look. Sage mist drifting into cream behind
everything (`AmbientBackground`), frosted glass for controls and floating everything (`AmbientBackground`), frosted glass for controls and floating
chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second chrome (`GlassSurface`, `glassCircle`), ink type with a soft grey second
tone, painted landscapes instead of photography (`SceneArt`), a floating tone, painted landscapes instead of photography (`SceneArt`), a floating
glass tab bar with a dark round action button (`FloatingNav`), and one lime glass tab bar with a dark square action button (`FloatingNav`), and one lime
spark on night-green for the primary control. spark on night-green for the primary control.
## Building blocks (`designs/`) ## Building blocks (`designs/`)
@@ -12,16 +12,16 @@ spark on night-green for the primary control.
| Piece | Use | | Piece | Use |
|---|---| |---|---|
| `AmbientBackground` | The backdrop for every light screen. | | `AmbientBackground` | The backdrop for every light screen. |
| `GlassSurface` / `glassCircle` | Frosted panels and round controls; `onDark` over a scene or tinted stage. | | `GlassSurface` / `glassCircle` | Frosted panels and square controls; `onDark` over a scene or tinted stage. |
| `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. | | `SceneArt` | Painted sky, sun and ridges with an optional figure glyph. Palettes come from `scenePalette(kind)`, `goalScene(accent)`, `classScene(class)`; seed with `sceneSeed(title)` so an item always wears the same picture. |
| `DotDigits` | The dotted clock in the routine player. | | `DotDigits` | The dotted clock in the routine player. |
| `FloatingNav` | Home's tab bar and action button. | | `FloatingNav` | Home's tab bar; the selected tab is a filled night block. The side action button is optional. |
| `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. | | `GroundedMark` | The logo, drawn — a lime sun on a ground line under two arcs. The launcher PNGs in `assets/icons/` were rendered from the same geometry. |
Cards are image-led where an item has a kind (routines, goals, tasks): scene on Cards are image-led where an item has a kind (routines, goals, tasks): scene on
top or as a thumbnail, white type over the shaded lower half, three light top or as a thumbnail, white type over the shaded lower half, three light
numbers along the bottom. Greetings are two-tone: Medium ink, then Regular numbers along the bottom. Greetings are two-tone: Medium ink, then Regular
`colorInkSoft`. Primary buttons are night pills with a lime icon. `colorInkSoft`. Primary buttons are square night bars with a lime icon.
`Sheet` is light (ambient) by default; passing `chrome`, `banner` or `Sheet` is light (ambient) by default; passing `chrome`, `banner` or
`scene` gives a tinted stage with white type. Home, Routines and the player `scene` gives a tinted stage with white type. Home, Routines and the player
@@ -30,7 +30,11 @@ build their own layouts from the same pieces.
The sections below describe the original record-style type scale, which still The sections below describe the original record-style type scale, which still
applies to labels and metrics. applies to labels and metrics.
## Type — General Sans ## Type — Inter
`TextType.Light` is Inter Display Regular (the display cut, for titles and
big numbers); Regular, Medium and Bold are Inter Regular, Medium and
SemiBold.
| Role | Family | Size | Use | | Role | Family | Size | Use |
|---|---|---|---| |---|---|---|---|
@@ -45,16 +49,22 @@ directly above a large dark value. Used everywhere numbers appear.
## Geometry ## Geometry
- Sheet radius `28`, card radius `16`, chip radius `999`, control radius `12`. - Softly rounded, never pill-soft: large cards and image cards `16`, buttons `14`, rows, tiles and fields `12`, small controls `10`, chips and badges `8`, bars `4`. Sheets round their top at `20`.
- Portraits, the nav buttons, status dots, rings and the breathing circle are full circles.
- People are real photos (`designs/Portrait.dart`), never scene art: your photo is picked in Settings or by tapping the Home avatar, shrunk to 256 px and kept on the phone; members show the `photo` the server sends. No photo means an initial on a soft neutral disc. Mock mode uses bundled Unsplash portraits (`assets/images/person_*.jpg`, see `PEOPLE-CREDITS.txt`).
- Screen padding `20` horizontal. Card padding `16`. Section gap `28`. - Screen padding `20` horizontal. Card padding `16`. Section gap `28`.
- Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%). - Hairlines at `colorDivider` (black 7%), borders at `colorBorder` (black 8%).
- One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows. - One soft shadow only: `0 4 18 rgba(0,0,0,0.03)`. No coloured glows.
## Colour ## Colour
Near-black ink (`colorPrimaryDark #141414`) on warm off-white After the coach reference: near-black ink and buttons (`colorInk`,
(`colorPrimaryLight #EDEEEA`), with a single green accent (`colorPrimary `colorNight #1C1C1C`) on a neutral light grey ground (`colorAmbient*`
#2F6F4E`). Enforcement tiers own the rest of the palette: around `#EAEAEA`), frosted near-white cards (`colorGlassStrong`) with white
edges, and one warm orange accent (`colorAccent #EC6A36`, soft
`colorAccentSoft`). `colorLime` is that orange too: the spark on dark
buttons. Enforcement tiers keep their own colours, so standing still reads
at a glance:
| Standing | Colour | | Standing | Colour |
|---|---| |---|---|
@@ -66,10 +76,18 @@ Near-black ink (`colorPrimaryDark #141414`) on warm off-white
The tier colour is used consistently — header, chip, meter, queue accent — so The tier colour is used consistently — header, chip, meter, queue accent — so
the standing is legible without reading the word. the standing is legible without reading the word.
## Navigation
The nav is a floating cluster of round frosted buttons, icons only; the
open tab is a raised white disc. It switches tabs inside Home (Today,
Routines, Goals, Groups, Progress) in place: nothing is pushed, a tab has
no back arrow (`tab: true`), and Android back returns to Today. Tabs are
built on first open and then kept.
## Structure ## Structure
Every screen is: black `headerBar` (back, title, action) → white sheet with a Every screen is: black `headerBar` (back, title, action) → white sheet with a
`28` top radius → `sectionHeader` (label + light display title) → content. square top edge → `sectionHeader` (label + light display title) → content.
## Rules ## Rules
@@ -78,3 +96,63 @@ Every screen is: black `headerBar` (back, title, action) → white sheet with a
- Chips carry state, never actions. - Chips carry state, never actions.
- One primary button per screen; everything else is outlined or text. - One primary button per screen; everything else is outlined or text.
- Empty states are plain. An empty overdue queue is good news, not a party. - Empty states are plain. An empty overdue queue is good news, not a party.
## Home
Home opens on a light header panel (`colorHeaderTop` → `colorHeaderBottom`, text `colorHeaderText`):
greeting and avatar, a Today / Weekly toggle, a seven-day streak panel ending
today (a leaf on every kept day, kept = 80%+), then the numbers. Today shows
minutes held against planned, a ring with + that opens the "Add to today"
sheet, the plan as thumbnails, the report-card letter as the Score, and one
bar per commitment class. Weekly swaps in the week's average, a bar per day
and the class completion rates. Below the block, on the light ambient
surface: the rest of the day. Bars on the dark block use the `colorBar*` tokens.
## Groups
`see/group/`: people keeping each other honest. A group has members, shared
goals and a chat.
- **Shared goals** are split into **objectives** with a number and a unit
(200 km, 40 runs, 30,000 words). Every member logs toward them; the card
shows the group total, your share and a night **Log** button. Like
personal goals, they carry no debt.
- **Leaderboard** ranks members by kept rate over the last 7 days, with
streak, last check-in (red "quiet" after 24h) and a nudge button.
- **Chat** keeps the work and the talk together: plain messages are square
bubbles (yours in night, others white with the author's colour), while
check-ins (green bar) and nudges (amber bar) are cards in the thread.
The open chat polls every 4 seconds.
- Members are drawn by `memberAvatar` / `avatarStack`
(`designs/MemberAvatar.dart`): square painted portraits with the initial.
- Groups are reached from the nav (Groups tab), the Home "Your groups" rail
(tap opens the chat) and the add sheet.
## Progress
`see/progress/`: progress over time, reached from the nav (Progress tab).
- A Day / Week / Month switch over one white card: routines held against
the target, a change chip measured against the *same point* of the
previous period, and bars (days of the week, weeks of the month) with the
current one in night and future ones as stubs. Day shows a meter instead.
- Two tiles below: active time against its target, and the daily streak.
- Runs come from the device's run log (`RoutineRunRecord`), written when the
routine runner logs. Targets are local (`ProgressTargets`).
- **Targets** (`TargetSetup`) is the only setup screen: routines per week,
active minutes per week and reminders before scheduled routines. Until it
is saved once, Progress measures against the schedule and shows a night
"Set your weekly target" prompt. Targets never touch debt.
## Coach
`see/coach/`: the centre tab. Questions about your own data, answered by
`CoachEngine` from the run log, routines, today's plan, the overdue queue
and the debt score. There is no model and no guessing: six question kinds
(keeping routines, where you slip, streak, what's left today, active time,
debt), matched by keyword; anything else gets the list. An answer is a
frosted card with **bold** figures (`markedText`), an optional bar chart
(solid accent where the target was met, soft accent where short, a dashed
outline for the target, a callout over the standout bar) and buttons that
do something real: start a routine, open overdue, targets or progress, or
ask the follow-up. Messages fade and rise in; bars grow.

View File

@@ -1,57 +0,0 @@
Fontshare EULA
---—---------------------------------—------------------------------
Free Font - End User License Agreement (FF EULA)
---—---------------------------------—------------------------------
Notice to User
Indian Type Foundry designs, produces and distributes font software as digital fonts to end users worldwide. In addition to commercial fonts that are available for a fee, ITF also offers several fonts which can be used free of charge. The free fonts are distributed through a dedicated platform called www.fontshare.com (“Fontshare”) to end users worldwide. These free fonts are subject to this legally binding EULA between the Indian Type Foundry (“Indian Type Foundry” or “Licensor”) and you (“Licensee”). 
You acknowledge that the Font Software and designs embodied therein are protected by the copyright, other intellectual property rights and industrial property rights and by international treaties. They are and remain at all times the intellectual property of the Indian Type Foundry.
In addition to direct download, Fontshare also offers these free fonts via Fonthsare API using a code. In this case, the Font Software is delivered directly from the servers used by Indian Type Foundry to the Licensee's website, without the Licensee having to download the Font Software.
By downloading, accessing the API, installing, storing, copying or using one of any Font Software, you agree to the following terms. 
Definitions
“Font Software” refers to the set of computer files or programs released under this license that instructs your computer to display and/or print each letters, characters, typographic designs, ornament and so forth. Font Software includes all bitmap and vector representations of fonts and typographic representations and embellishments created by or derived from the Font Software. 
“Original Version” refers to the Font Software as distributed by the Indian Type Foundry as the copyright holder. 
“Derivative Work” refers to the pictorial representation of the font created by the Font Software, including typographic characters such as letters, numerals, ornaments, symbols, or punctuation and special characters.
01. Grant of License
You are hereby granted a non-exclusive, non-assignable, non-transferrable, terminable license to access, download and use the Font Software for your personal or commercial use for an unlimited period of time for free of charge. 
You may use the font Software in any media (including Print, Web, Mobile, Digital, Apps, ePub, Broadcasting and OEM) at any scale, at any location worldwide. 
You may use the Font Software to create logos and other graphic elements, images on any surface, vector files or other scalable drawings and static images. 
You may use the Font Software on any number of devices (computer, tablet, phone). The number of output devices (Printers) is not restricted. 
You may make only such reasonable number of back-up copies suitable to your permitted use. 
You may but are not required to identify Indian Type Foundry Fonts in your work credits. 
02. Limitations of usage
You may not modify, edit, adapt, translate, reverse engineer, decompile or disassemble, alter or otherwise copy the Font Software or the designs embodied therein in whole or in part, without the prior written consent of the Licensor. 
The Fonts may not - beyond the permitted copies and the uses defined herein - be distributed, duplicated, loaned, resold or licensed in any way, whether by lending, donating or give otherwise to a person or entity. This includes the distribution of the Fonts by e-mail, on USB sticks, CD-ROMs, or other media, uploading them in a public server or making the fonts available on peer-to-peer networks. A passing on to external designers or service providers (design agencies, repro studios, printers, etc.) is also not permitted. 
You are not allowed to transmit the Font Software over the Internet in font serving or for font replacement by means of technologies such as but not limited to EOT, Cufon, sIFR or similar technologies that may be developed in the future without the prior written consent of the Licensor. 
03. Embedding
You may embed the Font Software in PDF and other digital documents provided that is done in a secured, read-only mode. It must be ensured beyond doubt that the recipient cannot use the Font Software to edit or to create new documents. The design data (PDFs) created in this way and under these created design data (PDFs) may be distributed in any number. 
The extraction of the Font Software in whole or in part is prohibited. 
04. Third party use, Commercial print service provider
You may include the Font Software in a non-editable electronic document solely for printing and display purposes and provide that electronic document to the commercial print service provider for the purpose of printing. If the print service needs to install the fonts, they too need to download the Font Software from the Licensor's website.
05. Derivative Work
You are allowed to make derivative works as far as you use them for your personal or commercial use. However, you cannot modify, make changes or reverse engineer the original font software provided to you. Any derivative works are the exclusive property of the Licensor and shall be subject to the terms and conditions of this EULA. Derivative works may not be sub-licensed, sold, leased, rented, loaned, or given away without the express written permission of the Licensor. 
06. Warranty and Liability
BECAUSE THIS SOFTWARE IS LICENSED FREE OF CHARGE, INDIAN TYPE FOUNDRY MAKES NO WARRANTIES, EXPRESS OR IMPLIED AS TO THE MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR OTHERWISE. THE FONT SOFTWARE WAS NOT MANUFACTURED FOR USE IN MANUFACTURING CONTROL DEVICES OR NAVIGATION DEVICES OR IN CIRCUMSTANCES THAT COULD RESULT IN ENVIRONMENTAL DAMAGE OR PERSONAL INJURY. WITHOUT LIMITING THE FOREGOING, INDIAN TYPE FOUNDRY SHALL IN NO EVENT BE LIABLE TO THE LICENSED USER OR ANY OTHER THIRD PARTY FOR ANY DIRECT, CONSEQUENTIAL OR INCIDENTAL DAMAGES, INCLUDING DAMAGES FROM LOSS OF BUSINESS PROFITS, BUSINESS INTERRUPTION, LOSS OF BUSINESS INFORMATION NOR FOR LOST PROFITS OR SAVINGS ARISING OUT OF THE USE OR INABILITY TO USE THE PRODUCT EVEN IF NOTIFIED IN ADVANCE, UNDER NO CIRCUMSTANCES SHALL INDIAN TYPE FOUNDRY’S LIABILITY EXCEED THE REPLACEMENT COST OF THE SOFTWARE. 
IF LICENSEE CHOOSES TO ACCESS THE FONT SOFTWARE THROUGH A CODE (API), IT MAY HAVE A DIRECT IMPACT ON LICENSEE'S WEBSITE OR APPLICATIONS. INDIAN TYPE FOUNDRY IS NOT RESPONSIBLE OR LIABLE FOR ANY INTERRUPTION, MALFUNCTION, DOWNTIME OR OTHER FAILURE OF THE WEBSITE OR ITS API.
07. Updates, Maintenance and Support Services
Licensor will not provide you with any support services for the Software under this Agreement.
08. Termination 
Any breach of the terms of this agreement shall be a cause for termination, provided that such breach is notified in writing to the Licensee by the Licensor and the Licensee failed to rectify the breach within 30 days of the receipt of such notification. 
In the event of termination and without limitation of any remedies under law or equity, you must delete the Font Software and all copies thereof. Proof of this must be provided upon request of the Licensor.  
We reserve the right to claim damages for the violation of the conditions. 
09. Final Provisions
If individual provisions of this agreement are or become invalid, the validity of the remaining provisions shall remain unaffected. Invalid provisions shall be replaced by mutual agreement by such provisions that are suitable to achieve the desired economic purpose, taking into account the interests of both parties. The same shall apply mutatis mutandis to the filling of any gaps which may arise in this agreement.
This contract is subject to laws of the Republic of India. Place of performance and exclusive place of jurisdiction for all disputes between the parties arising out of or in connection with this contract is, as far as legally permissible, Ahmedabad, India.
- 
Last Updated on 22 March 2021
Copyright 2021 Indian Type Foundry. All rights reserved. 

View File

@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,94 @@
import '../../../utils/ObjectConvertors.dart';
import 'GroupGoal.dart';
import 'GroupMember.dart';
/// People keeping each other honest: members, the goals they share, and the
/// chat they talk in.
class Group {
String id;
String name;
String description;
String colourHex;
/// Six characters a friend types to join.
String inviteCode;
List<GroupMember> members;
List<GroupGoal> goals;
/// Messages the signed-in user has not seen yet.
int unread;
String lastMessage;
String lastAuthor;
DateTime? lastMessageAt;
Group({
this.id = "",
this.name = "",
this.description = "",
this.colourHex = "",
this.inviteCode = "",
List<GroupMember>? members,
List<GroupGoal>? goals,
this.unread = 0,
this.lastMessage = "",
this.lastAuthor = "",
this.lastMessageAt,
}) : members = members ?? <GroupMember>[],
goals = goals ?? <GroupGoal>[];
/// Average progress across the shared goals.
double get progress {
if (goals.isEmpty) {
return 0;
}
double total = 0;
for (GroupGoal goal in goals) {
total = total + goal.progress;
}
return total / goals.length;
}
bool get iOwn => members.any((member) => member.me && member.owner);
factory Group.fromJson(Map<String, dynamic> json) {
return Group(
id: json['id'] ?? "",
name: json['name'] ?? "",
description: json['description'] ?? "",
colourHex: json['colourHex'] ?? "",
inviteCode: json['inviteCode'] ?? "",
members: getGroupMemberList(json['members']),
goals: getGroupGoalList(json['goals']),
unread: json['unread'] ?? 0,
lastMessage: json['lastMessage'] ?? "",
lastAuthor: json['lastAuthor'] ?? "",
lastMessageAt: json['lastMessageAt'] == null
? null
: DateTime.tryParse(json['lastMessageAt']),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['name'] = name;
data['description'] = description;
data['colourHex'] = colourHex;
data['inviteCode'] = inviteCode;
data['members'] = members.map((item) => item.toJson()).toList();
data['goals'] = goals.map((item) => item.toJson()).toList();
data['unread'] = unread;
data['lastMessage'] = lastMessage;
data['lastAuthor'] = lastAuthor;
data['lastMessageAt'] = lastMessageAt?.toIso8601String();
return data;
}
}

View File

@@ -0,0 +1,67 @@
import '../../../utils/ObjectConvertors.dart';
import 'GroupObjective.dart';
/// A goal the whole group shares, broken into objectives everyone logs
/// against. Like a personal goal it carries no debt of its own.
class GroupGoal {
String id;
String title;
String description;
DateTime? targetDate;
String colourHex;
List<GroupObjective> objectives;
GroupGoal({
this.id = "",
this.title = "",
this.description = "",
this.targetDate,
this.colourHex = "",
List<GroupObjective>? objectives,
}) : objectives = objectives ?? <GroupObjective>[];
/// The average of the objectives, so one runaway objective cannot carry
/// the rest.
double get progress {
if (objectives.isEmpty) {
return 0;
}
double total = 0;
for (GroupObjective objective in objectives) {
total = total + objective.progress;
}
return total / objectives.length;
}
int get completeObjectives =>
objectives.where((objective) => objective.complete).length;
factory GroupGoal.fromJson(Map<String, dynamic> json) {
return GroupGoal(
id: json['id'] ?? "",
title: json['title'] ?? "",
description: json['description'] ?? "",
targetDate: json['targetDate'] == null || json['targetDate'] == ""
? null
: DateTime.tryParse(json['targetDate']),
colourHex: json['colourHex'] ?? "",
objectives: getGroupObjectiveList(json['objectives']),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['description'] = description;
data['targetDate'] = targetDate?.toIso8601String();
data['colourHex'] = colourHex;
data['objectives'] = objectives.map((item) => item.toJson()).toList();
return data;
}
}

View File

@@ -0,0 +1,70 @@
/// Someone in a group, with the numbers the leaderboard ranks on.
class GroupMember {
String id;
String name;
/// Their profile photo (URL), or empty for none.
String photo;
/// "Owner" or "Member".
String role;
/// Share of their own commitments kept over the last seven days, 0..1.
double keptRate;
/// Kept days in a row.
int streak;
/// Their standing name — Good, Warned, Grounded, Lockdown.
String standing;
DateTime? lastCheckIn;
/// True for the signed-in user.
bool me;
GroupMember({
this.id = "",
this.name = "",
this.photo = "",
this.role = "Member",
this.keptRate = 0,
this.streak = 0,
this.standing = "Good",
this.lastCheckIn,
this.me = false,
});
bool get owner => role == "Owner";
factory GroupMember.fromJson(Map<String, dynamic> json) {
return GroupMember(
id: json['id'] ?? "",
name: json['name'] ?? "",
photo: json['photo'] ?? "",
role: json['role'] ?? "Member",
keptRate: (json['keptRate'] ?? 0).toDouble(),
streak: json['streak'] ?? 0,
standing: json['standing'] ?? "Good",
lastCheckIn: json['lastCheckIn'] == null
? null
: DateTime.tryParse(json['lastCheckIn']),
me: json['me'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['name'] = name;
data['photo'] = photo;
data['role'] = role;
data['keptRate'] = keptRate;
data['streak'] = streak;
data['standing'] = standing;
data['lastCheckIn'] = lastCheckIn?.toIso8601String();
data['me'] = me;
return data;
}
}

View File

@@ -0,0 +1,66 @@
/// One line in a group chat. Besides plain text, the app posts check-ins
/// (progress logged on an objective), nudges and system notes into the same
/// thread so the conversation and the work sit together.
class GroupMessage {
String id;
String groupId;
String authorId;
String authorName;
String body;
/// "Text", "CheckIn", "Nudge" or "System".
String kind;
DateTime? sentAt;
/// True when the signed-in user wrote it.
bool mine;
GroupMessage({
this.id = "",
this.groupId = "",
this.authorId = "",
this.authorName = "",
this.body = "",
this.kind = "Text",
this.sentAt,
this.mine = false,
});
bool get isCheckIn => kind == "CheckIn";
bool get isNudge => kind == "Nudge";
bool get isSystem => kind == "System";
factory GroupMessage.fromJson(Map<String, dynamic> json) {
return GroupMessage(
id: json['id'] ?? "",
groupId: json['groupId'] ?? "",
authorId: json['authorId'] ?? "",
authorName: json['authorName'] ?? "",
body: json['body'] ?? "",
kind: json['kind'] ?? "Text",
sentAt:
json['sentAt'] == null ? null : DateTime.tryParse(json['sentAt']),
mine: json['mine'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['groupId'] = groupId;
data['authorId'] = authorId;
data['authorName'] = authorName;
data['body'] = body;
data['kind'] = kind;
data['sentAt'] = sentAt?.toIso8601String();
data['mine'] = mine;
return data;
}
}

View File

@@ -0,0 +1,58 @@
/// One measurable part of a shared goal — "Run 100 km", "20 sessions" — that
/// every member logs toward.
class GroupObjective {
String id;
String title;
/// km, sessions, pages, hours…
String unit;
double target;
/// The group's total so far.
double current;
/// What the signed-in user has put in.
double mine;
GroupObjective({
this.id = "",
this.title = "",
this.unit = "",
this.target = 0,
this.current = 0,
this.mine = 0,
});
double get progress {
if (target <= 0) {
return 0;
}
return (current / target).clamp(0.0, 1.0);
}
bool get complete => target > 0 && current >= target;
factory GroupObjective.fromJson(Map<String, dynamic> json) {
return GroupObjective(
id: json['id'] ?? "",
title: json['title'] ?? "",
unit: json['unit'] ?? "",
target: (json['target'] ?? 0).toDouble(),
current: (json['current'] ?? 0).toDouble(),
mine: (json['mine'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['unit'] = unit;
data['target'] = target;
data['current'] = current;
data['mine'] = mine;
return data;
}
}

View File

@@ -0,0 +1,40 @@
import 'ObjectiveDraft.dart';
/// Creates a shared goal in a group, objectives and all.
class GroupGoalRequest {
String groupId;
String? id;
String title;
String description;
String targetDate;
String colourHex;
List<ObjectiveDraft> objectives;
GroupGoalRequest({
this.groupId = "",
this.id,
this.title = "",
this.description = "",
this.targetDate = "",
this.colourHex = "",
List<ObjectiveDraft>? objectives,
}) : objectives = objectives ?? <ObjectiveDraft>[];
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['groupId'] = groupId;
data['id'] = id;
data['title'] = title;
data['description'] = description;
data['targetDate'] = targetDate;
data['colourHex'] = colourHex;
data['objectives'] = objectives.map((item) => item.toJson()).toList();
return data;
}
}

View File

@@ -0,0 +1,18 @@
/// Posts a message to a group chat.
class GroupMessageRequest {
String groupId;
String body;
String kind;
GroupMessageRequest({this.groupId = "", this.body = "", this.kind = "Text"});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['groupId'] = groupId;
data['body'] = body;
data['kind'] = kind;
return data;
}
}

View File

@@ -0,0 +1,21 @@
/// Creates a group, or renames one when [id] is set.
class GroupRequest {
String? id;
String name;
String description;
String colourHex;
GroupRequest({this.id, this.name = "", this.description = "", this.colourHex = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['name'] = name;
data['description'] = description;
data['colourHex'] = colourHex;
return data;
}
}

View File

@@ -0,0 +1,12 @@
/// Joins a group by the code a friend shared.
class JoinGroupRequest {
String inviteCode;
JoinGroupRequest({this.inviteCode = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['inviteCode'] = inviteCode;
return data;
}
}

View File

@@ -0,0 +1,15 @@
/// Nudges one member, or everyone when [memberId] is empty.
class NudgeRequest {
String groupId;
String memberId;
NudgeRequest({this.groupId = "", this.memberId = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['groupId'] = groupId;
data['memberId'] = memberId;
return data;
}
}

View File

@@ -0,0 +1,18 @@
/// One objective as typed into the shared-goal editor.
class ObjectiveDraft {
String title;
String unit;
double target;
ObjectiveDraft({this.title = "", this.unit = "", this.target = 0});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['title'] = title;
data['unit'] = unit;
data['target'] = target;
return data;
}
}

View File

@@ -0,0 +1,24 @@
/// Logs an amount against one objective of a shared goal.
class ObjectiveProgressRequest {
String groupId;
String goalId;
String objectiveId;
double amount;
String note;
ObjectiveProgressRequest({this.groupId = "", this.goalId = "", this.objectiveId = "", this.amount = 0, this.note = ""});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['groupId'] = groupId;
data['goalId'] = goalId;
data['objectiveId'] = objectiveId;
data['amount'] = amount;
data['note'] = note;
return data;
}
}

View File

@@ -0,0 +1,24 @@
import 'CoachActionKind.dart';
class CoachAction {
String label;
CoachActionKind kind;
/// For [CoachActionKind.StartRoutine].
String routineId;
/// For [CoachActionKind.Ask].
String question;
/// The filled button; the rest are outlined.
bool primary;
CoachAction({
required this.label,
required this.kind,
this.routineId = "",
this.question = "",
this.primary = false,
});
}

View File

@@ -0,0 +1,18 @@
/// What a button under a coach answer does. Every action does something real
/// in the app; the coach never offers a button that only talks.
enum CoachActionKind {
/// Run a routine now.
StartRoutine,
/// Open the overdue queue.
OpenOverdue,
/// Open the Targets & reminders setup.
OpenTargets,
/// Open the progress screen.
OpenProgress,
/// Ask a follow-up question.
Ask,
}

View File

@@ -0,0 +1,31 @@
/// A small bar chart inside a coach answer: one bar per day against a dashed
/// target outline.
class CoachChart {
/// "Target · 3 a day"
String title;
List<String> labels;
List<double> values;
/// Per-bar target; the dashed outline is drawn to this height.
List<double> targets;
/// A bar to call out with [callout] above it, or -1.
int highlight;
String callout;
/// Bars after this index have not happened yet (today and beyond).
int lastComplete;
CoachChart({
required this.title,
required this.labels,
required this.values,
required this.targets,
this.highlight = -1,
this.callout = "",
int? lastComplete,
}) : lastComplete = lastComplete ?? labels.length - 1;
}

View File

@@ -0,0 +1,29 @@
import '../../external/data/Commitment.dart';
import '../../external/data/RoutineChain.dart';
import 'ProgressTargets.dart';
import 'RoutineRunRecord.dart';
/// Everything the coach may answer from. Nothing outside this is used, so
/// every answer is traceable to the user's own data.
class CoachContext {
List<RoutineRunRecord> runs;
List<RoutineChain> routines;
ProgressTargets targets;
List<Commitment> plan;
List<Commitment> overdue;
double debtScore;
CoachContext({
required this.runs,
required this.routines,
required this.targets,
required this.plan,
required this.overdue,
required this.debtScore,
});
}

View File

@@ -0,0 +1,28 @@
import 'CoachAction.dart';
import 'CoachChart.dart';
/// One turn in the coach thread. Answers carry **bold** markup in [body] and
/// [followUp], an optional chart between them, and actions under them.
class CoachMessage {
bool mine;
String body;
CoachChart? chart;
String followUp;
List<CoachAction> actions;
DateTime at;
CoachMessage({
required this.mine,
required this.body,
this.chart,
this.followUp = "",
List<CoachAction>? actions,
DateTime? at,
}) : actions = actions ?? <CoachAction>[],
at = at ?? DateTime.now();
}

View File

@@ -0,0 +1,13 @@
/// The span the Progress screen measures.
enum ProgressPeriod { Day, Week, Month }
String periodLabel(ProgressPeriod value) {
switch (value) {
case ProgressPeriod.Day:
return "Day";
case ProgressPeriod.Week:
return "Week";
case ProgressPeriod.Month:
return "Month";
}
}

View File

@@ -0,0 +1,46 @@
/// What a good week looks like, set by the user on the Targets screen.
/// Progress measures against these; reminders fire before scheduled routines.
class ProgressTargets {
/// Routines held (every step done) per week.
int routinesPerWeek;
/// Minutes spent inside routines per week, held or not.
int minutesPerWeek;
bool reminders;
/// How long before a routine's start the reminder fires.
int reminderLead;
/// False until the user has saved the Targets screen once, so Progress can
/// ask for a target instead of silently using the defaults.
bool set;
ProgressTargets({
this.routinesPerWeek = 5,
this.minutesPerWeek = 150,
this.reminders = false,
this.reminderLead = 10,
this.set = false,
});
factory ProgressTargets.fromJson(Map<String, dynamic> json) {
return ProgressTargets(
routinesPerWeek: json['routinesPerWeek'] ?? 5,
minutesPerWeek: json['minutesPerWeek'] ?? 150,
reminders: json['reminders'] ?? false,
reminderLead: json['reminderLead'] ?? 10,
set: json['set'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['routinesPerWeek'] = routinesPerWeek;
data['minutesPerWeek'] = minutesPerWeek;
data['reminders'] = reminders;
data['reminderLead'] = reminderLead;
data['set'] = set;
return data;
}
}

View File

@@ -0,0 +1,56 @@
/// One finished run of a routine, kept on the device so Progress can draw the
/// days and weeks without waiting on the network. The server keeps its own
/// log through `Routine/Log`; this is the local mirror.
class RoutineRunRecord {
String routineId;
String title;
/// [RoutineKind] name.
String kind;
DateTime startedAt;
int seconds;
int completedSteps;
int totalSteps;
RoutineRunRecord({
required this.routineId,
required this.title,
required this.kind,
required this.startedAt,
this.seconds = 0,
this.completedSteps = 0,
this.totalSteps = 0,
});
/// Every step, in order. Partial runs still count toward active time.
bool get held => totalSteps > 0 && completedSteps >= totalSteps;
factory RoutineRunRecord.fromJson(Map<String, dynamic> json) {
return RoutineRunRecord(
routineId: json['routineId'] ?? '',
title: json['title'] ?? '',
kind: json['kind'] ?? '',
startedAt: DateTime.tryParse(json['startedAt'] ?? '') ?? DateTime.now(),
seconds: json['seconds'] ?? 0,
completedSteps: json['completedSteps'] ?? 0,
totalSteps: json['totalSteps'] ?? 0,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['routineId'] = routineId;
data['title'] = title;
data['kind'] = kind;
data['startedAt'] = startedAt.toIso8601String();
data['seconds'] = seconds;
data['completedSteps'] = completedSteps;
data['totalSteps'] = totalSteps;
return data;
}
}

View File

@@ -0,0 +1,27 @@
/// A quick to-do: no window, no class, no debt. Kept on the device so jotting
/// something down never waits on the network.
class TodoItem {
String id;
String title;
bool done;
TodoItem({required this.id, required this.title, this.done = false});
factory TodoItem.fromJson(Map<String, dynamic> json) {
return TodoItem(
id: json['id'] ?? '',
title: json['title'] ?? '',
done: json['done'] ?? false,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['title'] = title;
data['done'] = done;
return data;
}
}

View File

@@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart'; import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/GroupGoalRequest.dart';
import '../about/external/initial/GroupMessageRequest.dart';
import '../about/external/initial/GroupRequest.dart';
import '../about/external/initial/JoinGroupRequest.dart';
import '../about/external/initial/NudgeRequest.dart';
import '../about/external/initial/ObjectiveProgressRequest.dart';
import '../about/external/initial/IdRequest.dart'; import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart'; import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart'; import '../about/external/initial/ReportCardRequest.dart';
@@ -512,4 +518,76 @@ class Comms implements ConnectComms {
dio.options.headers = navigation.value; dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request); return await dio.post(navigation.key, data: request);
} }
// ── Groups ────────────────────────────────────────────────────────────────
@override
Future<Response> getMyGroups(HistoryRequest request) async {
Pair navigation = await getRequestHeaders(myGroups, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getGroupEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(oneGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveGroupEntry(GroupRequest request) async {
Pair navigation = await getRequestHeaders(saveGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> joinGroupEntry(JoinGroupRequest request) async {
Pair navigation = await getRequestHeaders(joinGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> leaveGroupEntry(IdRequest request) async {
Pair navigation = await getRequestHeaders(leaveGroup, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) async {
Pair navigation = await getRequestHeaders(saveGroupGoal, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) async {
Pair navigation = await getRequestHeaders(logObjective, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> getGroupMessages(IdRequest request) async {
Pair navigation = await getRequestHeaders(groupMessages, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> sendGroupMessage(GroupMessageRequest request) async {
Pair navigation = await getRequestHeaders(sendGroupMessageTo, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
@override
Future<Response> nudgeGroupMember(NudgeRequest request) async {
Pair navigation = await getRequestHeaders(nudgeMember, "");
dio.options.headers = navigation.value;
return await dio.post(navigation.key, data: request);
}
} }

View File

@@ -172,3 +172,24 @@ String myPartners = 'Partner/Mine';
String invitePartner = 'Partner/Invite'; String invitePartner = 'Partner/Invite';
String partnerPolicy = 'Partner/Policy'; String partnerPolicy = 'Partner/Policy';
// ── Groups ────────────────────────────────────────────────────────────────────
String myGroups = 'Group/Mine';
String oneGroup = 'Group/One';
String saveGroup = 'Group/Save';
String joinGroup = 'Group/Join';
String leaveGroup = 'Group/Leave';
String saveGroupGoal = 'Group/Goal/Save';
String logObjective = 'Group/Objective/Log';
String groupMessages = 'Group/Messages';
String sendGroupMessageTo = 'Group/Message/Send';
String nudgeMember = 'Group/Nudge';

View File

@@ -9,6 +9,12 @@ import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart'; import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/GroupGoalRequest.dart';
import '../about/external/initial/GroupMessageRequest.dart';
import '../about/external/initial/GroupRequest.dart';
import '../about/external/initial/JoinGroupRequest.dart';
import '../about/external/initial/NudgeRequest.dart';
import '../about/external/initial/ObjectiveProgressRequest.dart';
import '../about/external/initial/IdRequest.dart'; import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart'; import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart'; import '../about/external/initial/ReportCardRequest.dart';
@@ -144,4 +150,25 @@ abstract class ConnectComms {
Future<Response> getMyNotifications(HistoryRequest request); Future<Response> getMyNotifications(HistoryRequest request);
Future<Response> acknowledgeNudgeEntry(IdRequest request); Future<Response> acknowledgeNudgeEntry(IdRequest request);
// ── Groups ──────────────────────────────────────────────────────────────
Future<Response> getMyGroups(HistoryRequest request);
Future<Response> getGroupEntry(IdRequest request);
Future<Response> saveGroupEntry(GroupRequest request);
Future<Response> joinGroupEntry(JoinGroupRequest request);
Future<Response> leaveGroupEntry(IdRequest request);
Future<Response> saveGroupGoalEntry(GroupGoalRequest request);
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request);
Future<Response> getGroupMessages(IdRequest request);
Future<Response> sendGroupMessage(GroupMessageRequest request);
Future<Response> nudgeGroupMember(NudgeRequest request);
} }

View File

@@ -2,9 +2,12 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as timezones;
import 'package:timezone/timezone.dart' as tz;
import '../about/external/data/Commitment.dart'; import '../about/external/data/Commitment.dart';
import '../about/external/data/LiveSession.dart'; import '../about/external/data/LiveSession.dart';
import '../about/external/data/RoutineChain.dart';
import '../about/internal/application/CommitmentClass.dart'; import '../about/internal/application/CommitmentClass.dart';
import '../about/internal/application/EscalationTier.dart'; import '../about/internal/application/EscalationTier.dart';
import '../about/internal/application/ToneLevel.dart'; import '../about/internal/application/ToneLevel.dart';
@@ -209,6 +212,72 @@ class LocalNotificationEngine {
); );
} }
/// Routine reminders occupy ids [reminderBase, reminderBase + reminderSlots).
static const int reminderBase = 7000;
static const int reminderSlots = 200;
/// One-shot reminders for every scheduled routine over the next seven days,
/// [leadMinutes] before its start. One-shots rather than repeating rules so
/// no device time-zone lookup is needed: each fire time is an absolute
/// instant. Home re-places them on every open, so the week rolls forward.
static Future<void> scheduleRoutineReminders(
List<RoutineChain> routines, int leadMinutes) async {
if (kIsWeb) {
return;
}
await init();
await cancelRoutineReminders();
timezones.initializeTimeZones();
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
int slot = 0;
for (int offset = 0; offset < 7; offset++) {
final DateTime day = today.add(Duration(days: offset));
for (RoutineChain routine in routines) {
if (!routine.days.contains(day.weekday) || routine.startMinute < 0) {
continue;
}
final DateTime fireAt = day.add(
Duration(minutes: routine.startMinute - leadMinutes));
if (!fireAt.isAfter(now) || slot >= reminderSlots) {
continue;
}
await plugin.zonedSchedule(
id: reminderBase + slot,
title: routine.title,
body: leadMinutes == 0
? "Starting now."
: "Starts in $leadMinutes minutes.",
scheduledDate: tz.TZDateTime.from(fireAt, tz.UTC),
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(reminderChannel, 'Reminders',
category: AndroidNotificationCategory.reminder),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
payload: routine.id,
);
slot = slot + 1;
}
}
}
static Future<void> cancelRoutineReminders() async {
if (kIsWeb) {
return;
}
await init();
for (int index = 0; index < reminderSlots; index++) {
await plugin.cancel(id: reminderBase + index);
}
}
static Future<void> cancelAll() async { static Future<void> cancelAll() async {
// The browser has no notification channels; the runner works without. // The browser has no notification channels; the runner works without.
if (kIsWeb) { if (kIsWeb) {

View File

@@ -48,7 +48,7 @@ class TickBox extends StatelessWidget {
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: checked ? tone : Colors.transparent, color: checked ? tone : Colors.transparent,
borderRadius: BorderRadius.circular(7), borderRadius: BorderRadius.circular(6),
border: Border.all( border: Border.all(
color: checked ? tone : colorGrey.withValues(alpha: 0.55), color: checked ? tone : colorGrey.withValues(alpha: 0.55),
width: 1.6, width: 1.6,
@@ -137,7 +137,8 @@ class TickRow extends StatelessWidget {
duration: const Duration(milliseconds: 160), duration: const Duration(milliseconds: 160),
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontFamily: struck ? "GroundedRegular" : "GroundedMedium", fontFamily:
struck ? "GroundedRegular" : "GroundedMedium",
color: struck ? colorGrey2 : colorPrimaryDark, color: struck ? colorGrey2 : colorPrimaryDark,
decoration: struck decoration: struck
? TextDecoration.lineThrough ? TextDecoration.lineThrough
@@ -202,7 +203,7 @@ Widget addTaskAffordance(
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: tone.withValues(alpha: 0.12), color: tone.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(12),
border: Border.all(color: tone.withValues(alpha: 0.25), width: 1), border: Border.all(color: tone.withValues(alpha: 0.25), width: 1),
), ),
child: Icon(Icons.add_rounded, size: 22, color: tone), child: Icon(Icons.add_rounded, size: 22, color: tone),

View File

@@ -5,7 +5,7 @@ import '../utils/Colors.dart';
import 'text/Text.dart'; import 'text/Text.dart';
/// The font families, wrapped so no screen ever names a family directly. /// The font families, wrapped so no screen ever names a family directly.
/// General Sans — see fonts/LICENSE-GeneralSans.txt. /// Inter (SIL OFL) — see fonts/LICENSE-Inter.txt.
String getTextType(TextType type) { String getTextType(TextType type) {
switch (type) { switch (type) {
case TextType.Bold: case TextType.Bold:
@@ -32,7 +32,7 @@ Widget pill(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: background, color: background,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(14),
border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1), border: Border.all(color: foreground.withValues(alpha: 0.20), width: 1),
), ),
child: Row( child: Row(
@@ -57,11 +57,11 @@ Widget pill(
/// The standard card surface. /// The standard card surface.
Widget card({ Widget card({
required Widget child, required Widget child,
EdgeInsets padding = const EdgeInsets.all(16), EdgeInsets padding = const EdgeInsets.all(20),
EdgeInsets margin = EdgeInsets.zero, EdgeInsets margin = EdgeInsets.zero,
Color? background, Color? background,
Color? borderColor, Color? borderColor,
double radius = 22, double radius = 16,
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
final Widget body = Container( final Widget body = Container(
@@ -191,7 +191,7 @@ Widget meter(
: fraction; : fraction;
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(14),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: clamped, value: clamped,
minHeight: height, minHeight: height,

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
/// A dashed rounded-rectangle outline: the target a bar is measured against.
class DashedOutline extends CustomPainter {
final Color color;
final double radius;
const DashedOutline({required this.color, this.radius = 10});
@override
void paint(Canvas canvas, Size size) {
if (size.height <= 0) {
return;
}
final Paint paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
final Path outline = Path()
..addRRect(
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)));
const double dash = 4;
const double gap = 3;
for (final metric in outline.computeMetrics()) {
double distance = 0;
while (distance < metric.length) {
canvas.drawPath(metric.extractPath(distance, distance + dash), paint);
distance = distance + dash + gap;
}
}
}
@override
bool shouldRepaint(DashedOutline oldDelegate) =>
oldDelegate.color != color || oldDelegate.radius != radius;
}

View File

@@ -82,8 +82,7 @@ class _DotDigitsPainter extends CustomPainter {
final Path? outline = _glyph(glyph); final Path? outline = _glyph(glyph);
if (outline != null) { if (outline != null) {
final Path placed = outline.transform( final Path placed = outline.transform((Matrix4.identity()
(Matrix4.identity()
..translateByDouble(x, 0, 0, 1) ..translateByDouble(x, 0, 0, 1)
..scaleByDouble(_scale, _scale, 1, 1)) ..scaleByDouble(_scale, _scale, 1, 1))
.storage); .storage);

View File

@@ -1,125 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart'; import 'FloatingNavState.dart';
import '../utils/Colors.dart';
import 'GlassSurface.dart';
import 'text/Text.dart';
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap}); typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
/// The floating glass tab bar with the dark round action button beside it. /// The floating tab bar, after the reference: a row of frosted circles that
class FloatingNav extends StatelessWidget { /// just overlap, icons only. The open tab is a solid white disc that pops
/// forward over its neighbours; a pressed circle dips. It switches tabs in
/// place — nothing here pushes a page.
class FloatingNav extends StatefulWidget {
final List<NavEntry> entries; final List<NavEntry> entries;
final int selected; final int selected;
final IconData actionIcon;
final VoidCallback onAction;
const FloatingNav({ const FloatingNav({
super.key, super.key,
required this.entries, required this.entries,
required this.selected, required this.selected,
required this.actionIcon,
required this.onAction,
}); });
@override @override
Widget build(BuildContext context) { State<FloatingNav> createState() => FloatingNavState();
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(34),
boxShadow: const [
BoxShadow(
color: Color(0x1A1A1F1C),
blurRadius: 30,
offset: Offset(0, 10),
),
],
),
child: GlassSurface(
radius: BorderRadius.circular(34),
tint: colorGlassStrong,
padding: const EdgeInsets.all(6),
child: Row(
children: [
for (int index = 0; index < entries.length; index++)
Expanded(child: _tab(entries[index], index == selected)),
],
),
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: onAction,
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const RadialGradient(
center: Alignment(-0.3, -0.4),
colors: <Color>[colorNightGlow, colorNight],
),
border: Border.all(color: colorWhite, width: 3),
boxShadow: const [
BoxShadow(
color: Color(0x331A1F1C),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: Icon(actionIcon, size: 24, color: colorLime),
),
),
],
),
),
);
}
Widget _tab(NavEntry entry, bool active) {
return GestureDetector(
onTap: entry.onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: active ? colorWhite : colorWhite.withValues(alpha: 0.0),
borderRadius: BorderRadius.circular(28),
boxShadow: active
? const [
BoxShadow(
color: Color(0x141A1F1C),
blurRadius: 12,
offset: Offset(0, 4),
),
]
: const [],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(entry.icon,
size: 19, color: active ? colorInk : colorInkSoft),
const SizedBox(height: 4),
text(entry.label, 10, active ? TextType.Medium : TextType.Regular,
color: active ? colorInk : colorInkSoft, maxLines: 1),
],
),
),
);
}
} }

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
import 'FloatingNav.dart';
import 'GlassSurface.dart';
class FloatingNavState extends State<FloatingNav> {
static const double _size = 60;
/// Neighbours overlap by this much, as in the reference.
static const double _overlap = 6;
int _pressed = -1;
void _onPressDown(int index) {
setState(() {
_pressed = index;
});
}
void _onPressEnd() {
setState(() {
_pressed = -1;
});
}
@override
Widget build(BuildContext context) {
final int count = widget.entries.length;
const double step = _size - _overlap;
// The active disc is painted last so it sits in front of both neighbours.
final List<int> order = <int>[
for (int index = 0; index < count; index++)
if (index != widget.selected) index,
if (widget.selected >= 0 && widget.selected < count) widget.selected,
];
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Center(
child: SizedBox(
width: step * (count - 1) + _size,
height: _size + 8,
child: Stack(
clipBehavior: Clip.none,
children: [
for (int index in order)
AnimatedPositioned(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
left: step * index,
top: index == widget.selected ? 0 : 4,
child: _button(index),
),
],
),
),
),
),
);
}
Widget _button(int index) {
final NavEntry entry = widget.entries[index];
final bool active = index == widget.selected;
final double scale = _pressed == index ? 0.9 : (active ? 1.06 : 1.0);
return Semantics(
button: true,
selected: active,
label: entry.label,
child: GestureDetector(
onTap: entry.onTap,
onTapDown: (_) => _onPressDown(index),
onTapUp: (_) => _onPressEnd(),
onTapCancel: _onPressEnd,
behavior: HitTestBehavior.opaque,
child: AnimatedScale(
scale: scale,
duration: Duration(milliseconds: _pressed == index ? 90 : 260),
curve: active ? Curves.easeOutBack : Curves.easeOutCubic,
child: AnimatedContainer(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
width: _size,
height: _size,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: active
? const [
BoxShadow(
color: Color(0x2E000000),
blurRadius: 20,
offset: Offset(0, 8),
),
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 14,
offset: Offset(0, 6),
),
],
),
child: GlassSurface(
radius: const BorderRadius.all(Radius.circular(_size)),
tint: active ? colorWhite : colorGlass,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: colorWhite.withValues(alpha: active ? 1 : 0.85),
width: 1.2),
),
child: TweenAnimationBuilder<Color?>(
tween: ColorTween(end: active ? colorInk : colorInkSoft),
duration: const Duration(milliseconds: 260),
builder: (context, colour, child) =>
Icon(entry.icon, size: 23, color: colour),
),
),
),
),
),
),
);
}
}

View File

@@ -23,7 +23,7 @@ class GlassSurface extends StatelessWidget {
const GlassSurface({ const GlassSurface({
super.key, super.key,
required this.child, required this.child,
this.radius = const BorderRadius.all(Radius.circular(20)), this.radius = const BorderRadius.all(Radius.circular(14)),
this.padding = EdgeInsets.zero, this.padding = EdgeInsets.zero,
this.tint, this.tint,
this.onDark = false, this.onDark = false,
@@ -60,7 +60,7 @@ class GlassSurface extends StatelessWidget {
Widget glassCircle( Widget glassCircle(
IconData icon, IconData icon,
VoidCallback onTap, { VoidCallback onTap, {
double size = 42, double size = 50,
bool onDark = false, bool onDark = false,
bool dotted = false, bool dotted = false,
Color? dotColor, Color? dotColor,

View File

@@ -32,8 +32,8 @@ Future<Goal?> showGoalPicker(
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: colorSheetBackground, color: colorSheetBackground,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(28), topLeft: Radius.circular(16),
topRight: Radius.circular(28), topRight: Radius.circular(16),
), ),
), ),
child: ListView( child: ListView(
@@ -93,7 +93,7 @@ Widget _goalOption(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorWhite, color: colorWhite,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: selected ? accent.withValues(alpha: 0.5) : colorBorder, color: selected ? accent.withValues(alpha: 0.5) : colorBorder,
width: selected ? 1.5 : 1, width: selected ? 1.5 : 1,

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'Portrait.dart';
import 'text/Text.dart';
/// A member's round photo, or their initial when they have none.
Widget memberAvatar(String name, double size,
{String photo = "", Color? border}) {
return portrait(photo, name, size, border: border);
}
/// Several portraits overlapping, with "+N" once there are more than fit.
/// [photos] lines up with [names]; missing entries fall back to initials.
Widget avatarStack(List<String> names, double size,
{List<String> photos = const <String>[], int max = 4, Color? border}) {
final List<String> shown = names.take(max).toList();
final int extra = names.length - shown.length;
final double step = size * 0.68;
final int slots = shown.length + (extra > 0 ? 1 : 0);
return SizedBox(
width: slots == 0 ? 0 : size + step * (slots - 1),
height: size,
child: Stack(
children: [
for (int index = 0; index < shown.length; index++)
Positioned(
left: step * index,
child: memberAvatar(shown[index], size,
photo: index < photos.length ? photos[index] : "",
border: border),
),
if (extra > 0)
Positioned(
left: step * shown.length,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorNight,
border: Border.all(color: border ?? colorWhite, width: 2),
),
child: text("+$extra", size * 0.34, TextType.Medium,
color: colorWhite),
),
),
],
),
);
}

View File

@@ -0,0 +1,56 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// Where a portrait comes from. A stored `pic` is one of:
/// - `data:image/...;base64,...` — a photo picked on this device
/// - `http(s)://...` — a photo the server holds
/// - `asset:assets/images/...` — a bundled sample (mock mode)
/// Anything else, or empty, has no photo.
ImageProvider? portraitImage(String pic) {
if (pic.startsWith("data:image")) {
final int comma = pic.indexOf(",");
if (comma < 0) {
return null;
}
return MemoryImage(base64Decode(pic.substring(comma + 1)));
}
if (pic.startsWith("http://") || pic.startsWith("https://")) {
return NetworkImage(pic);
}
if (pic.startsWith("asset:")) {
return AssetImage(pic.substring("asset:".length));
}
return null;
}
/// A round photo of a person. Without a photo, their initial on a soft
/// neutral disc — never a landscape: a person should look like a person.
Widget portrait(String pic, String name, double size,
{Color? border, double borderWidth = 2}) {
final ImageProvider? image = portraitImage(pic);
final String initial =
name.trim().isEmpty ? "?" : name.trim().substring(0, 1).toUpperCase();
return Container(
width: size,
height: size,
alignment: Alignment.center,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorMuted,
border: Border.all(color: border ?? colorWhite, width: borderWidth),
image: image == null
? null
: DecorationImage(image: image, fit: BoxFit.cover),
),
child: image == null
? text(initial, size * 0.4, TextType.Medium, color: colorInkSoft)
: null,
);
}

View File

@@ -53,8 +53,8 @@ class ProgressRing extends StatelessWidget {
), ),
child: Center( child: Center(
child: child ?? child: child ??
text("${(clamped * 100).round()}%", size * 0.22, text(
TextType.Bold, "${(clamped * 100).round()}%", size * 0.22, TextType.Bold,
color: colorPrimaryDark), color: colorPrimaryDark),
), ),
); );

View File

@@ -27,6 +27,9 @@ class Sheet extends StatelessWidget {
/// Optional trailing control in the chrome. /// Optional trailing control in the chrome.
final Widget? action; final Widget? action;
/// Space under the content; tabs leave room for the floating nav.
final double bottomSpace;
/// Stage colour. Leaving it null (with no banner) gives the ambient tone. /// Stage colour. Leaving it null (with no banner) gives the ambient tone.
final Color? chrome; final Color? chrome;
@@ -53,6 +56,7 @@ class Sheet extends StatelessWidget {
this.scrollable = true, this.scrollable = true,
this.scene, this.scene,
this.sceneSeed = 0, this.sceneSeed = 0,
this.bottomSpace = 40,
}); });
bool get _light => chrome == null && banner == null && scene == null; bool get _light => chrome == null && banner == null && scene == null;
@@ -62,8 +66,7 @@ class Sheet extends StatelessWidget {
final bool light = _light; final bool light = _light;
final Color stage = chrome ?? colorPrimaryDark; final Color stage = chrome ?? colorPrimaryDark;
final Color ink = light ? colorInk : colorWhite; final Color ink = light ? colorInk : colorWhite;
final Color soft = final Color soft = light ? colorInkSoft : colorWhite.withValues(alpha: 0.5);
light ? colorInkSoft : colorWhite.withValues(alpha: 0.5);
final Widget header = SafeArea( final Widget header = SafeArea(
bottom: false, bottom: false,
@@ -80,8 +83,7 @@ class Sheet extends StatelessWidget {
? null ? null
: Align( : Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: glassCircle( child: glassCircle(CupertinoIcons.arrow_left, onBack!,
CupertinoIcons.arrow_left, onBack!,
onDark: !light), onDark: !light),
), ),
), ),
@@ -136,14 +138,14 @@ class Sheet extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: light ? colorFrost : colorPrimaryLight, color: light ? colorFrost : colorPrimaryLight,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(32), topLeft: Radius.circular(16),
topRight: Radius.circular(32), topRight: Radius.circular(16),
), ),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: scrollable child: scrollable
? SingleChildScrollView( ? SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 40), padding: EdgeInsets.fromLTRB(20, 28, 20, bottomSpace),
child: child, child: child,
) )
: Padding( : Padding(

View File

@@ -13,7 +13,7 @@ Widget roundedCornerButton(
Color? foreground, Color? foreground,
IconData? icon, IconData? icon,
bool enabled = true, bool enabled = true,
double radius = 99, double radius = 14,
double verticalPadding = 16, double verticalPadding = 16,
}) { }) {
final Color bg = enabled ? (background ?? colorNight) : colorGrey; final Color bg = enabled ? (background ?? colorNight) : colorGrey;
@@ -55,7 +55,7 @@ Widget outlinedActionButton(
Color? foreground, Color? foreground,
IconData? icon, IconData? icon,
bool enabled = true, bool enabled = true,
double radius = 99, double radius = 14,
}) { }) {
final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey; final Color fg = enabled ? (foreground ?? colorPrimaryDark) : colorGrey;

View File

@@ -17,7 +17,7 @@ Widget countStepper(
padding: const EdgeInsets.fromLTRB(14, 10, 8, 10), padding: const EdgeInsets.fromLTRB(14, 10, 8, 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorWhite, color: colorWhite,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
border: Border.all(color: colorBorder, width: 1), border: Border.all(color: colorBorder, width: 1),
), ),
child: Row( child: Row(
@@ -56,8 +56,8 @@ Widget _stepButton(IconData icon, VoidCallback? onTap) {
color: enabled ? colorSecondary : colorInset, color: enabled ? colorSecondary : colorInset,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Icon(icon, child:
size: 14, color: enabled ? colorPrimaryDark : colorGrey), Icon(icon, size: 14, color: enabled ? colorPrimaryDark : colorGrey),
), ),
); );
} }

View File

@@ -46,27 +46,26 @@ Widget inputField(
fontSize: 13, fontSize: 13,
color: colorGrey, color: colorGrey,
), ),
prefixIcon: icon == null prefixIcon:
? null icon == null ? null : Icon(icon, size: 18, color: colorGrey2),
: Icon(icon, size: 18, color: colorGrey2),
filled: true, filled: true,
fillColor: colorWhite, fillColor: colorWhite,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14), const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorBorder, width: 1), borderSide: BorderSide(color: colorBorder, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4), borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorNegative, width: 1), borderSide: BorderSide(color: colorNegative, width: 1),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorNegative, width: 1.4), borderSide: BorderSide(color: colorNegative, width: 1.4),
), ),
errorStyle: TextStyle( errorStyle: TextStyle(
@@ -80,6 +79,38 @@ Widget inputField(
); );
} }
/// The bare add-a-line field used inside a card: no label, no box of its
/// own; return or the trailing button submits.
Widget quickAddField(
TextEditingController controller,
String hint,
ValueChanged<String> onSubmitted, {
FocusNode? focusNode,
}) {
return TextField(
controller: controller,
focusNode: focusNode,
onSubmitted: onSubmitted,
textInputAction: TextInputAction.done,
style: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 16,
color: colorInk,
),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontFamily: getTextType(TextType.Regular),
fontSize: 16,
color: colorInkSoft,
),
isDense: true,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
);
}
/// The excuse field. Deliberately unadorned — no templates, no quick-picks, a /// The excuse field. Deliberately unadorned — no templates, no quick-picks, a
/// live character count that shows the minimum, because the friction is the /// live character count that shows the minimum, because the friction is the
/// feature rather than an obstacle to route around. /// feature rather than an obstacle to route around.
@@ -131,19 +162,19 @@ Widget excuseField(
fillColor: colorWhite, fillColor: colorWhite,
contentPadding: const EdgeInsets.all(14), contentPadding: const EdgeInsets.all(14),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorBorder, width: 1), borderSide: BorderSide(color: colorBorder, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorPrimaryDark, width: 1.4), borderSide: BorderSide(color: colorPrimaryDark, width: 1.4),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorNegative, width: 1), borderSide: BorderSide(color: colorNegative, width: 1),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colorNegative, width: 1.4), borderSide: BorderSide(color: colorNegative, width: 1.4),
), ),
errorStyle: TextStyle( errorStyle: TextStyle(
@@ -180,7 +211,7 @@ Widget selectField(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorWhite, color: colorWhite,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
border: Border.all(color: colorBorder, width: 1), border: Border.all(color: colorBorder, width: 1),
), ),
child: Row( child: Row(

View File

@@ -37,3 +37,34 @@ Widget text(
), ),
); );
} }
/// Copy with **bold** phrases marked inline, for answers that need to stress
/// a number. Regular for the body, Medium for the marked parts.
Widget markedText(
String marked,
double textSize, {
Color? color,
double? height,
}) {
final List<String> parts = marked.split("**");
return Text.rich(
TextSpan(
children: <InlineSpan>[
for (int index = 0; index < parts.length; index++)
TextSpan(
text: parts[index],
style: TextStyle(
fontFamily:
getTextType(index.isOdd ? TextType.Medium : TextType.Regular),
),
),
],
),
style: TextStyle(
color: color ?? colorPrimaryDark,
fontSize: textSize,
height: height,
),
);
}

View File

@@ -14,6 +14,12 @@ import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart'; import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/GroupGoalRequest.dart';
import '../about/external/initial/GroupMessageRequest.dart';
import '../about/external/initial/GroupRequest.dart';
import '../about/external/initial/JoinGroupRequest.dart';
import '../about/external/initial/NudgeRequest.dart';
import '../about/external/initial/ObjectiveProgressRequest.dart';
import '../about/external/initial/IdRequest.dart'; import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart'; import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart'; import '../about/external/initial/ReportCardRequest.dart';
@@ -21,6 +27,9 @@ import '../about/external/initial/SessionLogRequest.dart';
import '../about/external/initial/SickModeRequest.dart'; import '../about/external/initial/SickModeRequest.dart';
import '../about/external/initial/ToneRequest.dart'; import '../about/external/initial/ToneRequest.dart';
import '../about/internal/application/CapacityProfile.dart'; import '../about/internal/application/CapacityProfile.dart';
import '../about/internal/application/ProgressTargets.dart';
import '../about/internal/application/RoutineRunRecord.dart';
import '../about/internal/application/TodoItem.dart';
import '../about/internal/application/MeDescription.dart'; import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/Token.dart'; import '../about/internal/application/Token.dart';
import '../about/internal/application/UserDetails.dart'; import '../about/internal/application/UserDetails.dart';
@@ -119,6 +128,27 @@ class AppDataManager implements DataManager {
@override @override
Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent); Future setAmnestySpent(int spent) => memory.setAmnestySpent(spent);
@override
Future<List<TodoItem>> getTodos() => memory.getTodos();
@override
Future setTodos(List<TodoItem> todos) => memory.setTodos(todos);
@override
Future<List<RoutineRunRecord>> getRunLog() => memory.getRunLog();
@override
Future setRunLog(List<RoutineRunRecord> records) =>
memory.setRunLog(records);
@override
Future<ProgressTargets> getProgressTargets() =>
memory.getProgressTargets();
@override
Future setProgressTargets(ProgressTargets targets) =>
memory.setProgressTargets(targets);
@override @override
Future<bool> showOnboarding() => memory.showOnboarding(); Future<bool> showOnboarding() => memory.showOnboarding();
@@ -363,4 +393,36 @@ class AppDataManager implements DataManager {
@override @override
Future<Response> acknowledgeNudgeEntry(IdRequest request) => Future<Response> acknowledgeNudgeEntry(IdRequest request) =>
comms.acknowledgeNudgeEntry(request); comms.acknowledgeNudgeEntry(request);
// ── Groups ────────────────────────────────────────────────────────────────
@override
Future<Response> getMyGroups(HistoryRequest request) => comms.getMyGroups(request);
@override
Future<Response> getGroupEntry(IdRequest request) => comms.getGroupEntry(request);
@override
Future<Response> saveGroupEntry(GroupRequest request) => comms.saveGroupEntry(request);
@override
Future<Response> joinGroupEntry(JoinGroupRequest request) => comms.joinGroupEntry(request);
@override
Future<Response> leaveGroupEntry(IdRequest request) => comms.leaveGroupEntry(request);
@override
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) => comms.saveGroupGoalEntry(request);
@override
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) => comms.logObjectiveProgress(request);
@override
Future<Response> getGroupMessages(IdRequest request) => comms.getGroupMessages(request);
@override
Future<Response> sendGroupMessage(GroupMessageRequest request) => comms.sendGroupMessage(request);
@override
Future<Response> nudgeGroupMember(NudgeRequest request) => comms.nudgeGroupMember(request);
} }

View File

@@ -3,6 +3,9 @@ import '../about/external/data/Program.dart';
import '../about/external/data/SystemResponse.dart'; import '../about/external/data/SystemResponse.dart';
import '../about/internal/application/CapacityProfile.dart'; import '../about/internal/application/CapacityProfile.dart';
import '../about/internal/application/MeDescription.dart'; import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/ProgressTargets.dart';
import '../about/internal/application/RoutineRunRecord.dart';
import '../about/internal/application/TodoItem.dart';
import '../about/internal/application/Token.dart'; import '../about/internal/application/Token.dart';
import '../about/internal/application/UserDetails.dart'; import '../about/internal/application/UserDetails.dart';
@@ -59,6 +62,21 @@ abstract class ConnectInternalMemory {
Future setAmnestySpent(int spent); Future setAmnestySpent(int spent);
/// The quick to-do list on Home.
Future<List<TodoItem>> getTodos();
Future setTodos(List<TodoItem> todos);
/// Finished routine runs, newest last. Feeds Progress.
Future<List<RoutineRunRecord>> getRunLog();
Future setRunLog(List<RoutineRunRecord> records);
/// Weekly targets and reminder preferences.
Future<ProgressTargets> getProgressTargets();
Future setProgressTargets(ProgressTargets targets);
Future<bool> showOnboarding(); Future<bool> showOnboarding();
Future setOnboardingOption(bool value); Future setOnboardingOption(bool value);

View File

@@ -8,6 +8,9 @@ import '../about/external/data/ResponseState.dart';
import '../about/external/data/SystemResponse.dart'; import '../about/external/data/SystemResponse.dart';
import '../about/internal/application/CapacityProfile.dart'; import '../about/internal/application/CapacityProfile.dart';
import '../about/internal/application/MeDescription.dart'; import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/ProgressTargets.dart';
import '../about/internal/application/RoutineRunRecord.dart';
import '../about/internal/application/TodoItem.dart';
import '../about/internal/application/Token.dart'; import '../about/internal/application/Token.dart';
import '../about/internal/application/UserDetails.dart'; import '../about/internal/application/UserDetails.dart';
import 'ConnectInternalMemory.dart'; import 'ConnectInternalMemory.dart';
@@ -40,6 +43,10 @@ class InternalMemory implements ConnectInternalMemory {
static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B"; static const String AMNESTY_SPENT = "AMNESTY_SPENT_Jr4xVh9KmT3gLc6B";
static const String TODOS = "TODOS_Wm3kRb8TqZ5nHc2F";
static const String RUN_LOG = "RUN_LOG_p8Xq2LbV7nZr4KcT";
static const String PROGRESS_TARGETS = "PROGRESS_TARGETS_Hd5sWm9JqR3vYx6B";
static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M"; static const String SHOW_ONBOARDING = "SHOW_ONBOARDING_Nz6qGf2SdX7wPb4M";
final groundedStorage = const FlutterSecureStorage(); final groundedStorage = const FlutterSecureStorage();
@@ -252,6 +259,64 @@ class InternalMemory implements ConnectInternalMemory {
await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString()); await groundedStorage.write(key: AMNESTY_SPENT, value: spent.toString());
} }
@override
Future<List<TodoItem>> getTodos() async {
String? value = await groundedStorage.read(key: TODOS);
if (value != null) {
return (jsonDecode(value) as List<dynamic>)
.map((item) => TodoItem.fromJson(item as Map<String, dynamic>))
.toList();
}
return <TodoItem>[];
}
@override
Future setTodos(List<TodoItem> todos) async {
await groundedStorage.write(
key: TODOS,
value: jsonEncode(todos.map((item) => item.toJson()).toList()));
}
@override
Future<List<RoutineRunRecord>> getRunLog() async {
String? value = await groundedStorage.read(key: RUN_LOG);
if (value != null) {
return (jsonDecode(value) as List<dynamic>)
.map((item) =>
RoutineRunRecord.fromJson(item as Map<String, dynamic>))
.toList();
}
return <RoutineRunRecord>[];
}
@override
Future setRunLog(List<RoutineRunRecord> records) async {
await groundedStorage.write(
key: RUN_LOG,
value: jsonEncode(records.map((item) => item.toJson()).toList()));
}
@override
Future<ProgressTargets> getProgressTargets() async {
String? value = await groundedStorage.read(key: PROGRESS_TARGETS);
if (value != null) {
return ProgressTargets.fromJson(jsonDecode(value));
}
return ProgressTargets();
}
@override
Future setProgressTargets(ProgressTargets targets) async {
await groundedStorage.write(
key: PROGRESS_TARGETS, value: jsonEncode(targets.toJson()));
}
@override @override
Future<bool> showOnboarding() async { Future<bool> showOnboarding() async {
String? value = await groundedStorage.read(key: SHOW_ONBOARDING); String? value = await groundedStorage.read(key: SHOW_ONBOARDING);

View File

@@ -12,6 +12,12 @@ import '../about/external/initial/CompletionRequest.dart';
import '../about/external/initial/DeferralRequest.dart'; import '../about/external/initial/DeferralRequest.dart';
import '../about/external/initial/DeviceRequest.dart'; import '../about/external/initial/DeviceRequest.dart';
import '../about/external/initial/GoalRequest.dart'; import '../about/external/initial/GoalRequest.dart';
import '../about/external/initial/GroupGoalRequest.dart';
import '../about/external/initial/GroupMessageRequest.dart';
import '../about/external/initial/GroupRequest.dart';
import '../about/external/initial/JoinGroupRequest.dart';
import '../about/external/initial/NudgeRequest.dart';
import '../about/external/initial/ObjectiveProgressRequest.dart';
import '../about/external/initial/IdRequest.dart'; import '../about/external/initial/IdRequest.dart';
import '../about/external/initial/LoginData.dart'; import '../about/external/initial/LoginData.dart';
import '../about/external/initial/ReportCardRequest.dart'; import '../about/external/initial/ReportCardRequest.dart';
@@ -19,6 +25,7 @@ import '../about/external/initial/SessionLogRequest.dart';
import '../about/external/initial/SickModeRequest.dart'; import '../about/external/initial/SickModeRequest.dart';
import '../about/external/initial/ToneRequest.dart'; import '../about/external/initial/ToneRequest.dart';
import '../comms/ConnectComms.dart'; import '../comms/ConnectComms.dart';
import 'MockGroups.dart';
import 'MockStore.dart'; import 'MockStore.dart';
/// A fake backend. Answers every call from [MockStore] after a short delay, /// A fake backend. Answers every call from [MockStore] after a short delay,
@@ -39,7 +46,8 @@ class MockComms implements ConnectComms {
Future<Response> _done() => _ok(<String, dynamic>{'success': true}); Future<Response> _done() => _ok(<String, dynamic>{'success': true});
Future<Response> _emptyPage() => _ok(MockStore.page(<Map<String, dynamic>>[])); Future<Response> _emptyPage() =>
_ok(MockStore.page(<Map<String, dynamic>>[]));
// ── Auth ────────────────────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────────────────────
@@ -65,7 +73,8 @@ class MockComms implements ConnectComms {
Future<Response> logout() => _done(); Future<Response> logout() => _done();
@override @override
Future<Response> me() => _ok(<String, dynamic>{'id': 'mock-user', 'name': 'Alex'}); Future<Response> me() =>
_ok(<String, dynamic>{'id': 'mock-user', 'name': 'Alex'});
// ── Goals ───────────────────────────────────────────────────────────────── // ── Goals ─────────────────────────────────────────────────────────────────
@@ -145,7 +154,8 @@ class MockComms implements ConnectComms {
@override @override
Future<Response> completeCommitmentEntry(CompletionRequest request) { Future<Response> completeCommitmentEntry(CompletionRequest request) {
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId); final Map<String, dynamic>? item =
MockStore.commitment(request.commitmentId);
if (item != null) { if (item != null) {
final DateTime? end = DateTime.tryParse(item['dueEnd'] ?? ''); final DateTime? end = DateTime.tryParse(item['dueEnd'] ?? '');
item['status'] = end != null && end.isBefore(DateTime.now()) item['status'] = end != null && end.isBefore(DateTime.now())
@@ -157,7 +167,8 @@ class MockComms implements ConnectComms {
@override @override
Future<Response> deferCommitmentEntry(DeferralRequest request) { Future<Response> deferCommitmentEntry(DeferralRequest request) {
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId); final Map<String, dynamic>? item =
MockStore.commitment(request.commitmentId);
if (item != null) { if (item != null) {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
item['dueStart'] = request.newDueStart.isNotEmpty item['dueStart'] = request.newDueStart.isNotEmpty
@@ -174,7 +185,8 @@ class MockComms implements ConnectComms {
@override @override
Future<Response> abandonCommitmentEntry(AbandonRequest request) { Future<Response> abandonCommitmentEntry(AbandonRequest request) {
final Map<String, dynamic>? item = MockStore.commitment(request.commitmentId); final Map<String, dynamic>? item =
MockStore.commitment(request.commitmentId);
if (item != null) { if (item != null) {
item['status'] = 'Abandoned'; item['status'] = 'Abandoned';
} }
@@ -264,7 +276,8 @@ class MockComms implements ConnectComms {
Future<Response> updateSickMode(SickModeRequest request) => _done(); Future<Response> updateSickMode(SickModeRequest request) => _done();
@override @override
Future<Response> checkDistress() => _ok(<String, dynamic>{'distressed': false}); Future<Response> checkDistress() =>
_ok(<String, dynamic>{'distressed': false});
@override @override
Future<Response> updateTone(ToneRequest request) => _done(); Future<Response> updateTone(ToneRequest request) => _done();
@@ -284,8 +297,8 @@ class MockComms implements ConnectComms {
Future<Response> getKeystoneHabits() => _ok(<dynamic>[]); Future<Response> getKeystoneHabits() => _ok(<dynamic>[]);
@override @override
Future<Response> getMyRoutines(HistoryRequest request) => _ok( Future<Response> getMyRoutines(HistoryRequest request) =>
MockStore.routines.map((routine) => routine.toJson()).toList()); _ok(MockStore.routines.map((routine) => routine.toJson()).toList());
@override @override
Future<Response> saveRoutineEntry(Map<String, dynamic> request) { Future<Response> saveRoutineEntry(Map<String, dynamic> request) {
@@ -378,13 +391,16 @@ class MockComms implements ConnectComms {
Future<Response> getSessionHistory(HistoryRequest request) => _emptyPage(); Future<Response> getSessionHistory(HistoryRequest request) => _emptyPage();
@override @override
Future<Response> getLastSessionFor(IdRequest request) => _ok(<String, dynamic>{}); Future<Response> getLastSessionFor(IdRequest request) =>
_ok(<String, dynamic>{});
@override @override
Future<Response> getWeeklyVolume(ReportCardRequest request) => _ok(<String, dynamic>{}); Future<Response> getWeeklyVolume(ReportCardRequest request) =>
_ok(<String, dynamic>{});
@override @override
Future<Response> getContactVolume(ReportCardRequest request) => _ok(<String, dynamic>{}); Future<Response> getContactVolume(ReportCardRequest request) =>
_ok(<String, dynamic>{});
@override @override
Future<Response> getPersonalRecords(HistoryRequest request) => _emptyPage(); Future<Response> getPersonalRecords(HistoryRequest request) => _emptyPage();
@@ -392,10 +408,12 @@ class MockComms implements ConnectComms {
// ── Report card ─────────────────────────────────────────────────────────── // ── Report card ───────────────────────────────────────────────────────────
@override @override
Future<Response> getWeeklyReportCard(ReportCardRequest request) => _ok(_report()); Future<Response> getWeeklyReportCard(ReportCardRequest request) =>
_ok(_report());
@override @override
Future<Response> getMonthlyReportCard(ReportCardRequest request) => _ok(_report()); Future<Response> getMonthlyReportCard(ReportCardRequest request) =>
_ok(_report());
Map<String, dynamic> _report() { Map<String, dynamic> _report() {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
@@ -414,13 +432,27 @@ class MockComms implements ConnectComms {
'Health': 0.79, 'Health': 0.79,
}, },
'completionByWeekday': { 'completionByWeekday': {
'1': 0.9, '2': 0.8, '3': 0.75, '4': 0.45, '5': 0.7, '6': 0.6, '7': 0.85, '1': 0.9,
'2': 0.8,
'3': 0.75,
'4': 0.45,
'5': 0.7,
'6': 0.6,
'7': 0.85,
}, },
'worstHour': 19, 'worstHour': 19,
'debtTrend': <double>[8, 9.5, 11, 10.2, 7.4, 6.8, 5.9], 'debtTrend': <double>[8, 9.5, 11, 10.2, 7.4, 6.8, 5.9],
'deferralLeaderboard': <Map<String, dynamic>>[ 'deferralLeaderboard': <Map<String, dynamic>>[
{'commitmentId': 'c-email', 'title': 'Email supervisor the outline', 'count': 2}, {
{'commitmentId': 'c-kitchen', 'title': 'Deep clean the kitchen', 'count': 1}, 'commitmentId': 'c-email',
'title': 'Email supervisor the outline',
'count': 2
},
{
'commitmentId': 'c-kitchen',
'title': 'Deep clean the kitchen',
'count': 1
},
], ],
'excuseTaxonomy': <dynamic>[], 'excuseTaxonomy': <dynamic>[],
'estimationAccuracy': {'Deep work': 1.6, 'Admin': 1.1}, 'estimationAccuracy': {'Deep work': 1.6, 'Admin': 1.1},
@@ -440,4 +472,46 @@ class MockComms implements ConnectComms {
@override @override
Future<Response> acknowledgeNudgeEntry(IdRequest request) => _done(); Future<Response> acknowledgeNudgeEntry(IdRequest request) => _done();
// ── Groups ────────────────────────────────────────────────────────────────
@override
Future<Response> getMyGroups(HistoryRequest request) =>
_ok(MockGroups.mine());
@override
Future<Response> getGroupEntry(IdRequest request) =>
_ok(MockGroups.one(request.id));
@override
Future<Response> saveGroupEntry(GroupRequest request) =>
_ok(MockGroups.save(request));
@override
Future<Response> joinGroupEntry(JoinGroupRequest request) =>
_ok(MockGroups.join(request));
@override
Future<Response> leaveGroupEntry(IdRequest request) =>
_ok(MockGroups.leave(request.id));
@override
Future<Response> saveGroupGoalEntry(GroupGoalRequest request) =>
_ok(MockGroups.saveGoal(request));
@override
Future<Response> logObjectiveProgress(ObjectiveProgressRequest request) =>
_ok(MockGroups.logProgress(request));
@override
Future<Response> getGroupMessages(IdRequest request) =>
_ok(MockGroups.messages(request.id));
@override
Future<Response> sendGroupMessage(GroupMessageRequest request) =>
_ok(MockGroups.send(request));
@override
Future<Response> nudgeGroupMember(NudgeRequest request) =>
_ok(MockGroups.nudge(request));
} }

View File

@@ -0,0 +1,500 @@
// MOCK DATA — TEMPORARY. See MockSwitch.dart.
import 'dart:math';
import '../about/external/initial/GroupGoalRequest.dart';
import '../about/external/initial/GroupMessageRequest.dart';
import '../about/external/initial/GroupRequest.dart';
import '../about/external/initial/JoinGroupRequest.dart';
import '../about/external/initial/NudgeRequest.dart';
import '../about/external/initial/ObjectiveProgressRequest.dart';
import '../utils/Colors.dart';
import '../utils/CommonUtils.dart';
import 'MockStore.dart';
/// The fake backend's groups: a couple already joined, one waiting behind an
/// invite code, and members who answer in the chat a moment after you post.
class MockGroups {
static const String _me = "mock-user";
static const String _myName = "Alex";
static bool _seeded = false;
static final List<Map<String, dynamic>> _groups = <Map<String, dynamic>>[];
/// Groups that exist but you are not in yet, keyed by invite code.
static final Map<String, Map<String, dynamic>> _joinable =
<String, Map<String, dynamic>>{};
static final Map<String, List<Map<String, dynamic>>> _messages =
<String, List<Map<String, dynamic>>>{};
/// Replies that "arrive" once their time has passed.
static final List<Map<String, dynamic>> _pending = <Map<String, dynamic>>[];
static final Random _random = Random(7);
/// Sample people with a bundled portrait (assets/images/person_*.jpg).
static const List<String> _photos = <String>[
'alex',
'sam',
'priya',
'jordan',
'mei',
'tom',
'ade',
'lena'
];
static Map<String, dynamic> _member(String id, String name,
{String role = "Member",
double kept = 0.7,
int streak = 2,
String standing = "Good",
int checkedInHoursAgo = 5}) {
return <String, dynamic>{
'id': id,
'name': name,
'photo': _photos.contains(name.toLowerCase())
? 'asset:assets/images/person_${name.toLowerCase()}.jpg'
: '',
'role': role,
'keptRate': kept,
'streak': streak,
'standing': standing,
'lastCheckIn': DateTime.now()
.subtract(Duration(hours: checkedInHoursAgo))
.toIso8601String(),
'me': id == _me,
};
}
static Map<String, dynamic> _objective(String id, String title, String unit,
double target, double current, double mine) {
return <String, dynamic>{
'id': id,
'title': title,
'unit': unit,
'target': target,
'current': current,
'mine': mine,
};
}
static Map<String, dynamic> _message(String groupId, String authorId,
String authorName, String body, int minutesAgo,
{String kind = "Text"}) {
return <String, dynamic>{
'id': MockStore.newId('msg'),
'groupId': groupId,
'authorId': authorId,
'authorName': authorName,
'body': body,
'kind': kind,
'sentAt': DateTime.now()
.subtract(Duration(minutes: minutesAgo))
.toIso8601String(),
'mine': authorId == _me,
};
}
static void _ensureSeeded() {
if (_seeded) {
return;
}
_seeded = true;
final DateTime now = DateTime.now();
_groups.addAll(<Map<String, dynamic>>[
<String, dynamic>{
'id': 'group-run',
'name': 'Morning Crew',
'description': 'Out the door before 7, five days a week.',
'colourHex': colorToHex(goalAccents[1]),
'inviteCode': 'MRN7AM',
'unread': 3,
'members': <Map<String, dynamic>>[
_member('m-sam', 'Sam',
role: 'Owner', kept: 0.92, streak: 11, checkedInHoursAgo: 2),
_member(_me, _myName, kept: 0.78, streak: 4, checkedInHoursAgo: 4),
_member('m-priya', 'Priya',
kept: 0.85, streak: 7, checkedInHoursAgo: 3),
_member('m-jordan', 'Jordan',
kept: 0.41, streak: 0, standing: 'Warned', checkedInHoursAgo: 52),
],
'goals': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gg-run',
'title': 'Run 200 km this month',
'description': 'Every kilometre counts, treadmill included.',
'targetDate':
DateTime(now.year, now.month + 1, 0).toIso8601String(),
'colourHex': colorToHex(goalAccents[1]),
'objectives': <Map<String, dynamic>>[
_objective('ob-km', 'Distance run', 'km', 200, 86, 22),
_objective('ob-sessions', 'Runs logged', 'runs', 40, 17, 5),
],
},
<String, dynamic>{
'id': 'gg-sleep',
'title': 'In bed before 23:00',
'description': 'Mornings start the night before.',
'targetDate': '',
'colourHex': colorToHex(goalAccents[4]),
'objectives': <Map<String, dynamic>>[
_objective('ob-nights', 'Early nights', 'nights', 60, 31, 9),
],
},
],
},
<String, dynamic>{
'id': 'group-thesis',
'name': 'Thesis Sprint',
'description': 'Drafts in by the 15th. No heroics, just pages.',
'colourHex': colorToHex(goalAccents[3]),
'inviteCode': 'THS15D',
'unread': 0,
'members': <Map<String, dynamic>>[
_member(_me, _myName,
role: 'Owner', kept: 0.78, streak: 4, checkedInHoursAgo: 4),
_member('m-mei', 'Mei', kept: 0.88, streak: 9, checkedInHoursAgo: 6),
_member('m-tom', 'Tom', kept: 0.63, streak: 1, checkedInHoursAgo: 20),
],
'goals': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gg-drafts',
'title': 'Full drafts submitted',
'description': 'Three theses, one deadline.',
'targetDate':
DateTime(now.year, now.month + 1, 15).toIso8601String(),
'colourHex': colorToHex(goalAccents[3]),
'objectives': <Map<String, dynamic>>[
_objective(
'ob-words', 'Words written', 'words', 30000, 18400, 7200),
_objective(
'ob-chapters', 'Chapters reviewed', 'chapters', 9, 3, 1),
_objective('ob-hours', 'Deep-work hours', 'hours', 120, 64, 23),
],
},
],
},
]);
_joinable['WLK2GO'] = <String, dynamic>{
'id': 'group-walk',
'name': 'Weekend Walkers',
'description': 'Ten thousand steps, Saturday and Sunday, no excuses.',
'colourHex': colorToHex(goalAccents[5]),
'inviteCode': 'WLK2GO',
'unread': 1,
'members': <Map<String, dynamic>>[
_member('m-ade', 'Ade', role: 'Owner', kept: 0.81, streak: 6),
_member('m-lena', 'Lena', kept: 0.74, streak: 3),
],
'goals': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gg-steps',
'title': '1 million steps together',
'description': '',
'targetDate': '',
'colourHex': colorToHex(goalAccents[5]),
'objectives': <Map<String, dynamic>>[
_objective('ob-steps', 'Steps', 'steps', 1000000, 412000, 0),
],
},
],
};
_messages['group-run'] = <Map<String, dynamic>>[
_message('group-run', 'm-sam', 'Sam',
'Same route tomorrow? Gate at 6:15.', 60 * 20),
_message('group-run', _me, _myName,
"I'm in. Bringing Priya's spare torch.", 60 * 19 + 40),
_message('group-run', 'm-priya', 'Priya', 'logged 6 km on Distance run',
60 * 3,
kind: 'CheckIn'),
_message(
'group-run', 'm-sam', 'Sam', 'logged 8 km on Distance run', 60 * 2,
kind: 'CheckIn'),
_message('group-run', 'm-sam', 'Sam',
'nudged Jordan: two days without a run, we miss you out there', 95,
kind: 'Nudge'),
_message('group-run', 'm-priya', 'Priya',
'86 km down. We need about 9 a day to make it, doable.', 40),
];
_messages['group-thesis'] = <Map<String, dynamic>>[
_message('group-thesis', _me, _myName, 'started the group', 60 * 24 * 6,
kind: 'System'),
_message('group-thesis', 'm-mei', 'Mei',
'Chapter 3 is with my supervisor. Terrified.', 60 * 26),
_message('group-thesis', _me, _myName,
'logged 1,200 words on Words written', 60 * 22,
kind: 'CheckIn'),
_message(
'group-thesis',
'm-tom',
'Tom',
'Library 9 to 1 tomorrow if anyone wants a co-working block.',
60 * 7),
];
_messages['group-walk'] = <Map<String, dynamic>>[
_message('group-walk', 'm-ade', 'Ade',
'Welcome! Saturday loop starts at the bridge, 9:00.', 60 * 30),
];
}
static Map<String, dynamic>? _find(String id) {
for (Map<String, dynamic> group in _groups) {
if (group['id'] == id) {
return group;
}
}
return null;
}
/// Moves any replies whose moment has come into their threads.
static void _deliver() {
final DateTime now = DateTime.now();
final List<Map<String, dynamic>> due = _pending
.where((item) => DateTime.parse(item['sentAt']).isBefore(now))
.toList();
for (Map<String, dynamic> message in due) {
_pending.remove(message);
_messages
.putIfAbsent(message['groupId'], () => <Map<String, dynamic>>[])
.add(message);
final Map<String, dynamic>? group = _find(message['groupId']);
if (group != null) {
group['unread'] = (group['unread'] ?? 0) + 1;
}
}
}
static Map<String, dynamic> _withLast(Map<String, dynamic> group) {
final List<Map<String, dynamic>> thread =
_messages[group['id']] ?? <Map<String, dynamic>>[];
final Map<String, dynamic> copy = Map<String, dynamic>.from(group);
if (thread.isNotEmpty) {
final Map<String, dynamic> last = thread.last;
copy['lastMessage'] = last['body'];
copy['lastAuthor'] = last['authorName'];
copy['lastMessageAt'] = last['sentAt'];
}
return copy;
}
static void _post(String groupId, String body, String kind) {
_messages
.putIfAbsent(groupId, () => <Map<String, dynamic>>[])
.add(_message(groupId, _me, _myName, body, 0, kind: kind));
}
/// Someone answers a few seconds later, so the chat feels inhabited.
static void _queueReply(String groupId) {
final Map<String, dynamic>? group = _find(groupId);
if (group == null) {
return;
}
final List<dynamic> others = (group['members'] as List)
.where((member) => member['id'] != _me)
.toList();
if (others.isEmpty) {
return;
}
const List<String> lines = <String>[
"Love it. Keep that going.",
"Same here, just finished mine.",
"That's the spirit.",
"Noted. I'll match you tomorrow.",
"Good — that puts us back on pace.",
"Who's in for the next one?",
];
final Map<String, dynamic> author = others[_random.nextInt(others.length)];
final Map<String, dynamic> reply = _message(groupId, author['id'],
author['name'], lines[_random.nextInt(lines.length)], 0);
reply['sentAt'] =
DateTime.now().add(const Duration(seconds: 3)).toIso8601String();
_pending.add(reply);
}
static String _code() {
const String letters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return List<String>.generate(
6, (index) => letters[_random.nextInt(letters.length)]).join();
}
// ── Endpoints ─────────────────────────────────────────────────────────────
static List<Map<String, dynamic>> mine() {
_ensureSeeded();
_deliver();
return _groups.map(_withLast).toList();
}
static Map<String, dynamic> one(String id) {
_ensureSeeded();
_deliver();
return _withLast(_find(id) ?? <String, dynamic>{});
}
static Map<String, dynamic> save(GroupRequest request) {
_ensureSeeded();
final Map<String, dynamic>? existing =
request.id == null ? null : _find(request.id!);
if (existing != null) {
existing['name'] = request.name;
existing['description'] = request.description;
existing['colourHex'] = request.colourHex;
return _withLast(existing);
}
final String id = MockStore.newId('group');
final Map<String, dynamic> group = <String, dynamic>{
'id': id,
'name': request.name,
'description': request.description,
'colourHex': request.colourHex,
'inviteCode': _code(),
'unread': 0,
'members': <Map<String, dynamic>>[
_member(_me, _myName,
role: 'Owner', kept: 0.78, streak: 4, checkedInHoursAgo: 0),
],
'goals': <Map<String, dynamic>>[],
};
_groups.insert(0, group);
_post(id, 'started the group', 'System');
return _withLast(group);
}
static Map<String, dynamic> join(JoinGroupRequest request) {
_ensureSeeded();
final String code = request.inviteCode.trim().toUpperCase();
for (Map<String, dynamic> group in _groups) {
if (group['inviteCode'] == code) {
return _withLast(group);
}
}
// Any well-formed code finds the waiting group, so the preview always
// has something to join.
final Map<String, dynamic> group = _joinable.remove(code) ??
(_joinable.isNotEmpty
? _joinable.remove(_joinable.keys.first)!
: <String, dynamic>{
'id': MockStore.newId('group'),
'name': 'Group $code',
'description': '',
'colourHex': colorToHex(goalAccents[0]),
'inviteCode': code,
'unread': 0,
'members': <Map<String, dynamic>>[],
'goals': <Map<String, dynamic>>[],
});
(group['members'] as List).add(
_member(_me, _myName, kept: 0.78, streak: 4, checkedInHoursAgo: 0));
_groups.insert(0, group);
_post(group['id'], 'joined the group', 'System');
_queueReply(group['id']);
return _withLast(group);
}
static Map<String, dynamic> leave(String id) {
_ensureSeeded();
_groups.removeWhere((group) => group['id'] == id);
return <String, dynamic>{'success': true};
}
static Map<String, dynamic> saveGoal(GroupGoalRequest request) {
_ensureSeeded();
final Map<String, dynamic>? group = _find(request.groupId);
if (group == null) {
return <String, dynamic>{};
}
final Map<String, dynamic> goal = <String, dynamic>{
'id': MockStore.newId('gg'),
'title': request.title,
'description': request.description,
'targetDate': request.targetDate,
'colourHex': request.colourHex,
'objectives': request.objectives
.map((draft) => _objective(MockStore.newId('ob'), draft.title,
draft.unit, draft.target, 0, 0))
.toList(),
};
(group['goals'] as List).add(goal);
_post(group['id'], 'added a shared goal: ${request.title}', 'System');
return _withLast(group);
}
static Map<String, dynamic> logProgress(ObjectiveProgressRequest request) {
_ensureSeeded();
final Map<String, dynamic>? group = _find(request.groupId);
if (group == null) {
return <String, dynamic>{};
}
for (Map<String, dynamic> goal in (group['goals'] as List)) {
if (goal['id'] != request.goalId) {
continue;
}
for (Map<String, dynamic> objective in (goal['objectives'] as List)) {
if (objective['id'] != request.objectiveId) {
continue;
}
objective['current'] = (objective['current'] as num) + request.amount;
objective['mine'] = (objective['mine'] as num) + request.amount;
final String amount = formatAmount(request.amount);
_post(
group['id'],
'logged $amount ${objective['unit']} on ${objective['title']}'
'${request.note.isEmpty ? "" : " — ${request.note}"}',
'CheckIn');
_queueReply(group['id']);
}
}
for (Map<String, dynamic> member in (group['members'] as List)) {
if (member['id'] == _me) {
member['lastCheckIn'] = DateTime.now().toIso8601String();
}
}
return _withLast(group);
}
static List<Map<String, dynamic>> messages(String groupId) {
_ensureSeeded();
_deliver();
final Map<String, dynamic>? group = _find(groupId);
if (group != null) {
group['unread'] = 0;
}
return List<Map<String, dynamic>>.from(
_messages[groupId] ?? <Map<String, dynamic>>[]);
}
static Map<String, dynamic> send(GroupMessageRequest request) {
_ensureSeeded();
_post(request.groupId, request.body, request.kind);
_queueReply(request.groupId);
return _messages[request.groupId]!.last;
}
static Map<String, dynamic> nudge(NudgeRequest request) {
_ensureSeeded();
final Map<String, dynamic>? group = _find(request.groupId);
if (group == null) {
return <String, dynamic>{'success': false};
}
String who = "everyone";
for (Map<String, dynamic> member in (group['members'] as List)) {
if (member['id'] == request.memberId) {
who = member['name'];
}
}
_post(
request.groupId, 'nudged $who: today still counts, go get it', 'Nudge');
return <String, dynamic>{'success': true};
}
}

View File

@@ -1,8 +1,12 @@
// MOCK DATA — TEMPORARY. See MockSwitch.dart. // MOCK DATA — TEMPORARY. See MockSwitch.dart.
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../about/internal/application/MeDescription.dart'; import '../about/internal/application/MeDescription.dart';
import '../about/internal/application/RoutineRunRecord.dart';
import '../about/internal/application/TodoItem.dart';
import '../about/internal/application/UserDetails.dart'; import '../about/internal/application/UserDetails.dart';
import '../memory/InternalMemory.dart'; import '../memory/InternalMemory.dart';
import 'MockStorage.dart'; import 'MockStorage.dart';
@@ -17,10 +21,87 @@ class MockMemory extends InternalMemory {
_seeded = true; _seeded = true;
setMyDescription( setMyDescription(
MeDescription(id: "mock-user", name: "Alex", token: "mock-token")); MeDescription(id: "mock-user", name: "Alex", token: "mock-token"));
setUserDetails(UserDetails(pic: "", name: "Alex")); setUserDetails(UserDetails(
pic: "asset:assets/images/person_alex.jpg", name: "Alex"));
setTodos(<TodoItem>[
TodoItem(id: "t1", title: "Pick up gym kit"),
TodoItem(id: "t2", title: "Book physio for Thursday"),
TodoItem(id: "t3", title: "Reply to Sam about Saturday", done: true),
]);
setRunLog(_history());
} }
} }
/// Six weeks of runs against the seeded routines, mostly held, with the
/// odd broken chain and skipped day, so Progress has a shape to draw.
static List<RoutineRunRecord> _history() {
final Random random = Random(11);
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final List<RoutineRunRecord> records = <RoutineRunRecord>[];
void add(DateTime day, String id, String title, String kind, int minute,
int seconds, int steps, double holdRate) {
final DateTime at = day.add(Duration(minutes: minute));
if (at.isAfter(now)) {
return;
}
final bool held = random.nextDouble() < holdRate;
records.add(RoutineRunRecord(
routineId: id,
title: title,
kind: kind,
startedAt: at,
seconds:
(seconds * (held ? 1 : 0.5) * (0.85 + random.nextDouble() * 0.3))
.round(),
completedSteps: held ? steps : 1 + random.nextInt(steps - 1),
totalSteps: steps,
));
}
for (int ago = 42; ago >= 1; ago--) {
final DateTime day = today.subtract(Duration(days: ago));
final int weekday = day.weekday;
// A quiet day now and then: nothing run at all.
if (random.nextDouble() < 0.12) {
continue;
}
if (weekday <= 5) {
add(day, "routine-morning", "Morning non-negotiables", "MustDo",
7 * 60 + 15, 14 * 60, 4, 0.85);
add(day, "routine-shutdown", "Evening shutdown", "MustDo", 18 * 60,
9 * 60, 4, 0.6);
}
if (weekday == 1 || weekday == 3 || weekday == 5) {
add(day, "routine-strength", "Full body strength", "Workout",
17 * 60 + 30, 44 * 60, 6, 0.75);
}
if (weekday >= 6) {
add(day, "routine-mobility", "Mobility flow", "Workout", 10 * 60,
22 * 60, 5, 0.8);
}
if (random.nextDouble() < 0.35) {
add(day, "routine-breathing", "Box breathing", "Meditation", 21 * 60,
8 * 60, 3, 0.95);
}
}
// Today: the morning chain is already held in the seeded routines.
records.add(RoutineRunRecord(
routineId: "routine-morning",
title: "Morning non-negotiables",
kind: "MustDo",
startedAt: today.add(const Duration(minutes: 7 * 60 + 10)),
seconds: 13 * 60 + 40,
completedSteps: 4,
totalSteps: 4,
));
return records;
}
@override @override
FlutterSecureStorage get groundedStorage => const MockStorage(); FlutterSecureStorage get groundedStorage => const MockStorage();
} }

View File

@@ -43,7 +43,8 @@ class MockStore {
{ {
'id': 'goal-strong', 'id': 'goal-strong',
'title': 'Get strong', 'title': 'Get strong',
'description': 'Deadlift my bodyweight by spring, without getting hurt.', 'description':
'Deadlift my bodyweight by spring, without getting hurt.',
'category': 'Health', 'category': 'Health',
'defaultClass': 'Standard', 'defaultClass': 'Standard',
'startDate': at(-30, 0).toIso8601String(), 'startDate': at(-30, 0).toIso8601String(),
@@ -145,10 +146,7 @@ class MockStore {
minutes: 60, minutes: 60,
category: 'Deep work'), category: 'Deep work'),
task('c-meal', 'Meal prep for the week', task('c-meal', 'Meal prep for the week',
goal: 'goal-strong', goal: 'goal-strong', start: at(1, 17), end: at(1, 20), minutes: 90),
start: at(1, 17),
end: at(1, 20),
minutes: 90),
// Two things already past their window, so the overdue queue and debt // Two things already past their window, so the overdue queue and debt
// have something to show. // have something to show.
task('c-email', 'Email supervisor the outline', task('c-email', 'Email supervisor the outline',
@@ -159,10 +157,7 @@ class MockStore {
status: 'Overdue', status: 'Overdue',
deferrals: 1), deferrals: 1),
task('c-kitchen', 'Deep clean the kitchen', task('c-kitchen', 'Deep clean the kitchen',
start: at(-1, 18), start: at(-1, 18), end: at(-1, 21), minutes: 45, status: 'Overdue'),
end: at(-1, 21),
minutes: 45,
status: 'Overdue'),
]); ]);
// ── Routines ─────────────────────────────────────────────────────────── // ── Routines ───────────────────────────────────────────────────────────
@@ -175,7 +170,10 @@ class MockStore {
final RoutineChain push = RoutineTemplates.upperPush() final RoutineChain push = RoutineTemplates.upperPush()
..id = 'routine-push' ..id = 'routine-push'
..goalId = 'goal-strong' ..goalId = 'goal-strong'
..days = <int>[1, 2, 3, 4, 5, 6, 7].where((day) => day != weekday).take(2).toList(); ..days = <int>[1, 2, 3, 4, 5, 6, 7]
.where((day) => day != weekday)
.take(2)
.toList();
final RoutineChain breathing = RoutineTemplates.boxBreathing() final RoutineChain breathing = RoutineTemplates.boxBreathing()
..id = 'routine-breathing' ..id = 'routine-breathing'
@@ -262,12 +260,14 @@ class MockStore {
final List<Map<String, dynamic>> tasks = goalTasks(goal['id']); final List<Map<String, dynamic>> tasks = goalTasks(goal['id']);
final int completed = tasks final int completed = tasks
.where((item) => .where((item) =>
item['status'] == 'Completed' || item['status'] == 'LateCompleted') item['status'] == 'Completed' ||
item['status'] == 'LateCompleted')
.length; .length;
final int late = final int late =
tasks.where((item) => !_settled(item) && _windowClosed(item)).length; tasks.where((item) => !_settled(item) && _windowClosed(item)).length;
return Map<String, dynamic>.from(goal) return Map<String, dynamic>.from(goal)
..['totalTasks'] = tasks.where((item) => item['status'] != 'Abandoned').length ..['totalTasks'] =
tasks.where((item) => item['status'] != 'Abandoned').length
..['completedTasks'] = completed ..['completedTasks'] = completed
..['overdueTasks'] = late ..['overdueTasks'] = late
..['debtContribution'] = late * 2.0; ..['debtContribution'] = late * 2.0;

View File

@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'CoachState.dart';
class Coach extends StatefulWidget {
/// Shown as a tab inside Home: no back arrow, and room at the bottom
/// for the nav.
final bool tab;
const Coach({super.key, this.tab = false});
@override
State<Coach> createState() => CoachState();
}

View File

@@ -0,0 +1,562 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/internal/application/CoachAction.dart';
import '../../about/internal/application/CoachActionKind.dart';
import '../../about/internal/application/CoachChart.dart';
import '../../about/internal/application/CoachMessage.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/DashedOutline.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/Responsive.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/CoachEngine.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../overdue/OverdueQueue.dart';
import '../progress/Progress.dart';
import '../progress/TargetSetup.dart';
import '../routine/RoutineRunner.dart';
import 'Coach.dart';
import 'ConnectCoach.dart';
import 'ViewCoach.dart';
class CoachState extends State<Coach> implements ConnectCoach {
ViewCoach? _model;
final List<CoachMessage> _thread = <CoachMessage>[];
final TextEditingController _input = TextEditingController();
final FocusNode _focus = FocusNode();
final ScrollController _scroll = ScrollController();
bool _thinking = false;
static const double _barHeight = 96;
@override
void dispose() {
_input.dispose();
_focus.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewCoach>.reactive(
viewModelBuilder: () => ViewCoach(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context);
}
void _onNewChat() {
setState(() {
_thread.clear();
_thinking = false;
});
}
void _onSend() {
_send(_input.text);
}
void _onSubmitted(String value) {
_send(value);
}
void _send(String question) {
final String trimmed = question.trim();
if (trimmed.isEmpty || _thinking) {
return;
}
_input.clear();
setState(() {
_thread.add(ViewCoach.question(trimmed));
_thinking = true;
});
_toBottom();
_model?.ask(trimmed);
}
void _onAction(CoachAction action) async {
switch (action.kind) {
case CoachActionKind.Ask:
_send(action.question);
return;
case CoachActionKind.OpenOverdue:
await GroundedNavigation()
.navigateToPageWithData(const OverdueQueue(), context);
return;
case CoachActionKind.OpenTargets:
await GroundedNavigation()
.navigateToPageWithData(const TargetSetup(), context);
return;
case CoachActionKind.OpenProgress:
await GroundedNavigation()
.navigateToPageWithData(const Progress(), context);
return;
case CoachActionKind.StartRoutine:
final RoutineChain? routine =
await _model?.findRoutine(action.routineId);
if (routine == null || !mounted) {
return;
}
await GroundedNavigation().navigateToPageWithData(
RoutineRunner(routine: routine.copy()), context);
return;
}
}
void _toBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) {
return;
}
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutCubic,
);
});
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Scaffold(
backgroundColor: colorAmbientMid,
resizeToAvoidBottomInset: true,
body: AmbientBackground(
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(),
Expanded(
child: _thread.isEmpty ? _intro() : _threadView(),
),
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, widget.tab ? 104 : 20),
child: _composer(),
),
],
),
),
),
);
}
Widget _header() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 16, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!widget.tab) ...[
glassCircle(CupertinoIcons.arrow_left, _onBack, size: 46),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text("Coach", 32, TextType.Medium,
color: colorInk, letterSpacing: -0.6),
const SizedBox(height: 2),
text("Answers from your own data", 15, TextType.Regular,
color: colorInkSoft),
],
),
),
Tooltip(
message: "New chat",
child: glassCircle(
CupertinoIcons.arrow_counterclockwise, _onNewChat,
size: 50),
),
],
),
);
}
/// Before the first question: what the coach can answer, as one-tap asks.
Widget _intro() {
return ListView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
children: [
text(
"Ask about your routines, streak, time or debt. Every answer is worked out from what you've logged — nothing is guessed.",
15,
TextType.Regular,
color: colorInkSoft,
height: 1.5,
),
const SizedBox(height: 22),
for (int index = 0; index < CoachEngine.suggestions.length; index++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _suggestion(CoachEngine.suggestions[index]),
).animate().fadeIn(duration: 300.ms, delay: (60 * index).ms).slideY(
begin: 0.15, end: 0, duration: 300.ms, delay: (60 * index).ms),
],
);
}
Widget _suggestion(String question) {
return GestureDetector(
onTap: () => _send(question),
child: Container(
padding: const EdgeInsets.fromLTRB(18, 16, 14, 16),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: colorGlassBorder),
),
child: Row(
children: [
Icon(CupertinoIcons.sparkles, size: 18, color: colorAccent),
const SizedBox(width: 12),
Expanded(
child: text(question, 15, TextType.Medium, color: colorInk),
),
Icon(CupertinoIcons.arrow_up_right, size: 16, color: colorInkSoft),
],
),
),
);
}
Widget _threadView() {
return ListView.builder(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: _thread.length + 1 + (_thinking ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0) {
return _stamp(_thread.first.at);
}
if (index - 1 < _thread.length) {
final CoachMessage message = _thread[index - 1];
return (message.mine ? _question(message) : _answer(message))
.animate()
.fadeIn(duration: 320.ms)
.slideY(
begin: 0.12,
end: 0,
duration: 380.ms,
curve: Curves.easeOutCubic);
}
return _typing();
},
);
}
Widget _stamp(DateTime at) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: text("Today ${formatStartMinute(at.hour * 60 + at.minute)}", 13,
TextType.Regular,
color: colorInkSoft),
),
);
}
Widget _question(CoachMessage message) {
return Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(left: 60, bottom: 14),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 13),
decoration: const BoxDecoration(
color: colorNight,
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: text(message.body, 15, TextType.Medium, color: colorWhite),
),
);
}
Widget _answer(CoachMessage message) {
return Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(18)),
border: Border.all(color: colorGlassBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 24,
offset: Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
markedText(message.body, 15, color: colorInk, height: 1.5),
if (message.chart != null) ...[
const SizedBox(height: 16),
_chart(message.chart!),
],
if (message.followUp.isNotEmpty) ...[
const SizedBox(height: 16),
markedText(message.followUp, 15, color: colorInk, height: 1.5),
],
if (message.actions.isNotEmpty) ...[
const SizedBox(height: 18),
Wrap(
spacing: 8,
runSpacing: 8,
children: message.actions.map(_actionButton).toList(),
),
],
],
),
);
}
Widget _actionButton(CoachAction action) {
return GestureDetector(
onTap: () => _onAction(action),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
decoration: BoxDecoration(
color: action.primary ? colorAccent : colorWhite,
borderRadius: const BorderRadius.all(Radius.circular(14)),
border: Border.all(color: action.primary ? colorAccent : colorBorder),
),
child: text(action.label, 14, TextType.Medium,
color: action.primary ? colorWhite : colorInk),
),
);
}
/// Bars against dashed target outlines. Days met in solid accent, days
/// short in a soft tint, the day still running as an outline only.
Widget _chart(CoachChart chart) {
double peak = 1;
for (int index = 0; index < chart.values.length; index++) {
peak = [peak, chart.values[index], chart.targets[index]]
.reduce((a, b) => a > b ? a : b);
}
return Container(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.6),
borderRadius: const BorderRadius.all(Radius.circular(14)),
border: Border.all(color: colorGlassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(chart.title, 12, TextType.Regular, color: colorInkSoft),
const SizedBox(height: 12),
SizedBox(
height: _barHeight + 50,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List<Widget>.generate(chart.values.length, (index) {
return Expanded(child: _bar(chart, index, peak));
}),
),
),
],
),
);
}
Widget _bar(CoachChart chart, int index, double peak) {
final double value = chart.values[index];
final double target = chart.targets[index];
final bool running = index > chart.lastComplete;
final bool met = target <= 0 || value >= target;
final double targetHeight = _barHeight * (target / peak);
final double valueHeight = _barHeight * (value / peak);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (index == chart.highlight && chart.callout.isNotEmpty)
Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: colorWhite,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: colorBorder),
),
child: text(chart.callout, 10, TextType.Medium, color: colorInk),
).animate().fadeIn(duration: 300.ms, delay: 500.ms),
SizedBox(
height: (targetHeight > valueHeight ? targetHeight : valueHeight)
.clamp(8, _barHeight),
child: Stack(
alignment: Alignment.bottomCenter,
children: [
if (target > 0)
Positioned.fill(
top: null,
child: SizedBox(
height: targetHeight.clamp(8, _barHeight),
child: CustomPaint(
painter: DashedOutline(
color: colorAccent.withValues(alpha: 0.55)),
),
),
),
if (!running)
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: valueHeight),
duration: Duration(milliseconds: 600 + index * 60),
curve: Curves.easeOutCubic,
builder: (context, height, child) => Container(
height: height,
decoration: BoxDecoration(
color: met ? colorAccent : colorAccentSoft,
borderRadius:
const BorderRadius.all(Radius.circular(10)),
),
),
),
],
),
),
const SizedBox(height: 8),
text(chart.labels[index], 11, TextType.Regular,
color: colorInkSoft, maxLines: 1),
],
),
);
}
Widget _typing() {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(18)),
border: Border.all(color: colorGlassBorder),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List<Widget>.generate(3, (index) {
return Container(
width: 7,
height: 7,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: const BoxDecoration(
color: colorInkSoft,
shape: BoxShape.circle,
),
)
.animate(onPlay: (controller) => controller.repeat())
.fadeOut(duration: 450.ms, delay: (150 * index).ms)
.then()
.fadeIn(duration: 450.ms);
}),
),
),
);
}
/// The ask bar, as in the reference: a frosted field with the send button
/// as a solid accent disc.
Widget _composer() {
return Container(
padding: const EdgeInsets.fromLTRB(18, 6, 6, 6),
decoration: BoxDecoration(
color: colorGlassStrong,
borderRadius: const BorderRadius.all(Radius.circular(20)),
border: Border.all(color: colorGlassBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 20,
offset: Offset(0, 6),
),
],
),
child: Row(
children: [
Expanded(
child: quickAddField(
_input,
"Ask about routines, streaks, debt",
_onSubmitted,
focusNode: _focus,
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _onSend,
child: Container(
width: 50,
height: 50,
decoration: const BoxDecoration(
color: colorAccent,
shape: BoxShape.circle,
),
child: Icon(CupertinoIcons.arrow_up, size: 22, color: colorWhite),
),
),
],
),
);
}
// ── ConnectCoach ──────────────────────────────────────────────────────────
@override
void onAnswered(CoachMessage answer) {
if (!mounted) {
return;
}
setState(() {
_thinking = false;
_thread.add(answer);
});
_toBottom();
}
}

View File

@@ -0,0 +1,5 @@
import '../../about/internal/application/CoachMessage.dart';
abstract class ConnectCoach {
void onAnswered(CoachMessage answer);
}

View File

@@ -0,0 +1,107 @@
import '../../about/external/data/Commitment.dart';
import '../../about/external/data/RoutineChain.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/HistoryRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart';
import '../../about/external/data/pages/response/CommitmentPage.dart';
import '../../about/internal/application/CoachContext.dart';
import '../../about/internal/application/CoachMessage.dart';
import '../../utils/CoachEngine.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectCoach.dart';
class ViewCoach extends ParentViewModel {
ConnectCoach connection;
ViewCoach(super.context, this.connection);
/// The coach reads fresh data for every question, so an answer never
/// lags behind a routine just logged. Network parts fail quietly: the run
/// log on the device still answers most questions.
void ask(String question) async {
final CoachContext data = await _gather();
// A short beat so the answer reads as considered, not instant.
await Future<void>.delayed(const Duration(milliseconds: 700));
connection.onAnswered(CoachEngine.answer(question, data, DateTime.now()));
}
Future<CoachContext> _gather() async {
final PageAndSort page = PageAndSort(
sort: Sort('asc', 'startMinute'),
page: Pageable(0, 0, 50, 0),
);
List<RoutineChain> routines = <RoutineChain>[];
try {
final response =
await getDataManager().getMyRoutines(HistoryRequest(query: page));
routines = getRoutineChainList(response.data);
} catch (e) {
routines = <RoutineChain>[];
}
List<Commitment> plan = <Commitment>[];
try {
final response = await getDataManager().getTodayPlan(CommitmentsRequest(
query: PageAndSort(
sort: Sort('asc', 'dueStart'),
page: Pageable(0, 0, 50, 0),
),
));
plan = CommitmentPage.fromJson(response.data).content;
} catch (e) {
plan = <Commitment>[];
}
List<Commitment> overdue = <Commitment>[];
try {
final response = await getDataManager().getOverdueQueue(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'dueEnd'),
page: Pageable(0, 0, 50, 0),
),
));
overdue = CommitmentPage.fromJson(response.data).content;
} catch (e) {
overdue = <Commitment>[];
}
return CoachContext(
runs: await getDataManager().getRunLog(),
routines: routines,
targets: await getDataManager().getProgressTargets(),
plan: plan,
overdue: overdue,
debtScore: await getDataManager().getCachedDebtScore(),
);
}
/// The routine a "Start" button names, fetched fresh so it runs current
/// steps.
Future<RoutineChain?> findRoutine(String id) async {
try {
final response = await getDataManager().getMyRoutines(HistoryRequest(
query: PageAndSort(
sort: Sort('asc', 'startMinute'),
page: Pageable(0, 0, 50, 0),
),
));
for (RoutineChain routine in getRoutineChainList(response.data)) {
if (routine.id == id) {
return routine;
}
}
} catch (e) {
return null;
}
return null;
}
static CoachMessage question(String text) =>
CoachMessage(mine: true, body: text);
}

View File

@@ -443,8 +443,8 @@ class NewCommitmentState extends State<NewCommitment>
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorSheetBackground, color: colorSheetBackground,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28), topLeft: Radius.circular(16),
topRight: Radius.circular(28), topRight: Radius.circular(16),
), ),
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -460,7 +460,7 @@ class NewCommitmentState extends State<NewCommitment>
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorGrey.withValues(alpha: 0.35), color: colorGrey.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(14),
), ),
), ),
), ),

View File

@@ -142,7 +142,7 @@ class ExcuseReportState extends State<ExcuseReport>
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorInset, color: colorInset,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
border: Border.all(color: colorBorder, width: 1), border: Border.all(color: colorBorder, width: 1),
), ),
child: Row( child: Row(

View File

@@ -242,7 +242,7 @@ class GoalDetailState extends State<GoalDetail>
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.08), color: colorWhite.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -405,7 +405,7 @@ class GoalDetailState extends State<GoalDetail>
width: 58, width: 58,
height: 58, height: 58,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
child: SceneArt( child: SceneArt(
palette: scenePalette(routine.kind), palette: scenePalette(routine.kind),
seed: sceneSeed(routine.title), seed: sceneSeed(routine.title),

View File

@@ -282,7 +282,7 @@ class GoalEditorState extends State<GoalEditor> implements ConnectGoalEditor {
return Container( return Container(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(14),
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,

View File

@@ -3,7 +3,11 @@ import 'package:flutter/material.dart';
import 'GoalsState.dart'; import 'GoalsState.dart';
class Goals extends StatefulWidget { class Goals extends StatefulWidget {
const Goals({super.key}); /// Shown as a tab inside Home: no back arrow, and room at the bottom
/// for the nav.
final bool tab;
const Goals({super.key, this.tab = false});
@override @override
State<Goals> createState() => GoalsState(); State<Goals> createState() => GoalsState();

View File

@@ -74,7 +74,8 @@ class GoalsState extends State<Goals> implements ConnectGoals {
return Sheet( return Sheet(
eyebrow: "Grounded", eyebrow: "Grounded",
title: "Goals", title: "Goals",
onBack: _onBack, onBack: widget.tab ? null : _onBack,
bottomSpace: widget.tab ? 130 : 40,
action: chromeAction(CupertinoIcons.add, _onNewGoal), action: chromeAction(CupertinoIcons.add, _onNewGoal),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -117,10 +118,10 @@ class GoalsState extends State<Goals> implements ConnectGoals {
return GestureDetector( return GestureDetector(
onTap: () => _onOpenGoal(goal), onTap: () => _onOpenGoal(goal),
child: Container( child: Container(
height: 214, height: 260,
margin: const EdgeInsets.only(bottom: 14), margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(26), borderRadius: BorderRadius.circular(16),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x262A3A30), color: Color(0x262A3A30),
@@ -130,7 +131,7 @@ class GoalsState extends State<Goals> implements ConnectGoals {
], ],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(26), borderRadius: BorderRadius.circular(16),
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -151,7 +152,7 @@ class GoalsState extends State<Goals> implements ConnectGoals {
children: [ children: [
GlassSurface( GlassSurface(
onDark: true, onDark: true,
radius: BorderRadius.circular(99), radius: BorderRadius.circular(14),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5), horizontal: 10, vertical: 5),
child: text( child: text(

View File

@@ -0,0 +1,10 @@
import '../../about/external/data/GroupMessage.dart';
abstract class ConnectGroupChat {
void onMessagesLoaded(List<GroupMessage> messages);
void onMessageSent(GroupMessage message);
/// The send failed; the text goes back in the box so nothing is lost.
void onSendFailed(String body);
}

View File

@@ -0,0 +1,12 @@
import '../../about/external/data/Group.dart';
abstract class ConnectGroupDetail {
void onGroupLoaded(Group group);
/// Progress landed; the group comes back with the new totals.
void onProgressLogged(Group group, String summary);
void onNudged(String who);
void onLeft();
}

View File

@@ -0,0 +1,6 @@
import '../../about/external/data/Group.dart';
abstract class ConnectGroupEditor {
/// Created or joined — either way you are now in it.
void onGroupReady(Group group);
}

View File

@@ -0,0 +1,3 @@
abstract class ConnectGroupGoalEditor {
void onGoalSaved();
}

View File

@@ -0,0 +1,5 @@
import '../../about/external/data/Group.dart';
abstract class ConnectGroups {
void onGroupsLoaded(List<Group> groups);
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import '../../about/external/data/Group.dart';
import 'GroupChatState.dart';
class GroupChat extends StatefulWidget {
final Group group;
const GroupChat({super.key, required this.group});
@override
State<GroupChat> createState() => GroupChatState();
}

View File

@@ -0,0 +1,497 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/GroupMessage.dart';
import '../../about/external/initial/GroupMessageRequest.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/AmbientBackground.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/MemberAvatar.dart';
import '../../designs/Responsive.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import 'ConnectGroupChat.dart';
import 'GroupChat.dart';
import 'ViewGroupChat.dart';
class GroupChatState extends State<GroupChat> implements ConnectGroupChat {
ViewGroupChat? _model;
List<GroupMessage> _messages = <GroupMessage>[];
final TextEditingController _input = TextEditingController();
Timer? _poll;
/// How often the open chat looks for new messages.
static const Duration _pollEvery = Duration(seconds: 4);
static const List<String> _quickReplies = <String>[
"Done for today",
"On my way",
"Need a push",
"Who's in tomorrow?",
];
@override
void dispose() {
_poll?.cancel();
_input.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroupChat>.reactive(
viewModelBuilder: () => ViewGroupChat(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
_model?.loadMessages(widget.group.id);
_poll = Timer.periodic(_pollEvery, _onPoll);
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, true);
}
void _onPoll(Timer timer) {
_model?.refresh(widget.group.id);
}
void _onSend(String value) {
final String body = value.trim();
if (body.isEmpty) {
return;
}
_input.clear();
_model?.send(GroupMessageRequest(groupId: widget.group.id, body: body));
}
void _onSendPressed() {
_onSend(_input.text);
}
void _onQuickReply(String reply) {
_onSend(reply);
}
/// A message author's photo, looked up among the group's members.
String _photoOf(String authorId) {
for (final member in widget.group.members) {
if (member.id == authorId) {
return member.photo;
}
}
return "";
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
final List<GroupMessage> newestFirst = _messages.reversed.toList();
return Scaffold(
backgroundColor: colorAmbientMid,
body: AmbientBackground(
child: Column(
children: [
_topBar(),
Expanded(
child: ListView.builder(
reverse: true,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
itemCount: newestFirst.length,
itemBuilder: (context, index) {
final GroupMessage message = newestFirst[index];
final GroupMessage? older = index + 1 < newestFirst.length
? newestFirst[index + 1]
: null;
final bool newDay =
older == null || !_sameDay(older.sentAt, message.sentAt);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (newDay) _dayLabel(message.sentAt),
_message(message, older),
],
);
},
),
),
_composer(),
],
),
),
);
}
Widget _topBar() {
final Color accent = goalColor(widget.group.colourHex);
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color.lerp(accent, colorNight, 0.55)!,
colorNight,
],
),
),
padding: EdgeInsets.fromLTRB(
16, MediaQuery.of(context).padding.top + 10, 16, 14),
child: Row(
children: [
glassCircle(CupertinoIcons.arrow_left, _onBack,
size: 46, onDark: true),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(widget.group.name, 19, TextType.Medium,
color: colorWhite,
maxLines: 1,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 3),
text(
widget.group.members
.map((member) => member.me ? "you" : member.name)
.join(", "),
12,
TextType.Regular,
color: colorWhite.withValues(alpha: 0.65),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
),
),
const SizedBox(width: 10),
avatarStack(
widget.group.members.map((member) => member.name).toList(), 30,
photos:
widget.group.members.map((member) => member.photo).toList(),
max: 3,
border: colorNight),
],
),
);
}
Widget _dayLabel(DateTime? moment) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.7),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child:
text(_dayName(moment), 11, TextType.Medium, color: colorInkSoft),
),
),
);
}
/// Text as bubbles, check-ins and nudges as cards in the thread, system
/// notes as a quiet centred line.
Widget _message(GroupMessage message, GroupMessage? older) {
if (message.isSystem) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: text(
"${message.mine ? "You" : message.authorName} ${message.body}",
12,
TextType.Regular,
color: colorInkSoft,
align: TextAlign.center,
),
);
}
if (message.isCheckIn || message.isNudge) {
return _eventCard(message);
}
// Consecutive lines from one person share a single name and portrait.
final bool grouped = older != null &&
older.authorId == message.authorId &&
!older.isSystem &&
!older.isCheckIn &&
!older.isNudge &&
_sameDay(older.sentAt, message.sentAt);
if (message.mine) {
return Padding(
padding: EdgeInsets.only(top: grouped ? 4 : 12, left: 64),
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 11, 14, 10),
decoration: BoxDecoration(
color: colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
text(message.body, 15, TextType.Regular,
color: colorWhite, height: 1.4),
const SizedBox(height: 4),
text(_time(message.sentAt), 10, TextType.Regular,
color: colorWhite.withValues(alpha: 0.65)),
],
),
),
),
);
}
final Color tone = _authorColour(message.authorName);
return Padding(
padding: EdgeInsets.only(top: grouped ? 4 : 12, right: 56),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
grouped
? const SizedBox(width: 34)
: memberAvatar(message.authorName, 34,
photo: _photoOf(message.authorId)),
const SizedBox(width: 8),
Flexible(
child: Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
color: colorWhite,
border: Border.all(color: colorGlassBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!grouped) ...[
text(message.authorName, 12, TextType.Medium, color: tone),
const SizedBox(height: 3),
],
text(message.body, 15, TextType.Regular,
color: colorInk, height: 1.4),
const SizedBox(height: 4),
text(_time(message.sentAt), 10, TextType.Regular,
color: colorInkSoft),
],
),
),
),
],
),
);
}
/// A check-in or a nudge, set apart from the talk so the work is visible.
Widget _eventCard(GroupMessage message) {
final bool checkIn = message.isCheckIn;
final String who = message.mine ? "You" : message.authorName;
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
color: checkIn ? colorStandingGoodBg : colorOverdueWarmEnd,
border: Border(
left: BorderSide(
color: checkIn ? colorPositive : colorBarStandard,
width: 4,
),
),
),
child: Row(
children: [
memberAvatar(message.authorName, 34,
photo: _photoOf(message.authorId)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
checkIn
? CupertinoIcons.checkmark_seal_fill
: CupertinoIcons.hand_point_right_fill,
size: 13,
color: checkIn ? colorPositive : colorWarning,
),
const SizedBox(width: 5),
text(checkIn ? "CHECK-IN" : "NUDGE", 10, TextType.Bold,
color: checkIn ? colorPositive : colorWarning,
letterSpacing: 1.0),
const Spacer(),
text(_time(message.sentAt), 10, TextType.Regular,
color: colorInkSoft),
],
),
const SizedBox(height: 4),
text("$who ${message.body}", 14, TextType.Regular,
color: colorInk, height: 1.4),
],
),
),
],
),
),
);
}
Widget _composer() {
return Container(
decoration: BoxDecoration(
color: colorWhite,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
padding: EdgeInsets.fromLTRB(
0, 10, 0, MediaQuery.of(context).padding.bottom + 10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 38,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _quickReplies.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) => GestureDetector(
onTap: () => _onQuickReply(_quickReplies[index]),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorAmbientMid,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: text(_quickReplies[index], 13, TextType.Regular,
color: colorInk),
),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 10, 0),
child: Row(
children: [
Expanded(
child: quickAddField(_input, "Message the group", _onSend),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _onSendPressed,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Icon(CupertinoIcons.paperplane_fill,
size: 20, color: colorLime),
),
),
],
),
),
],
),
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
Color _authorColour(String name) {
return goalAccents[sceneSeed(name) % goalAccents.length];
}
bool _sameDay(DateTime? a, DateTime? b) {
if (a == null || b == null) return false;
final DateTime x = a.toLocal();
final DateTime y = b.toLocal();
return x.year == y.year && x.month == y.month && x.day == y.day;
}
String _dayName(DateTime? moment) {
if (moment == null) return "";
final DateTime now = DateTime.now();
if (_sameDay(moment, now)) return "Today";
if (_sameDay(moment, now.subtract(const Duration(days: 1)))) {
return "Yesterday";
}
return formatDate(moment.toLocal());
}
String _time(DateTime? moment) {
if (moment == null) return "";
final DateTime local = moment.toLocal();
return "${local.hour.toString().padLeft(2, "0")}:${local.minute.toString().padLeft(2, "0")}";
}
// ── ConnectGroupChat ──────────────────────────────────────────────────────
@override
void onMessagesLoaded(List<GroupMessage> messages) {
if (!mounted) return;
setState(() {
_messages = messages;
});
}
@override
void onMessageSent(GroupMessage message) {
if (!mounted) return;
setState(() {
_messages = <GroupMessage>[..._messages, message];
});
}
@override
void onSendFailed(String body) {
if (!mounted) return;
_input.text = body;
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import '../../about/external/data/Group.dart';
import 'GroupDetailState.dart';
class GroupDetail extends StatefulWidget {
final Group group;
const GroupDetail({super.key, required this.group});
@override
State<GroupDetail> createState() => GroupDetailState();
}

View File

@@ -0,0 +1,731 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Group.dart';
import '../../about/external/data/GroupGoal.dart';
import '../../about/external/data/GroupMember.dart';
import '../../about/external/data/GroupObjective.dart';
import '../../about/external/initial/NudgeRequest.dart';
import '../../about/external/initial/ObjectiveProgressRequest.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/MemberAvatar.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import 'ConnectGroupDetail.dart';
import 'GroupChat.dart';
import 'GroupDetail.dart';
import 'GroupGoalEditor.dart';
import 'ViewGroupDetail.dart';
class GroupDetailState extends State<GroupDetail>
implements ConnectGroupDetail {
ViewGroupDetail? _model;
late Group _group;
/// Members nudged from this screen, so the button reads "Nudged" after.
final Set<String> _nudged = <String>{};
final TextEditingController _amount = TextEditingController();
final TextEditingController _note = TextEditingController();
@override
void initState() {
super.initState();
_group = widget.group;
}
@override
void dispose() {
_amount.dispose();
_note.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroupDetail>.reactive(
viewModelBuilder: () => ViewGroupDetail(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
_model?.loadGroup(widget.group.id);
}
Color get _accent => goalColor(_group.colourHex);
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, true);
}
void _onOpenChat() async {
await GroundedNavigation()
.navigateToPageWithData(GroupChat(group: _group), context);
_model?.loadGroup(_group.id, quiet: true);
}
void _onAddGoal() async {
final result = await GroundedNavigation()
.navigateToPageWithData(GroupGoalEditor(group: _group), context);
if (result == true) {
_model?.loadGroup(_group.id, quiet: true);
}
}
void _onCopyCode() async {
await Clipboard.setData(ClipboardData(text: _group.inviteCode));
_model?.showApplicationNotification(
NotificationType.success,
"Code copied",
"Send ${_group.inviteCode} to whoever should join. They enter it under Groups → Join with a code.",
true,
true,
null,
);
}
void _onNudge(GroupMember member) {
_model?.nudge(
NudgeRequest(groupId: _group.id, memberId: member.id), member.name);
}
void _onNudgeEveryone() {
_model?.nudge(NudgeRequest(groupId: _group.id), "everyone");
}
void _onLog(GroupGoal goal, GroupObjective objective) {
_amount.text = "";
_note.text = "";
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colorWhite,
builder: (sheetContext) => _logSheet(sheetContext, goal, objective),
);
}
void _onQuickAmount(double value) {
_amount.text = formatAmount(value).replaceAll(",", "");
}
void _onSubmitLog(
BuildContext sheetContext, GroupGoal goal, GroupObjective objective) {
final double amount = double.tryParse(_amount.text.trim()) ?? 0;
if (amount <= 0) {
return;
}
Navigator.pop(sheetContext);
_model?.logProgress(
ObjectiveProgressRequest(
groupId: _group.id,
goalId: goal.id,
objectiveId: objective.id,
amount: amount,
note: _note.text.trim(),
),
"${formatAmount(amount)} ${objective.unit} on ${objective.title}",
);
}
void _onLeave() {
_model?.showApplicationNotification(
NotificationType.warning,
"Leave ${_group.name}?",
"You stop seeing the shared goals and the chat. What you logged stays in the group's totals.",
true,
true,
null,
action: "Leave",
positiveAction: _onConfirmLeave,
);
}
void _onConfirmLeave() {
Navigator.pop(context);
_model?.leave(_group.id);
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
final List<GroupMember> ranked = List<GroupMember>.from(_group.members)
..sort((a, b) => b.keptRate.compareTo(a.keptRate));
return Sheet(
eyebrow: "${_group.members.length} members",
title: _group.name,
onBack: _onBack,
scene: goalScene(_accent),
sceneSeed: sceneSeed(_group.name),
action: chromeAction(CupertinoIcons.chat_bubble_2_fill, _onOpenChat),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_group.description.isNotEmpty) ...[
text(_group.description, 15, TextType.Regular,
color: colorGrey2, height: 1.55),
const SizedBox(height: 22),
],
_statsRow(),
const SizedBox(height: 20),
_chatCard(),
const SizedBox(height: 30),
sectionBreak(
"Shared goals",
caption: "${_group.goals.length} on the go",
trailing: textButton("+ Add", _onAddGoal,
color: colorInk, textSize: 14, type: TextType.Medium),
),
if (_group.goals.isEmpty)
emptyState(
CupertinoIcons.flag,
"Nothing shared yet",
"Add a goal and split it into objectives everyone can log toward, like kilometres, pages or sessions.",
)
else
..._group.goals.map(_goalCard),
const SizedBox(height: 30),
sectionBreak("Leaderboard",
caption: "kept, last 7 days",
trailing: textButton("Nudge all", _onNudgeEveryone,
color: colorInk, textSize: 14, type: TextType.Medium)),
for (int index = 0; index < ranked.length; index++)
_memberRow(index + 1, ranked[index]),
const SizedBox(height: 26),
Center(
child: textButton("Leave group", _onLeave,
color: colorDestructive, textSize: 13),
),
],
),
);
}
Widget _statsRow() {
final double average = _group.members.isEmpty
? 0
: _group.members
.map((member) => member.keptRate)
.reduce((a, b) => a + b) /
_group.members.length;
return card(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 18),
child: Row(
children: [
Expanded(
child: labelled("Together", "${(_group.progress * 100).round()}%",
valueSize: 26, valueType: TextType.Light),
),
Expanded(
child: labelled("Kept avg", "${(average * 100).round()}%",
valueSize: 26, valueType: TextType.Light),
),
GestureDetector(
onTap: _onCopyCode,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colorAmbientMid,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
text("INVITE", 9, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const SizedBox(width: 6),
Icon(CupertinoIcons.doc_on_doc,
size: 12, color: colorGrey2),
],
),
const SizedBox(height: 4),
text(_group.inviteCode, 18, TextType.Medium,
color: colorInk, letterSpacing: 1.5),
],
),
),
),
],
),
);
}
/// The way into the chat: the last line said and how much is unread.
Widget _chatCard() {
return GestureDetector(
onTap: _onOpenChat,
child: Container(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(12)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[colorNightLift, colorNight],
),
boxShadow: [
BoxShadow(
color: Color(0x332A3A30),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(CupertinoIcons.chat_bubble_2_fill,
size: 16, color: colorLime),
const SizedBox(width: 8),
text("Group chat", 14, TextType.Medium, color: colorLime),
if (_group.unread > 0) ...[
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: colorLime,
borderRadius:
const BorderRadius.all(Radius.circular(8)),
),
child: text(
"${_group.unread} new", 10, TextType.Medium,
color: colorNight),
),
],
],
),
const SizedBox(height: 10),
text(
_group.lastMessage.isEmpty
? "Say hello. Check-ins and nudges show up here too."
: "${_group.lastAuthor}: ${_group.lastMessage}",
15,
TextType.Regular,
color: colorWhite,
maxLines: 2,
overflow: TextOverflow.ellipsis,
height: 1.45,
),
],
),
),
const SizedBox(width: 12),
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: colorWhite.withValues(alpha: 0.12),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child:
Icon(CupertinoIcons.arrow_right, size: 20, color: colorWhite),
),
],
),
),
);
}
/// A shared goal: its overall bar, then each objective with the group's
/// total, your share, and a button to log more.
Widget _goalCard(GroupGoal goal) {
final Color tone =
goal.colourHex.isEmpty ? _accent : goalColor(goal.colourHex);
return Container(
margin: const EdgeInsets.only(bottom: 14),
child: card(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Container(height: 6, color: tone),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: text(goal.title, 19, TextType.Medium,
color: colorInk),
),
const SizedBox(width: 10),
text("${(goal.progress * 100).round()}%", 22,
TextType.Light,
color: colorInk),
],
),
const SizedBox(height: 4),
text(
[
"${goal.completeObjectives}/${goal.objectives.length} objectives met",
goal.targetDate == null
? "no deadline"
: targetLabel(goal.targetDate),
].join(" · "),
12,
TextType.Regular,
color: colorGrey2,
),
const SizedBox(height: 12),
meter(goal.progress,
fill: tone,
track: tone.withValues(alpha: 0.14),
height: 6),
const SizedBox(height: 8),
],
),
),
for (GroupObjective objective in goal.objectives)
_objectiveRow(goal, objective, tone),
],
),
),
);
}
Widget _objectiveRow(GroupGoal goal, GroupObjective objective, Color tone) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 14, 14, 16),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: Border(top: BorderSide(color: colorBorder)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (objective.complete) ...[
Icon(CupertinoIcons.checkmark_seal_fill,
size: 15, color: colorPositive),
const SizedBox(width: 6),
],
Flexible(
child: text(objective.title, 15, TextType.Medium,
color: colorInk,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
const SizedBox(height: 4),
text(
"${formatAmount(objective.current)} / ${formatAmount(objective.target)} ${objective.unit}",
13,
TextType.Regular,
color: colorGrey2,
),
const SizedBox(height: 8),
meter(objective.progress,
fill: tone, track: tone.withValues(alpha: 0.12), height: 4),
const SizedBox(height: 6),
text("You: ${formatAmount(objective.mine)} ${objective.unit}",
12, TextType.Medium,
color: tone),
],
),
),
const SizedBox(width: 14),
GestureDetector(
onTap: () => _onLog(goal, objective),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.add, size: 16, color: colorLime),
const SizedBox(width: 6),
text("Log", 14, TextType.Medium, color: colorWhite),
],
),
),
),
],
),
);
}
Widget _memberRow(int rank, GroupMember member) {
final bool nudged = _nudged.contains(member.id);
final bool quiet = member.lastCheckIn == null ||
DateTime.now().difference(member.lastCheckIn!).inHours >= 24;
return Container(
margin: const EdgeInsets.only(bottom: 10),
child: card(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
child: Row(
children: [
SizedBox(
width: 24,
child: text("$rank", 16, TextType.Medium,
color: rank == 1 ? colorInk : colorGrey2),
),
memberAvatar(member.name, 46, photo: member.photo),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: text(
member.me ? "${member.name} (you)" : member.name,
16,
TextType.Medium,
color: colorInk,
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
if (member.owner) ...[
const SizedBox(width: 6),
Icon(CupertinoIcons.star_fill,
size: 12, color: colorBarStandard),
],
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(CupertinoIcons.flame_fill,
size: 12, color: colorBarNonNegotiable),
const SizedBox(width: 3),
text("${member.streak}d", 12, TextType.Regular,
color: colorGrey2),
const SizedBox(width: 10),
text(
quiet
? "quiet ${member.lastCheckIn == null ? "" : _ago(member.lastCheckIn!)}"
: "checked in ${_ago(member.lastCheckIn!)}",
12,
TextType.Regular,
color: quiet ? colorStandingGrounded : colorGrey2,
),
],
),
],
),
),
text("${(member.keptRate * 100).round()}%", 20, TextType.Light,
color: colorInk),
if (!member.me) ...[
const SizedBox(width: 10),
GestureDetector(
onTap: nudged ? null : () => _onNudge(member),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: nudged ? colorAmbientMid : colorNight,
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Icon(
nudged
? CupertinoIcons.checkmark
: CupertinoIcons.hand_point_right_fill,
size: 18,
color: nudged ? colorGrey2 : colorLime,
),
),
),
],
],
),
),
);
}
/// Log progress: an amount (with one-tap presets that suit the unit) and
/// an optional note that rides along into the chat.
Widget _logSheet(
BuildContext sheetContext, GroupGoal goal, GroupObjective objective) {
final List<double> presets = _presets(objective.unit);
return Padding(
padding: EdgeInsets.fromLTRB(
20, 16, 20, MediaQuery.of(sheetContext).viewInsets.bottom + 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Center(child: Container(width: 40, height: 4, color: colorGrabber)),
const SizedBox(height: 18),
text("LOG PROGRESS", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.2),
const SizedBox(height: 6),
text(objective.title, 24, TextType.Medium, color: colorInk),
const SizedBox(height: 4),
text(
"${formatAmount(objective.current)} of ${formatAmount(objective.target)} ${objective.unit} so far",
13,
TextType.Regular,
color: colorGrey2),
const SizedBox(height: 18),
Row(
children: [
for (double preset in presets) ...[
Expanded(
child: GestureDetector(
onTap: () => _onQuickAmount(preset),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
color: colorAmbientMid,
borderRadius:
const BorderRadius.all(Radius.circular(12)),
),
child: text(
"+${formatAmount(preset)}", 15, TextType.Medium,
color: colorInk),
),
),
),
if (preset != presets.last) const SizedBox(width: 8),
],
],
),
const SizedBox(height: 16),
inputField(
"Amount (${objective.unit})",
_amount,
hint: "How much?",
keyboard: const TextInputType.numberWithOptions(decimal: true),
formatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")),
],
),
const SizedBox(height: 14),
inputField("Note", _note, hint: "Optional: posted with the check-in"),
const SizedBox(height: 22),
roundedCornerButton(
"Log it", () => _onSubmitLog(sheetContext, goal, objective),
icon: CupertinoIcons.checkmark),
],
),
);
}
List<double> _presets(String unit) {
switch (unit.toLowerCase()) {
case "steps":
return <double>[1000, 5000, 10000];
case "words":
return <double>[250, 500, 1000];
case "hours":
return <double>[0.5, 1, 2];
case "km":
return <double>[3, 5, 10];
default:
return <double>[1, 2, 5];
}
}
String _ago(DateTime moment) {
final Duration gap = DateTime.now().difference(moment);
if (gap.inMinutes < 60) return "${gap.inMinutes.clamp(1, 59)}m ago";
if (gap.inHours < 24) return "${gap.inHours}h ago";
return "${gap.inDays}d ago";
}
// ── ConnectGroupDetail ────────────────────────────────────────────────────
@override
void onGroupLoaded(Group group) {
setState(() {
_group = group;
});
}
@override
void onProgressLogged(Group group, String summary) {
setState(() {
_group = group;
});
_model?.showApplicationNotification(
NotificationType.success,
"Logged",
"$summary. The group sees it in the chat.",
true,
true,
null,
);
}
@override
void onNudged(String who) {
setState(() {
for (GroupMember member in _group.members) {
if (who == "everyone" ? !member.me : member.name == who) {
_nudged.add(member.id);
}
}
});
}
@override
void onLeft() {
Navigator.pop(context, true);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'GroupEditorState.dart';
class GroupEditor extends StatefulWidget {
final bool joining;
const GroupEditor({super.key, this.joining = false});
@override
State<GroupEditor> createState() => GroupEditorState();
}

View File

@@ -0,0 +1,294 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Group.dart';
import '../../about/external/initial/GroupRequest.dart';
import '../../about/external/initial/JoinGroupRequest.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/MemberAvatar.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/Validators.dart';
import 'ConnectGroupEditor.dart';
import 'GroupEditor.dart';
import 'ViewGroupEditor.dart';
class GroupEditorState extends State<GroupEditor>
implements ConnectGroupEditor {
ViewGroupEditor? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _name = TextEditingController();
final TextEditingController _description = TextEditingController();
final TextEditingController _code = TextEditingController();
Color _accent = goalAccents[1];
bool _joining = false;
@override
void initState() {
super.initState();
_joining = widget.joining;
}
@override
void dispose() {
_name.dispose();
_description.dispose();
_code.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroupEditor>.reactive(
viewModelBuilder: () => ViewGroupEditor(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, false);
}
void _onModeSelected(String mode) {
setState(() {
_joining = mode == "Join";
});
}
void _onNameChanged(String value) {
setState(() {});
}
void _onAccentSelected(Color colour) {
setState(() {
_accent = colour;
});
}
void _onSubmit() {
if (_formKey.currentState?.validate() != true) {
return;
}
if (_joining) {
_model
?.join(JoinGroupRequest(inviteCode: _code.text.trim().toUpperCase()));
} else {
_model?.create(GroupRequest(
name: _name.text.trim(),
description: _description.text.trim(),
colourHex: colorToHex(_accent),
));
}
}
String? _validateCode(String? value) {
final String code = (value ?? "").trim();
if (code.length != 6) {
return "Invite codes are six characters.";
}
return null;
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: _joining ? "Join" : "New",
title: "Group",
onBack: _onBack,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle(
_joining ? "Got a code?" : "Who's keeping\nyou honest?"),
const SizedBox(height: 22),
segmentedSelector<String>(
options: const <String>["Create", "Join"],
selected: _joining ? "Join" : "Create",
label: _modeLabel,
onSelected: _onModeSelected,
activeColor: colorNight,
),
const SizedBox(height: 26),
if (_joining) ..._joinFields() else ..._createFields(),
const SizedBox(height: 32),
roundedCornerButton(
_joining ? "Join group" : "Create group",
_onSubmit,
icon: _joining ? CupertinoIcons.arrow_right : CupertinoIcons.add,
),
],
),
),
);
}
String _modeLabel(String mode) => mode;
List<Widget> _createFields() {
return <Widget>[
_preview(),
const SizedBox(height: 26),
inputField(
"Group name",
_name,
hint: "Morning Crew, Thesis Sprint…",
validator: Validators.title,
onChanged: _onNameChanged,
),
const SizedBox(height: 16),
inputField(
"What it's for",
_description,
hint: "Optional: the promise you're all making",
maxLines: 3,
),
const SizedBox(height: 26),
sectionBreak("Colour", caption: "the group's banner"),
_swatches(),
const SizedBox(height: 14),
text(
"You'll get a six-letter invite code to send to the others once it's created.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
];
}
List<Widget> _joinFields() {
return <Widget>[
inputField(
"Invite code",
_code,
hint: "6 characters, like MRN7AM",
validator: _validateCode,
icon: CupertinoIcons.ticket,
formatters: <TextInputFormatter>[
LengthLimitingTextInputFormatter(6),
FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z0-9]")),
],
),
const SizedBox(height: 12),
text(
"Ask whoever started the group: the code is on the group's page. Joining shows you their shared goals and the chat straight away.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
];
}
/// The banner as it will look, so the colour is chosen by seeing it.
Widget _preview() {
final String name =
_name.text.trim().isEmpty ? "Your group" : _name.text.trim();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[_accent, Color.lerp(_accent, colorBlack, 0.45)!],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(CupertinoIcons.person_3_fill,
size: 16, color: colorWhite.withValues(alpha: 0.7)),
const SizedBox(width: 8),
text("1 MEMBER", 9, TextType.Bold,
color: colorWhite.withValues(alpha: 0.6), letterSpacing: 1.2),
],
),
const SizedBox(height: 14),
text(name, 26, TextType.Light,
color: colorWhite, maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 16),
avatarStack(const <String>["You"], 34),
],
),
);
}
Widget _swatches() {
return Wrap(
spacing: 9,
runSpacing: 9,
children: goalAccents.map((colour) {
final bool selected = colour.toARGB32() == _accent.toARGB32();
return GestureDetector(
onTap: () => _onAccentSelected(colour),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
color: colour,
border: Border.all(
color: selected ? colorWhite : Colors.transparent,
width: 3,
),
boxShadow: selected
? [BoxShadow(color: colour, spreadRadius: 1.5)]
: null,
),
child: selected
? Icon(Icons.check_rounded, size: 18, color: colorWhite)
: null,
),
);
}).toList(),
);
}
// ── ConnectGroupEditor ────────────────────────────────────────────────────
@override
void onGroupReady(Group group) {
Navigator.pop(context, group);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import '../../about/external/data/Group.dart';
import 'GroupGoalEditorState.dart';
class GroupGoalEditor extends StatefulWidget {
final Group group;
const GroupGoalEditor({super.key, required this.group});
@override
State<GroupGoalEditor> createState() => GroupGoalEditorState();
}

View File

@@ -0,0 +1,317 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/initial/GroupGoalRequest.dart';
import '../../about/external/initial/ObjectiveDraft.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/Component.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/input/InputFields.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import '../../utils/Validators.dart';
import 'ConnectGroupGoalEditor.dart';
import 'GroupGoalEditor.dart';
import 'ViewGroupGoalEditor.dart';
/// The three fields of one objective while it is being typed.
typedef _ObjectiveFields = ({
TextEditingController title,
TextEditingController target,
TextEditingController unit,
});
class GroupGoalEditorState extends State<GroupGoalEditor>
implements ConnectGroupGoalEditor {
ViewGroupGoalEditor? _model;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _title = TextEditingController();
final TextEditingController _description = TextEditingController();
final List<_ObjectiveFields> _objectives = <_ObjectiveFields>[];
DateTime? _targetDate;
@override
void initState() {
super.initState();
_objectives.add(_blankObjective());
}
@override
void dispose() {
_title.dispose();
_description.dispose();
for (_ObjectiveFields fields in _objectives) {
fields.title.dispose();
fields.target.dispose();
fields.unit.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroupGoalEditor>.reactive(
viewModelBuilder: () => ViewGroupGoalEditor(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {}
_ObjectiveFields _blankObjective() {
return (
title: TextEditingController(),
target: TextEditingController(),
unit: TextEditingController(),
);
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, false);
}
void _onAddObjective() {
setState(() {
_objectives.add(_blankObjective());
});
}
void _onRemoveObjective(_ObjectiveFields fields) {
setState(() {
_objectives.remove(fields);
});
fields.title.dispose();
fields.target.dispose();
fields.unit.dispose();
}
void _onPickTarget() async {
final DateTime now = DateTime.now();
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _targetDate ?? now.add(const Duration(days: 30)),
firstDate: now,
lastDate: now.add(const Duration(days: 365 * 3)),
);
if (picked == null) {
return;
}
setState(() {
_targetDate = picked;
});
}
void _onClearTarget() {
setState(() {
_targetDate = null;
});
}
void _onSave() {
if (_formKey.currentState?.validate() != true) {
return;
}
_model?.save(GroupGoalRequest(
groupId: widget.group.id,
title: _title.text.trim(),
description: _description.text.trim(),
targetDate: _targetDate?.toIso8601String() ?? "",
colourHex: widget.group.colourHex,
objectives: _objectives
.map((fields) => ObjectiveDraft(
title: fields.title.text.trim(),
unit: fields.unit.text.trim(),
target: double.tryParse(fields.target.text.trim()) ?? 0,
))
.toList(),
));
}
String? _validateTarget(String? value) {
final double? target = double.tryParse((value ?? "").trim());
if (target == null || target <= 0) {
return "A number above 0.";
}
return null;
}
String? _validateUnit(String? value) {
return Validators.required(value, "Unit");
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: widget.group.name,
title: "Shared goal",
onBack: _onBack,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle("What are you\nall going for?"),
const SizedBox(height: 24),
inputField(
"Goal",
_title,
hint: "Run 200 km this month, finish the drafts…",
validator: Validators.title,
),
const SizedBox(height: 16),
inputField(
"Why it matters",
_description,
hint: "Optional",
maxLines: 2,
),
const SizedBox(height: 28),
sectionBreak("Objectives", caption: "what everyone logs toward"),
text(
"Break the goal into numbers people can add to: kilometres, sessions, pages, hours. Each member logs their share and the totals add up.",
12,
TextType.Regular,
color: colorGrey2,
height: 1.5,
),
const SizedBox(height: 14),
for (int index = 0; index < _objectives.length; index++)
_objectiveEditor(index, _objectives[index]),
outlinedActionButton("Add objective", _onAddObjective,
icon: CupertinoIcons.add),
const SizedBox(height: 28),
sectionBreak("Deadline", caption: "optional"),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: selectField(
"Target date",
_targetDate == null ? "" : formatDate(_targetDate),
_onPickTarget,
hint: "No deadline",
icon: CupertinoIcons.calendar,
),
),
if (_targetDate != null) ...[
const SizedBox(width: 8),
iconButton(
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
_onClearTarget,
bordered: true,
background: colorWhite,
size: 50,
),
],
],
),
const SizedBox(height: 32),
roundedCornerButton("Share with the group", _onSave,
icon: CupertinoIcons.person_3_fill),
],
),
),
);
}
Widget _objectiveEditor(int index, _ObjectiveFields fields) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: card(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
text("OBJECTIVE ${index + 1}", 10, TextType.Bold,
color: colorGrey2, letterSpacing: 1.0),
const Spacer(),
if (_objectives.length > 1)
iconButton(
Icon(CupertinoIcons.xmark, size: 15, color: colorGrey2),
() => _onRemoveObjective(fields),
size: 36,
),
],
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
inputField("Name", fields.title,
hint: "Distance run", validator: Validators.title),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: inputField(
"Target",
fields.target,
hint: "200",
validator: _validateTarget,
keyboard: const TextInputType.numberWithOptions(
decimal: true),
formatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(
RegExp(r"[0-9.]")),
],
),
),
const SizedBox(width: 10),
Expanded(
child: inputField("Unit", fields.unit,
hint: "km", validator: _validateUnit),
),
],
),
],
),
),
],
),
),
);
}
// ── ConnectGroupGoalEditor ────────────────────────────────────────────────
@override
void onGoalSaved() {
Navigator.pop(context, true);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'GroupsState.dart';
class Groups extends StatefulWidget {
/// Shown as a tab inside Home: no back arrow, and room at the bottom
/// for the nav.
final bool tab;
const Groups({super.key, this.tab = false});
@override
State<Groups> createState() => GroupsState();
}

View File

@@ -0,0 +1,251 @@
import 'package:flutter/cupertino.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Group.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../designs/Component.dart';
import '../../designs/GlassSurface.dart';
import '../../designs/MemberAvatar.dart';
import '../../designs/Responsive.dart';
import '../../designs/Shell.dart';
import '../../designs/buttons/Buttons.dart';
import '../../designs/scene/SceneArt.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import 'ConnectGroups.dart';
import 'GroupDetail.dart';
import 'GroupEditor.dart';
import 'Groups.dart';
import 'ViewGroups.dart';
class GroupsState extends State<Groups> implements ConnectGroups {
ViewGroups? _model;
List<Group> _groups = <Group>[];
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewGroups>.reactive(
viewModelBuilder: () => ViewGroups(context, this),
onViewModelReady: (viewModel) {
_model = viewModel;
_initiate();
},
builder: (context, viewModel, child) => LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Responsive(
mobile: _mobileView(constraints),
tablet: _mobileView(constraints),
desktop: _mobileView(constraints),
);
},
),
);
}
void _initiate() {
_model?.loadGroups();
}
// ── Handlers ──────────────────────────────────────────────────────────────
void _onBack() {
Navigator.pop(context, true);
}
void _onOpenGroup(Group group) async {
await GroundedNavigation()
.navigateToPageWithData(GroupDetail(group: group), context);
_model?.loadGroups();
}
void _onNewGroup() {
_openEditor(false);
}
void _onJoinGroup() {
_openEditor(true);
}
/// Created or joined, you land straight in the group.
void _openEditor(bool joining) async {
final result = await GroundedNavigation()
.navigateToPageWithData(GroupEditor(joining: joining), context);
if (result is Group) {
_model?.loadGroups();
_onOpenGroup(result);
}
}
// ── Views ─────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return Sheet(
eyebrow: "Grounded",
title: "Groups",
onBack: widget.tab ? null : _onBack,
bottomSpace: widget.tab ? 130 : 40,
action: chromeAction(CupertinoIcons.add, _onNewGroup),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
displayTitle("Keep each other\nhonest."),
const SizedBox(height: 14),
text(
"Share goals with people who will notice when you slip. Everyone logs toward the same objectives, and the chat sees every check-in.",
14,
TextType.Regular,
color: colorGrey2,
height: 1.55,
),
const SizedBox(height: 28),
if (_groups.isEmpty)
emptyState(
CupertinoIcons.person_3,
"No groups yet",
"Start one and send the code to a friend, or join with a code someone sent you.",
)
else
..._groups.map(_groupCard),
const SizedBox(height: 24),
roundedCornerButton("New group", _onNewGroup,
icon: CupertinoIcons.add),
const SizedBox(height: 10),
outlinedActionButton("Join with a code", _onJoinGroup,
icon: CupertinoIcons.ticket),
],
),
);
}
/// The group as a painted card: who is in it, how the shared goals are
/// going, and the latest line from the chat.
Widget _groupCard(Group group) {
final Color accent = goalColor(group.colourHex);
final Color soft = colorWhite.withValues(alpha: 0.78);
return GestureDetector(
onTap: () => _onOpenGroup(group),
child: Container(
height: 250,
margin: const EdgeInsets.only(bottom: 14),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Color(0x262A3A30),
blurRadius: 24,
offset: Offset(0, 10),
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
SceneArt(
palette: goalScene(accent),
seed: sceneSeed(group.name),
figure: CupertinoIcons.person_3_fill,
figureScale: 0.42,
figureAlignment: const Alignment(0.7, -0.4),
shade: true,
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GlassSurface(
onDark: true,
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
child: text(
"${group.members.length} members · ${group.goals.length} shared goal${group.goals.length == 1 ? "" : "s"}",
11,
TextType.Medium,
color: colorWhite,
),
),
const Spacer(),
if (group.unread > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colorLime,
borderRadius:
const BorderRadius.all(Radius.circular(8)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.chat_bubble_fill,
size: 12, color: colorNight),
const SizedBox(width: 6),
text("${group.unread} new", 11, TextType.Medium,
color: colorNight),
],
),
),
],
),
const Spacer(),
text(group.name, 24, TextType.Medium,
color: colorWhite,
maxLines: 1,
overflow: TextOverflow.ellipsis),
if (group.lastMessage.isNotEmpty) ...[
const SizedBox(height: 4),
text("${group.lastAuthor}: ${group.lastMessage}", 13,
TextType.Regular,
color: soft,
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 14),
meter(group.progress,
fill: colorLime,
track: colorWhite.withValues(alpha: 0.25),
height: 6),
const SizedBox(height: 12),
Row(
children: [
avatarStack(
group.members.map((member) => member.name).toList(),
32,
photos: group.members
.map((member) => member.photo)
.toList()),
const Spacer(),
text("${(group.progress * 100).round()}%", 22,
TextType.Light,
color: colorWhite),
const SizedBox(width: 6),
text("together", 12, TextType.Regular, color: soft),
],
),
],
),
),
],
),
),
);
}
// ── ConnectGroups ─────────────────────────────────────────────────────────
@override
void onGroupsLoaded(List<Group> groups) {
setState(() {
_groups = groups;
});
}
}

View File

@@ -0,0 +1,54 @@
import '../../about/external/data/GroupMessage.dart';
import '../../about/external/initial/GroupMessageRequest.dart';
import '../../about/external/initial/IdRequest.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectGroupChat.dart';
class ViewGroupChat extends ParentViewModel {
ConnectGroupChat connection;
ViewGroupChat(super.context, this.connection);
void loadMessages(String groupId) async {
if (!await hasNetwork(() => loadMessages(groupId))) return;
showLoading("Opening the chat");
try {
final response =
await getDataManager().getGroupMessages(IdRequest(id: groupId));
closeLoading();
connection.onMessagesLoaded(getGroupMessageList(response.data));
} catch (e) {
handleError(
e, () => loadMessages(groupId), () => dismissError(), "Retry");
}
}
/// The background refresh while the chat is open. Silent either way — a
/// missed poll is picked up by the next one.
void refresh(String groupId) async {
try {
final response =
await getDataManager().getGroupMessages(IdRequest(id: groupId));
connection.onMessagesLoaded(getGroupMessageList(response.data));
} catch (e) {
// Next tick tries again.
}
}
void send(GroupMessageRequest request) async {
try {
final response = await getDataManager().sendGroupMessage(request);
connection.onMessageSent(GroupMessage.fromJson(response.data));
} catch (e) {
connection.onSendFailed(request.body);
handleError(e, () => send(request), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,77 @@
import '../../about/external/data/Group.dart';
import '../../about/external/initial/IdRequest.dart';
import '../../about/external/initial/NudgeRequest.dart';
import '../../about/external/initial/ObjectiveProgressRequest.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectGroupDetail.dart';
class ViewGroupDetail extends ParentViewModel {
ConnectGroupDetail connection;
ViewGroupDetail(super.context, this.connection);
/// [quiet] refreshes behind the screen without the loading overlay, for
/// coming back from the chat or the goal editor.
void loadGroup(String groupId, {bool quiet = false}) async {
if (!await hasNetwork(() => loadGroup(groupId, quiet: quiet))) return;
if (!quiet) showLoading("Loading the group");
try {
final response =
await getDataManager().getGroupEntry(IdRequest(id: groupId));
if (!quiet) closeLoading();
connection.onGroupLoaded(Group.fromJson(response.data));
} catch (e) {
handleError(e, () => loadGroup(groupId, quiet: quiet),
() => dismissError(), "Retry");
}
}
void logProgress(ObjectiveProgressRequest request, String summary) async {
if (!await hasNetwork(() => logProgress(request, summary))) return;
showLoading("Logging");
try {
final response = await getDataManager().logObjectiveProgress(request);
closeLoading();
connection.onProgressLogged(Group.fromJson(response.data), summary);
} catch (e) {
handleError(e, () => logProgress(request, summary), () => dismissError(),
"Retry");
}
}
void nudge(NudgeRequest request, String who) async {
if (!await hasNetwork(() => nudge(request, who))) return;
try {
await getDataManager().nudgeGroupMember(request);
connection.onNudged(who);
} catch (e) {
handleError(e, () => nudge(request, who), () => dismissError(), "Retry");
}
}
void leave(String groupId) async {
if (!await hasNetwork(() => leave(groupId))) return;
showLoading("Leaving");
try {
await getDataManager().leaveGroupEntry(IdRequest(id: groupId));
closeLoading();
connection.onLeft();
} catch (e) {
handleError(e, () => leave(groupId), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,43 @@
import '../../about/external/data/Group.dart';
import '../../about/external/initial/GroupRequest.dart';
import '../../about/external/initial/JoinGroupRequest.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectGroupEditor.dart';
class ViewGroupEditor extends ParentViewModel {
ConnectGroupEditor connection;
ViewGroupEditor(super.context, this.connection);
void create(GroupRequest request) async {
if (!await hasNetwork(() => create(request))) return;
showLoading("Creating the group");
try {
final response = await getDataManager().saveGroupEntry(request);
closeLoading();
connection.onGroupReady(Group.fromJson(response.data));
} catch (e) {
handleError(e, () => create(request), () => dismissError(), "Retry");
}
}
void join(JoinGroupRequest request) async {
if (!await hasNetwork(() => join(request))) return;
showLoading("Joining");
try {
final response = await getDataManager().joinGroupEntry(request);
closeLoading();
connection.onGroupReady(Group.fromJson(response.data));
} catch (e) {
handleError(e, () => join(request), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,25 @@
import '../../about/external/initial/GroupGoalRequest.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectGroupGoalEditor.dart';
class ViewGroupGoalEditor extends ParentViewModel {
ConnectGroupGoalEditor connection;
ViewGroupGoalEditor(super.context, this.connection);
void save(GroupGoalRequest request) async {
if (!await hasNetwork(() => save(request))) return;
showLoading("Saving the shared goal");
try {
await getDataManager().saveGroupGoalEntry(request);
closeLoading();
connection.onGoalSaved();
} catch (e) {
handleError(e, () => save(request), () => dismissError(), "Retry");
}
}
}

View File

@@ -0,0 +1,34 @@
import '../../about/external/data/pages/request/HistoryRequest.dart';
import '../../about/external/data/pages/request/PageAndSort.dart';
import '../../about/external/data/pages/request/Pageable.dart';
import '../../about/external/data/pages/request/Sort.dart';
import '../../utils/ObjectConvertors.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectGroups.dart';
class ViewGroups extends ParentViewModel {
ConnectGroups connection;
ViewGroups(super.context, this.connection);
void loadGroups() async {
if (!await hasNetwork(() => loadGroups())) return;
showLoading("Loading your groups");
try {
final response = await getDataManager().getMyGroups(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'lastMessageAt'),
page: Pageable(0, 0, 50, 0),
),
));
closeLoading();
connection.onGroupsLoaded(getGroupList(response.data));
} catch (e) {
handleError(e, () => loadGroups(), () => dismissError(), "Retry");
}
}
}

View File

@@ -1,7 +1,10 @@
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart'; import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/Group.dart';
import '../../about/external/data/ReportCard.dart';
import '../../about/external/data/RoutineChain.dart'; import '../../about/external/data/RoutineChain.dart';
import '../../about/internal/application/Standing.dart'; import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/TodoItem.dart';
import '../../about/internal/application/UserDetails.dart'; import '../../about/internal/application/UserDetails.dart';
abstract class ConnectHome { abstract class ConnectHome {
@@ -14,6 +17,16 @@ abstract class ConnectHome {
/// The routine library, for the up-next rail. /// The routine library, for the up-next rail.
void onRoutinesLoaded(List<RoutineChain> routines); void onRoutinesLoaded(List<RoutineChain> routines);
/// This week's report card, for the streak row, the score and the weekly
/// view. Null when it could not be loaded.
void onWeekLoaded(ReportCard? week);
/// Your groups, for the rail on Home.
void onGroupsLoaded(List<Group> groups);
/// The quick to-do list, read from the device.
void onTodosLoaded(List<TodoItem> todos);
/// Standing arrives derived, with the debt it was derived from. /// Standing arrives derived, with the debt it was derived from.
void onStandingResolved(Standing standing, double debtScore); void onStandingResolved(Standing standing, double debtScore);

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import '../../about/external/data/Commitment.dart'; import '../../about/external/data/Commitment.dart';
import '../../about/external/data/ExcuseCluster.dart'; import '../../about/external/data/ExcuseCluster.dart';
import '../../about/external/data/Group.dart';
import '../../about/external/data/ReportCard.dart';
import '../../about/external/data/RoutineChain.dart'; import '../../about/external/data/RoutineChain.dart';
import '../../about/external/data/pages/request/CommitmentsRequest.dart'; import '../../about/external/data/pages/request/CommitmentsRequest.dart';
import '../../about/external/data/pages/request/HistoryRequest.dart'; import '../../about/external/data/pages/request/HistoryRequest.dart';
@@ -10,8 +12,11 @@ import '../../about/external/data/pages/response/CommitmentPage.dart';
import '../../about/external/initial/CompletionRequest.dart'; import '../../about/external/initial/CompletionRequest.dart';
import '../../about/external/initial/ReportCardRequest.dart'; import '../../about/external/initial/ReportCardRequest.dart';
import '../../about/internal/application/CommitmentClass.dart'; import '../../about/internal/application/CommitmentClass.dart';
import '../../about/internal/application/ProgressTargets.dart';
import '../../about/internal/application/Standing.dart'; import '../../about/internal/application/Standing.dart';
import '../../about/internal/application/TodoItem.dart';
import '../../about/internal/application/UserDetails.dart'; import '../../about/internal/application/UserDetails.dart';
import '../../configs/NotificationServiceConfig.dart';
import '../../utils/DebtEngine.dart'; import '../../utils/DebtEngine.dart';
import '../../utils/GuardrailEngine.dart'; import '../../utils/GuardrailEngine.dart';
import '../../utils/ObjectConvertors.dart'; import '../../utils/ObjectConvertors.dart';
@@ -31,9 +36,68 @@ class ViewHome extends ParentViewModel {
final UserDetails cached = await getDataManager().getUserDetails(); final UserDetails cached = await getDataManager().getUserDetails();
connection.onUserLoaded(cached); connection.onUserLoaded(cached);
loadTodos();
loadWeek();
loadGroups();
loadPlan(); loadPlan();
} }
/// The last seven days. Quiet on failure: the header falls back to today.
void loadWeek() async {
try {
final DateTime now = DateTime.now();
final DateTime start = DateTime(now.year, now.month, now.day)
.subtract(const Duration(days: 6));
final response =
await getDataManager().getWeeklyReportCard(ReportCardRequest(
periodStart: start.toIso8601String(),
periodEnd: now.toIso8601String(),
));
connection.onWeekLoaded(ReportCard.fromJson(response.data));
} catch (e) {
connection.onWeekLoaded(null);
}
}
/// The groups rail. Quiet on failure: the rail just stays hidden.
void loadGroups() async {
try {
final response = await getDataManager().getMyGroups(HistoryRequest(
query: PageAndSort(
sort: Sort('desc', 'lastMessageAt'),
page: Pageable(0, 0, 20, 0),
),
));
connection.onGroupsLoaded(getGroupList(response.data));
} catch (e) {
connection.onGroupsLoaded(<Group>[]);
}
}
/// A new profile photo, kept on the device with the rest of the user's
/// details. There is no upload endpoint yet, so it does not leave the phone.
void savePhoto(String pic) async {
final UserDetails details = await getDataManager().getUserDetails();
details.pic = pic;
await getDataManager().setUserDetails(details);
connection.onUserLoaded(details);
}
void loadTodos() async {
connection.onTodosLoaded(await getDataManager().getTodos());
}
/// Saves the whole list; it is small and only ever lives on the device.
void saveTodos(List<TodoItem> todos) async {
await getDataManager().setTodos(todos);
}
void loadPlan() async { void loadPlan() async {
if (!await hasNetwork(() => loadPlan())) return; if (!await hasNetwork(() => loadPlan())) return;
@@ -72,12 +136,26 @@ class ViewHome extends ParentViewModel {
), ),
)); ));
connection.onRoutinesLoaded(getRoutineChainList(response.data)); final List<RoutineChain> routines = getRoutineChainList(response.data);
connection.onRoutinesLoaded(routines);
_placeReminders(routines);
} catch (e) { } catch (e) {
connection.onRoutinesLoaded(<RoutineChain>[]); connection.onRoutinesLoaded(<RoutineChain>[]);
} }
} }
/// Reminders are one-shots for the coming week, so they are re-placed
/// whenever the schedule is read.
void _placeReminders(List<RoutineChain> routines) async {
final ProgressTargets targets = await getDataManager().getProgressTargets();
if (targets.reminders) {
await LocalNotificationEngine.scheduleRoutineReminders(
routines, targets.reminderLead);
}
}
void loadOverdue() async { void loadOverdue() async {
try { try {
final response = await getDataManager().getOverdueQueue(HistoryRequest( final response = await getDataManager().getOverdueQueue(HistoryRequest(

View File

@@ -184,8 +184,8 @@ class LiveTaskState extends State<LiveTask>
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorSheetBackground, color: colorSheetBackground,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28), topLeft: Radius.circular(16),
topRight: Radius.circular(28), topRight: Radius.circular(16),
), ),
), ),
child: SingleChildScrollView( child: SingleChildScrollView(

View File

@@ -107,8 +107,8 @@ class LoginState extends State<Login> implements ConnectLogin {
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorPrimaryLight, color: colorPrimaryLight,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28), topLeft: Radius.circular(16),
topRight: Radius.circular(28), topRight: Radius.circular(16),
), ),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,

Some files were not shown because too many files have changed in this diff Show More