Compare commits
9 Commits
36bc41509d
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b5d105400 | ||
|
|
72786701a9 | ||
|
|
99225c2ee2 | ||
|
|
4cd3f6c038 | ||
|
|
d3431ca5de | ||
|
|
add94f621c | ||
|
|
4b71257429 | ||
|
|
4548e1f164 | ||
|
|
9c858b8146 |
@@ -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}`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
13
frontend/assets/images/PEOPLE-CREDITS.txt
Normal 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.
|
||||||
BIN
frontend/assets/images/person_ade.jpg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/assets/images/person_alex.jpg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
frontend/assets/images/person_jordan.jpg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
frontend/assets/images/person_lena.jpg
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
frontend/assets/images/person_mei.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/assets/images/person_priya.jpg
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
frontend/assets/images/person_sam.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/assets/images/person_tom.jpg
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
92
frontend/fonts/LICENSE-Inter.txt
Normal 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.
|
||||||
94
frontend/lib/Grounded/about/external/data/Group.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
frontend/lib/Grounded/about/external/data/GroupGoal.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
frontend/lib/Grounded/about/external/data/GroupMember.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
frontend/lib/Grounded/about/external/data/GroupMessage.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
frontend/lib/Grounded/about/external/data/GroupObjective.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/lib/Grounded/about/external/initial/GroupGoalRequest.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/lib/Grounded/about/external/initial/GroupMessageRequest.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/lib/Grounded/about/external/initial/GroupRequest.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/lib/Grounded/about/external/initial/JoinGroupRequest.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/lib/Grounded/about/external/initial/NudgeRequest.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/lib/Grounded/about/external/initial/ObjectiveDraft.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
frontend/lib/Grounded/about/external/initial/ObjectiveProgressRequest.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
39
frontend/lib/Grounded/designs/DashedOutline.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
132
frontend/lib/Grounded/designs/FloatingNavState.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
54
frontend/lib/Grounded/designs/MemberAvatar.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/lib/Grounded/designs/Portrait.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
500
frontend/lib/Grounded/mock/MockGroups.dart
Normal 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};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
14
frontend/lib/Grounded/see/coach/Coach.dart
Normal 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();
|
||||||
|
}
|
||||||
562
frontend/lib/Grounded/see/coach/CoachState.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/lib/Grounded/see/coach/ConnectCoach.dart
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import '../../about/internal/application/CoachMessage.dart';
|
||||||
|
|
||||||
|
abstract class ConnectCoach {
|
||||||
|
void onAnswered(CoachMessage answer);
|
||||||
|
}
|
||||||
107
frontend/lib/Grounded/see/coach/ViewCoach.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
10
frontend/lib/Grounded/see/group/ConnectGroupChat.dart
Normal 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);
|
||||||
|
}
|
||||||
12
frontend/lib/Grounded/see/group/ConnectGroupDetail.dart
Normal 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();
|
||||||
|
}
|
||||||
6
frontend/lib/Grounded/see/group/ConnectGroupEditor.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
abstract class ConnectGroupGoalEditor {
|
||||||
|
void onGoalSaved();
|
||||||
|
}
|
||||||
5
frontend/lib/Grounded/see/group/ConnectGroups.dart
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import '../../about/external/data/Group.dart';
|
||||||
|
|
||||||
|
abstract class ConnectGroups {
|
||||||
|
void onGroupsLoaded(List<Group> groups);
|
||||||
|
}
|
||||||
13
frontend/lib/Grounded/see/group/GroupChat.dart
Normal 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();
|
||||||
|
}
|
||||||
497
frontend/lib/Grounded/see/group/GroupChatState.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/lib/Grounded/see/group/GroupDetail.dart
Normal 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();
|
||||||
|
}
|
||||||
731
frontend/lib/Grounded/see/group/GroupDetailState.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/lib/Grounded/see/group/GroupEditor.dart
Normal 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();
|
||||||
|
}
|
||||||
294
frontend/lib/Grounded/see/group/GroupEditorState.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/lib/Grounded/see/group/GroupGoalEditor.dart
Normal 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();
|
||||||
|
}
|
||||||
317
frontend/lib/Grounded/see/group/GroupGoalEditorState.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/lib/Grounded/see/group/Groups.dart
Normal 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();
|
||||||
|
}
|
||||||
251
frontend/lib/Grounded/see/group/GroupsState.dart
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/lib/Grounded/see/group/ViewGroupChat.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
frontend/lib/Grounded/see/group/ViewGroupDetail.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/lib/Grounded/see/group/ViewGroupEditor.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/lib/Grounded/see/group/ViewGroupGoalEditor.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
frontend/lib/Grounded/see/group/ViewGroups.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||